convSessions held the DingTalk-conversation to agent-session map purely in memory, so a connector restart dropped every chat's multi-turn context - the core weakness of an always-on digital employee. Persist the map to ~/.dws/connect/<clientId>/sessions.json (scoped per clientId so multiple bots on one machine stay isolated), reusing the existing config dir, 0600 FilePerm and lock-id sanitizer conventions: - newConvSessions restores the map on startup; a missing or corrupt file degrades to an empty map with a warning, never a panic or a blocked connection. - args/reset persist after every mutation, under the existing mutex so the snapshot is race-free, via a temp-file + rename atomic write. - saves are best-effort: a write failure only logs a warning and never blocks message handling. - an empty path (clientId missing or --agent-memory off) keeps the map in memory only, preserving the original behaviour exactly. forwarderForChannel now takes clientId to derive the per-bot store path. Adds unit tests for reload, corrupt-file degradation, in-memory mode and concurrent access (race-clean).
112 lines
4.3 KiB
Go
112 lines
4.3 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// connectSessionStorePath returns the on-disk location for a robot's
|
|
// conversation→session map, scoped by clientId so multiple bots on one machine
|
|
// stay isolated: <config dir>/connect/<clientId>/sessions.json. An empty
|
|
// clientId means "do not persist" (the caller keeps the map in memory only,
|
|
// preserving the pre-persistence behaviour). The clientId is sanitized with the
|
|
// same rule as the connect lock file so it is always filesystem-safe.
|
|
func connectSessionStorePath(clientID string) string {
|
|
if clientID == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "sessions.json")
|
|
}
|
|
|
|
// loadConvSessionMap reads a persisted conversation→session map from path. It is
|
|
// deliberately forgiving: a missing file (first run) or a corrupt/unparseable
|
|
// file degrades to an empty map plus a warning, never an error or panic, so a
|
|
// bad file on disk can never block the connector from coming up. An empty path
|
|
// (persistence disabled) also yields an empty map.
|
|
func loadConvSessionMap(path string) map[string]string {
|
|
if path == "" {
|
|
return make(map[string]string)
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 读取会话存档失败,按空会话起:%v\n", err)
|
|
}
|
|
return make(map[string]string)
|
|
}
|
|
m := make(map[string]string)
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 会话存档已损坏,按空会话起:%v\n", err)
|
|
return make(map[string]string)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// saveConvSessionMap atomically persists m to path with 0600 perms. It is
|
|
// best-effort: any failure (mkdir, marshal, write, rename) only logs a warning
|
|
// and returns — message handling must never block or fail because the session
|
|
// snapshot could not be written. The write is atomic (temp file in the same
|
|
// directory + rename) so a crash mid-write can never leave a half-written,
|
|
// later-unparseable file. An empty path is a no-op (persistence disabled).
|
|
func saveConvSessionMap(path string, m map[string]string) {
|
|
if path == "" {
|
|
return
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话存档目录失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
// map[string]string never fails to marshal, but stay defensive.
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 序列化会话失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
tmp, err := os.CreateTemp(dir, "sessions-*.json.tmp")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话临时文件失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
tmpName := tmp.Name()
|
|
if err := tmp.Chmod(config.FilePerm); err != nil {
|
|
tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 设置会话文件权限失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 写入会话临时文件失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 关闭会话临时文件失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 原子替换会话存档失败,跳过本次落盘:%v\n", err)
|
|
return
|
|
}
|
|
}
|