@sidleo3/dsh-chat 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/bot-list.js +243 -0
- package/client/bot-settings.js +175 -0
- package/client/bot-shared-settings.js +561 -0
- package/client/chat-ui.js +134 -0
- package/client/context-enhancement.js +435 -0
- package/client/delivery-targets.js +334 -0
- package/client/diagnostics.js +160 -0
- package/client/i18n.js +371 -0
- package/client/index.js +77 -0
- package/client/list-order.js +144 -0
- package/client/rpc.js +52 -0
- package/client/scoped-mode-editor.js +111 -0
- package/client/section.js +250 -0
- package/client/session-badges.js +263 -0
- package/client/styles.js +960 -0
- package/client/version-panel.js +97 -0
- package/cordis.patch.yml +5 -0
- package/host/bot-model.mjs +53 -0
- package/host/bot-settings.mjs +247 -0
- package/host/channel-registry.mjs +237 -0
- package/host/commands.mjs +857 -0
- package/host/deferred.mjs +291 -0
- package/host/delivery.mjs +377 -0
- package/host/file-log.mjs +169 -0
- package/host/guidance.mjs +73 -0
- package/host/index.mjs +7 -0
- package/host/interactions.mjs +330 -0
- package/host/json-store.mjs +144 -0
- package/host/log-tail.mjs +63 -0
- package/host/panel.mjs +1012 -0
- package/host/paths.mjs +50 -0
- package/host/plugin.mjs +873 -0
- package/host/prompt-context.mjs +70 -0
- package/host/rpc.mjs +147 -0
- package/host/session-keys.mjs +25 -0
- package/host/session-store.mjs +187 -0
- package/host/sessions.mjs +1348 -0
- package/host/tools.mjs +283 -0
- package/lib/client.js +4431 -0
- package/lib/index.js +5676 -0
- package/package.json +63 -0
- package/shared/access-policy.mjs +263 -0
- package/shared/channel-rail.mjs +156 -0
- package/shared/context-enhancement.mjs +415 -0
- package/shared/contract.mjs +120 -0
- package/shared/panel-sections.mjs +76 -0
- package/shared/reply-reference.mjs +115 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把"本会话的增强提示词"注入成**系统提示词的一段**(而不是拼在用户消息前面)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:`dsh-chat` 早先是把提示词块 `<dsh_im_source_guidance>…</dsh_im_source_guidance>`
|
|
5
|
+
* 拼在飞书那条消息的正文前面,于是它成了**用户轮次的内容**——每轮重复、占用户消息的上下文、
|
|
6
|
+
* 还可能被模型当成"用户说的话"。现在改成 DSH 的系统提示词段(system 角色),
|
|
7
|
+
* 用户消息里只剩真正的来源块 `<dsh_im_source>{…}</dsh_im_source>`。
|
|
8
|
+
*
|
|
9
|
+
* 作用域:段是**全局注册**的,但文本是**按 agent 求值**的(`AssembleContext.agent`),
|
|
10
|
+
* 所以只有我们自己的会话(`guidance` 登记表里有值的那种)才会渲染出内容——
|
|
11
|
+
* 这台 Host 上其它会话(Web 聊天、子代理…)拿到的还是空字符串,等价于没这一段。
|
|
12
|
+
* 不用 `agent.ctx` 逐 agent 注册:全局一份 + 按 agent 过滤更简单,也不会随 agent 生死来回注册。
|
|
13
|
+
*
|
|
14
|
+
* 顺序:400——排在部署 persona(0)之后、第一方政策(500+)与工具指导(1000+)之前。
|
|
15
|
+
* 这类"在这个群里该怎么说话"的上下文不该盖过第一方政策,所以放在它们前面。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-chat/host/prompt-context
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 段名(全局唯一;同名重复注册会抛)。 */
|
|
21
|
+
export const SOURCE_GUIDANCE_SECTION = 'dsh-chat:source-guidance';
|
|
22
|
+
|
|
23
|
+
/** 段顺序(见文件头的取舍)。 */
|
|
24
|
+
export const SOURCE_GUIDANCE_ORDER = 400;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从一次组装的上下文里取会话 id。
|
|
28
|
+
*
|
|
29
|
+
* `AssembleContext.agent` 由 `dsh-agent` 扩展提供;诊断类组装可能没有 agent。
|
|
30
|
+
*
|
|
31
|
+
* @param context - 组装上下文 `{ agent? }`。
|
|
32
|
+
* @returns 会话 id 或 null。
|
|
33
|
+
*/
|
|
34
|
+
function sessionIdOf(context) {
|
|
35
|
+
const agent = context?.agent;
|
|
36
|
+
const id = agent?.id ?? agent?.session?.id;
|
|
37
|
+
return typeof id === 'string' && id ? id : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 注册"增强提示词"这一段。
|
|
42
|
+
*
|
|
43
|
+
* @param ctx - Cordis host 上下文。
|
|
44
|
+
* @param guidance - `createGuidanceRegistry()` 的登记表。
|
|
45
|
+
* @param options - { logger }。
|
|
46
|
+
* @returns 是否真的装上了(false = 当前 Host 没有 `systemPrompt`,调用方要退回前缀注入)。
|
|
47
|
+
*/
|
|
48
|
+
export function installSourceGuidanceSection(ctx, guidance, { logger = console } = {}) {
|
|
49
|
+
const systemPrompt = typeof ctx?.get === 'function' ? ctx.get('systemPrompt') : ctx?.systemPrompt;
|
|
50
|
+
if (!systemPrompt || typeof systemPrompt.section !== 'function') return false;
|
|
51
|
+
const register = () => systemPrompt.section({
|
|
52
|
+
name: SOURCE_GUIDANCE_SECTION,
|
|
53
|
+
order: SOURCE_GUIDANCE_ORDER,
|
|
54
|
+
// 文本按 agent 现算:登记表里没有这个会话就返回空,空段在渲染时会被丢掉。
|
|
55
|
+
text: (context) => {
|
|
56
|
+
const sessionId = sessionIdOf(context);
|
|
57
|
+
return (sessionId && guidance.get(sessionId)) || '';
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
if (typeof ctx?.effect === 'function') ctx.effect(register, 'dsh-chat: 增强提示词段');
|
|
62
|
+
else register();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// 注册失败(ctx 已销毁 / 同名段已存在):当作没装上,由调用方退回前缀注入。
|
|
65
|
+
logger.warn?.(`[dsh-chat] 注册增强提示词段失败:${error?.message ?? error}`);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
logger.info?.('[dsh-chat] 增强提示词走系统提示词段(按会话生效,不再拼进用户消息)。');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
package/host/rpc.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub 的 RPC 载体:把每个渠道挂到 `/api/dsh-chat/<channelId>`,
|
|
3
|
+
* hub 自身的控制端点挂在 `/api/dsh-chat/control`。
|
|
4
|
+
*
|
|
5
|
+
* 线路格式与 DSH Connection 的客户端 RPC 载体一致(浏览器侧用
|
|
6
|
+
* `connection.rpc.call('/api', 'dsh-chat/<channelId>', { method, payload })`):
|
|
7
|
+
*
|
|
8
|
+
* 请求 { type:'client-request', rpcId, method:'dsh-chat/<channelId>', payload:{ method, payload } }
|
|
9
|
+
* 响应 { type:'server-response', rpcId, result: { ok:true, value } | { ok:false, error } }
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-chat/host/rpc
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { RPC_PREFIX } from '../shared/contract.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param channelId - 渠道 id(或控制端点 id)。
|
|
18
|
+
* @returns 浏览器侧端点名,如 `dsh-chat/feishu`。
|
|
19
|
+
*/
|
|
20
|
+
export function rpcEndpoint(channelId) {
|
|
21
|
+
return `${RPC_PREFIX}/${channelId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param channelId - 渠道 id。
|
|
26
|
+
* @returns host 侧精确路由路径,如 `/api/dsh-chat/feishu`。
|
|
27
|
+
*/
|
|
28
|
+
export function rpcPath(channelId) {
|
|
29
|
+
return `/api/${rpcEndpoint(channelId)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 成功结果。
|
|
34
|
+
*
|
|
35
|
+
* @param value - 可 JSON 序列化的返回值。
|
|
36
|
+
* @returns 结果对象。
|
|
37
|
+
*/
|
|
38
|
+
export function ok(value) {
|
|
39
|
+
return { ok: true, value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 失败结果。
|
|
44
|
+
*
|
|
45
|
+
* @param code - 稳定错误码。
|
|
46
|
+
* @param message - 面向用户的中文说明。
|
|
47
|
+
* @param details - 附加信息(不得含凭据)。
|
|
48
|
+
* @returns 结果对象。
|
|
49
|
+
*/
|
|
50
|
+
export function fail(code, message, details = {}) {
|
|
51
|
+
return { ok: false, error: { code, message, details } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 把任意异常折成失败结果。 */
|
|
55
|
+
export function failFrom(error, fallbackCode = 'chat/internal') {
|
|
56
|
+
const code = typeof error?.code === 'string' ? error.code : fallbackCode;
|
|
57
|
+
const message = typeof error?.message === 'string' && error.message
|
|
58
|
+
? error.message
|
|
59
|
+
: '聊天插件内部错误。';
|
|
60
|
+
return fail(code, message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function jsonResponse(rpcId, result) {
|
|
64
|
+
// 旧端点可能省略 details,客户端要求该字段存在。
|
|
65
|
+
const value = result?.ok === false
|
|
66
|
+
? { ...result, error: { ...result.error, details: result.error?.details ?? {} } }
|
|
67
|
+
: result;
|
|
68
|
+
return Response.json({ type: 'server-response', rpcId, result: value });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 创建 RPC 载体。
|
|
73
|
+
*
|
|
74
|
+
* @param ctx - host 插件上下文(需要 connection.fetch.register)。
|
|
75
|
+
* @param options - { logger }。
|
|
76
|
+
* @returns { register, registered, disposeAll }。
|
|
77
|
+
*/
|
|
78
|
+
export function createRpcCarrier(ctx, { logger = console } = {}) {
|
|
79
|
+
if (typeof ctx?.connection?.fetch?.register !== 'function') {
|
|
80
|
+
throw new TypeError('dsh-chat 需要 DSH 的 connection.fetch 注册表。');
|
|
81
|
+
}
|
|
82
|
+
const releases = new Map();
|
|
83
|
+
|
|
84
|
+
function register(channelId, handler) {
|
|
85
|
+
const path = rpcPath(channelId);
|
|
86
|
+
const endpoint = rpcEndpoint(channelId);
|
|
87
|
+
if (releases.has(path)) throw new Error(`RPC 路径 ${path} 已被注册。`);
|
|
88
|
+
if (typeof handler !== 'function') throw new TypeError('RPC handler 必须是函数。');
|
|
89
|
+
|
|
90
|
+
const dispose = ctx.connection.fetch.register({
|
|
91
|
+
path,
|
|
92
|
+
methods: ['POST'],
|
|
93
|
+
requestBody: 'buffered',
|
|
94
|
+
async fetch(request) {
|
|
95
|
+
// DSH 已在本处理器之前完成浏览器鉴权与 Host/Origin 信任校验。
|
|
96
|
+
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
|
|
97
|
+
const contentType = request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
98
|
+
if (contentType !== 'application/json') {
|
|
99
|
+
return new Response('content type must be application/json', { status: 415 });
|
|
100
|
+
}
|
|
101
|
+
let message;
|
|
102
|
+
try {
|
|
103
|
+
message = await request.json();
|
|
104
|
+
} catch {
|
|
105
|
+
return new Response('body is not JSON', { status: 400 });
|
|
106
|
+
}
|
|
107
|
+
const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
108
|
+
const call = message?.payload;
|
|
109
|
+
if (message?.type !== 'client-request' || typeof message.rpcId !== 'string'
|
|
110
|
+
|| message.method !== endpoint || call === null || typeof call !== 'object'
|
|
111
|
+
|| Array.isArray(call) || typeof call.method !== 'string'
|
|
112
|
+
|| !Object.hasOwn(call, 'payload')) {
|
|
113
|
+
return jsonResponse(rpcId, fail('chat/bad-request', '无效的聊天插件管理请求。'));
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return jsonResponse(rpcId, await handler(call.method, call.payload, request.signal));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.warn?.(`[dsh-chat] ${endpoint} 处理 ${call.method} 失败:${error?.message ?? error}`);
|
|
119
|
+
return jsonResponse(rpcId, failFrom(error));
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const release = () => {
|
|
125
|
+
if (!releases.delete(path)) return;
|
|
126
|
+
try {
|
|
127
|
+
dispose?.();
|
|
128
|
+
} catch {
|
|
129
|
+
// 已经释放过就算了。
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
releases.set(path, release);
|
|
133
|
+
return release;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
register,
|
|
138
|
+
/** @returns 已注册的路径数。 */
|
|
139
|
+
get registered() {
|
|
140
|
+
return releases.size;
|
|
141
|
+
},
|
|
142
|
+
/** 释放全部路由(插件卸载时)。 */
|
|
143
|
+
disposeAll() {
|
|
144
|
+
for (const release of [...releases.values()]) release();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话键(`p2p:<平台 id>` / `group:<平台 id>`)的小工具。
|
|
3
|
+
*
|
|
4
|
+
* 单独一个模块:面板与命令都要把会话键说成人话,而它们之间不该互相 import。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-chat/host/session-keys
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 会话键的人话名字(`p2p:ou_x` → 「私聊 ou_x」/`group:oc_y` → 「群 oc_y」)。
|
|
11
|
+
*
|
|
12
|
+
* 键里的 id 是**平台 id**:hub 拿不到昵称(那是渠道的事),所以只给类型 + 掩码 id——
|
|
13
|
+
* 用户要照它去找到那个聊天,够用,也不编造。
|
|
14
|
+
*
|
|
15
|
+
* @param key - 会话键。
|
|
16
|
+
* @returns 一行说明。
|
|
17
|
+
*/
|
|
18
|
+
export function chatKeyLabel(key) {
|
|
19
|
+
const raw = typeof key === 'string' ? key : '';
|
|
20
|
+
const [kind, id = ''] = raw.split(':', 2);
|
|
21
|
+
const masked = id.length > 12 ? `${id.slice(0, 12)}…` : id;
|
|
22
|
+
if (kind === 'p2p') return `私聊 ${masked}`;
|
|
23
|
+
if (kind === 'group') return `群 ${masked}`;
|
|
24
|
+
return raw || '未知聊天';
|
|
25
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IM 会话 ↔ DSH 会话的绑定表(hub 所有)。
|
|
3
|
+
*
|
|
4
|
+
* 渠道只提供"会话键"(飞书 `p2p:ou_xxx` / `group:oc_xxx`,微信 `p2p:<user>@im.wechat`),
|
|
5
|
+
* 由 hub 统一维护到 Session id 的映射,因此每个渠道不必各写一份状态文件。
|
|
6
|
+
*
|
|
7
|
+
* 旧实现(dsh-im)的绑定存在渠道自己的 `state.json` 里,渠道加载时用 `adopt()`
|
|
8
|
+
* 把它们灌进来即可继续沿用原会话。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-chat/host/session-store
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { createJsonStore } from './json-store.mjs';
|
|
16
|
+
|
|
17
|
+
const DOCUMENT_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
function isPlainObject(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeDocument(value) {
|
|
24
|
+
const source = isPlainObject(value) && value.version === DOCUMENT_VERSION ? value : {};
|
|
25
|
+
const channels = {};
|
|
26
|
+
if (isPlainObject(source.channels)) {
|
|
27
|
+
for (const [channelId, bots] of Object.entries(source.channels)) {
|
|
28
|
+
if (!isPlainObject(bots)) continue;
|
|
29
|
+
const accounts = {};
|
|
30
|
+
for (const [botId, keys] of Object.entries(bots)) {
|
|
31
|
+
if (!isPlainObject(keys)) continue;
|
|
32
|
+
const entries = {};
|
|
33
|
+
for (const [key, entry] of Object.entries(keys)) {
|
|
34
|
+
const sessionId = typeof entry?.sessionId === 'string' ? entry.sessionId : null;
|
|
35
|
+
if (!sessionId) continue;
|
|
36
|
+
entries[key] = {
|
|
37
|
+
sessionId,
|
|
38
|
+
workspacePath: typeof entry.workspacePath === 'string' ? entry.workspacePath : null,
|
|
39
|
+
boundAt: typeof entry.boundAt === 'string' ? entry.boundAt : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
accounts[botId] = entries;
|
|
43
|
+
}
|
|
44
|
+
channels[channelId] = accounts;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { version: DOCUMENT_VERSION, channels };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 创建会话绑定表。
|
|
52
|
+
*
|
|
53
|
+
* @param options - { dataDir, logger }。
|
|
54
|
+
* @returns 绑定表 API。
|
|
55
|
+
*/
|
|
56
|
+
export function createSessionStore({ dataDir, logger = console } = {}) {
|
|
57
|
+
if (typeof dataDir !== 'string' || !dataDir.trim()) {
|
|
58
|
+
throw new TypeError('session store 需要 dataDir。');
|
|
59
|
+
}
|
|
60
|
+
const store = createJsonStore({
|
|
61
|
+
path: join(dataDir, 'sessions.json'),
|
|
62
|
+
normalize: normalizeDocument,
|
|
63
|
+
empty: () => ({ version: DOCUMENT_VERSION, channels: {} }),
|
|
64
|
+
logger,
|
|
65
|
+
label: '会话绑定',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function entriesOf(channelId, botId) {
|
|
69
|
+
return store.snapshot().channels?.[channelId]?.[botId] ?? {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
path: store.path,
|
|
74
|
+
ready: () => store.ready(),
|
|
75
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns 绑定记录,未绑定时为 undefined。
|
|
79
|
+
*/
|
|
80
|
+
get(channelId, botId, key) {
|
|
81
|
+
const entry = entriesOf(channelId, botId)[key];
|
|
82
|
+
return entry ? Object.freeze({ ...entry }) : undefined;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** @returns 某个机器人的全部绑定(key → entry)。 */
|
|
86
|
+
entries(channelId, botId) {
|
|
87
|
+
return Object.freeze({ ...entriesOf(channelId, botId) });
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 绑定(或更新)一个会话键。
|
|
92
|
+
*
|
|
93
|
+
* @param channelId - 渠道 id。
|
|
94
|
+
* @param botId - 机器人 id。
|
|
95
|
+
* @param key - 渠道侧会话键。
|
|
96
|
+
* @param entry - { sessionId, workspacePath? }。
|
|
97
|
+
*/
|
|
98
|
+
async bind(channelId, botId, key, entry) {
|
|
99
|
+
if (typeof key !== 'string' || !key) throw new TypeError('会话键必填。');
|
|
100
|
+
if (typeof entry?.sessionId !== 'string' || !entry.sessionId) {
|
|
101
|
+
throw new TypeError('绑定需要 sessionId。');
|
|
102
|
+
}
|
|
103
|
+
await store.update((current) => ({
|
|
104
|
+
...current,
|
|
105
|
+
channels: {
|
|
106
|
+
...current.channels,
|
|
107
|
+
[channelId]: {
|
|
108
|
+
...(current.channels[channelId] ?? {}),
|
|
109
|
+
[botId]: {
|
|
110
|
+
...((current.channels[channelId] ?? {})[botId] ?? {}),
|
|
111
|
+
[key]: {
|
|
112
|
+
sessionId: entry.sessionId,
|
|
113
|
+
workspacePath: typeof entry.workspacePath === 'string' ? entry.workspacePath : null,
|
|
114
|
+
boundAt: new Date().toISOString(),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** 解除一个会话键的绑定(下一条消息开新会话)。 */
|
|
123
|
+
async unbind(channelId, botId, key) {
|
|
124
|
+
await store.update((current) => {
|
|
125
|
+
const accounts = current.channels[channelId];
|
|
126
|
+
const keys = accounts?.[botId];
|
|
127
|
+
if (!keys || !Object.hasOwn(keys, key)) return null;
|
|
128
|
+
const nextKeys = { ...keys };
|
|
129
|
+
delete nextKeys[key];
|
|
130
|
+
return {
|
|
131
|
+
...current,
|
|
132
|
+
channels: { ...current.channels, [channelId]: { ...accounts, [botId]: nextKeys } },
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 一次性接管旧实现的绑定(只补空缺,不覆盖已有绑定)。
|
|
139
|
+
*
|
|
140
|
+
* @param channelId - 渠道 id。
|
|
141
|
+
* @param botId - 机器人 id。
|
|
142
|
+
* @param entries - `{ [key]: sessionId | { sessionId, workspacePath? } }`。
|
|
143
|
+
* @returns 实际接管的条数。
|
|
144
|
+
*/
|
|
145
|
+
async adopt(channelId, botId, entries) {
|
|
146
|
+
if (!isPlainObject(entries)) throw new TypeError('adopt 需要 { key: sessionId } 形式。');
|
|
147
|
+
let adopted = 0;
|
|
148
|
+
await store.update((current) => {
|
|
149
|
+
const accounts = current.channels[channelId] ?? {};
|
|
150
|
+
const keys = { ...(accounts[botId] ?? {}) };
|
|
151
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
152
|
+
if (!key || keys[key]) continue;
|
|
153
|
+
const sessionId = typeof value === 'string' ? value : value?.sessionId;
|
|
154
|
+
if (typeof sessionId !== 'string' || !sessionId) continue;
|
|
155
|
+
keys[key] = {
|
|
156
|
+
sessionId,
|
|
157
|
+
workspacePath: isPlainObject(value) && typeof value.workspacePath === 'string'
|
|
158
|
+
? value.workspacePath
|
|
159
|
+
: null,
|
|
160
|
+
boundAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
adopted += 1;
|
|
163
|
+
}
|
|
164
|
+
if (adopted === 0) return null;
|
|
165
|
+
return {
|
|
166
|
+
...current,
|
|
167
|
+
channels: { ...current.channels, [channelId]: { ...accounts, [botId]: keys } },
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
return adopted;
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** 该 Session 属于哪个 (渠道, 机器人, 会话键)——审批/提问回传时用。 */
|
|
174
|
+
locate(sessionId) {
|
|
175
|
+
if (typeof sessionId !== 'string' || !sessionId) return undefined;
|
|
176
|
+
const channels = store.snapshot().channels ?? {};
|
|
177
|
+
for (const [channelId, accounts] of Object.entries(channels)) {
|
|
178
|
+
for (const [botId, keys] of Object.entries(accounts)) {
|
|
179
|
+
for (const [key, entry] of Object.entries(keys)) {
|
|
180
|
+
if (entry.sessionId === sessionId) return Object.freeze({ channelId, botId, key });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|