@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.
Files changed (47) hide show
  1. package/client/bot-list.js +243 -0
  2. package/client/bot-settings.js +175 -0
  3. package/client/bot-shared-settings.js +561 -0
  4. package/client/chat-ui.js +134 -0
  5. package/client/context-enhancement.js +435 -0
  6. package/client/delivery-targets.js +334 -0
  7. package/client/diagnostics.js +160 -0
  8. package/client/i18n.js +371 -0
  9. package/client/index.js +77 -0
  10. package/client/list-order.js +144 -0
  11. package/client/rpc.js +52 -0
  12. package/client/scoped-mode-editor.js +111 -0
  13. package/client/section.js +250 -0
  14. package/client/session-badges.js +263 -0
  15. package/client/styles.js +960 -0
  16. package/client/version-panel.js +97 -0
  17. package/cordis.patch.yml +5 -0
  18. package/host/bot-model.mjs +53 -0
  19. package/host/bot-settings.mjs +247 -0
  20. package/host/channel-registry.mjs +237 -0
  21. package/host/commands.mjs +857 -0
  22. package/host/deferred.mjs +291 -0
  23. package/host/delivery.mjs +377 -0
  24. package/host/file-log.mjs +169 -0
  25. package/host/guidance.mjs +73 -0
  26. package/host/index.mjs +7 -0
  27. package/host/interactions.mjs +330 -0
  28. package/host/json-store.mjs +144 -0
  29. package/host/log-tail.mjs +63 -0
  30. package/host/panel.mjs +1012 -0
  31. package/host/paths.mjs +50 -0
  32. package/host/plugin.mjs +873 -0
  33. package/host/prompt-context.mjs +70 -0
  34. package/host/rpc.mjs +147 -0
  35. package/host/session-keys.mjs +25 -0
  36. package/host/session-store.mjs +187 -0
  37. package/host/sessions.mjs +1348 -0
  38. package/host/tools.mjs +283 -0
  39. package/lib/client.js +4431 -0
  40. package/lib/index.js +5676 -0
  41. package/package.json +63 -0
  42. package/shared/access-policy.mjs +263 -0
  43. package/shared/channel-rail.mjs +156 -0
  44. package/shared/context-enhancement.mjs +415 -0
  45. package/shared/contract.mjs +120 -0
  46. package/shared/panel-sections.mjs +76 -0
  47. package/shared/reply-reference.mjs +115 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * 延迟交付:**超时放手之后,继续有界地盯着这一轮的终态,拿到结果就补发**。
3
+ *
4
+ * 为什么需要它:`ask()` 的超时兜底只防"流断了/回合卡死",不是长任务。可一旦判定超时,
5
+ * 我们就把 follow 流收掉、渠道只回一句「回合未正常结束(timeout)」——如果那一轮其实之后
6
+ * 跑完了,**最终答案谁也拿不到**("回合跑完但用户没收到"这条故障线本仓库栽过两次)。
7
+ *
8
+ * 做法(照上游 dsh-im 的 deferred-delivery 收窄):
9
+ * - 超时时只**登记一条待交付记录**(会话路由 + sessionId + turn + 超时原因),不重问、不重跑;
10
+ * - 之后**有界复查**:先等 `firstCheckMs`,再每 `intervalMs` 一次,最多盯 `maxAgeMs`;
11
+ * 每次用调用方给的 `probe` 问一句"这个会话还在跑吗、最后一条助手正文是什么";
12
+ * - 会话空闲且拿到非空正文 → 交给渠道注册的 `deliver` 补发,然后删记录;
13
+ * - 会话没了 / 一直空闲但没有正文 / 盯满时限 → 删记录并留日志(不静默)。
14
+ *
15
+ * 语义边界(与上游一致):平台侧没有消息事务,**不承诺恰好一次**——补发过但删记录前崩溃、
16
+ * 或同一 chat 里紧接着又有一轮很快结束,都可能重复补发一次。宁可能重复,也不丢。
17
+ *
18
+ * 记录落盘(`deferred.json`):进程重启后由渠道在重新建桥时 `register`,届时重新起盯;
19
+ * 盯不到就按上面的规则清掉。
20
+ *
21
+ * @module dsh-chat/host/deferred
22
+ */
23
+
24
+ import { join } from 'node:path';
25
+
26
+ import { createJsonStore } from './json-store.mjs';
27
+
28
+ /** 超时后第一次复查的等待:给它一点时间自己跑完(真机上"刚好差一点"是最常见的情况)。 */
29
+ const FIRST_CHECK_MS = 60_000;
30
+ /** 之后每次复查的间隔。 */
31
+ const INTERVAL_MS = 30_000;
32
+ /** 最多盯多久:超过就认了(记录删掉、日志留痕)。 */
33
+ const MAX_AGE_MS = 30 * 60_000;
34
+ /** 同一个会话键最多留几条:多了说明这个 chat 一直在超时,留最新的就够。 */
35
+ const MAX_PER_KEY = 2;
36
+ /** 正文长度上限:与最终答案同一个量级,防一条记录把状态文件撑爆。 */
37
+ const MAX_TEXT = 8_000;
38
+
39
+ /**
40
+ * 创建延迟交付服务。
41
+ *
42
+ * @param options - {
43
+ * dataDir, logger, probe, firstCheckMs?, intervalMs?, maxAgeMs?,
44
+ * }。
45
+ * `probe({ record })` 由 hub 提供:返回 `{ exists, running, text }`(会话在不在、是否还在跑、
46
+ * 最后一条助手正文)。这样这个模块不碰 DSH 的细节,只负责"盯 + 补发"。
47
+ * @returns 服务:ready / register / schedule / checkNow / list / forgetKey / stop。
48
+ */
49
+ export function createDeferredDelivery({
50
+ dataDir,
51
+ logger = console,
52
+ probe,
53
+ firstCheckMs = FIRST_CHECK_MS,
54
+ intervalMs = INTERVAL_MS,
55
+ maxAgeMs = MAX_AGE_MS,
56
+ } = {}) {
57
+ if (typeof dataDir !== 'string' || !dataDir.trim()) {
58
+ throw new TypeError('延迟交付需要 dataDir。');
59
+ }
60
+ if (typeof probe !== 'function') throw new TypeError('延迟交付需要 probe。');
61
+
62
+ const store = createJsonStore({
63
+ path: join(dataDir, 'deferred.json'),
64
+ empty: () => ({ version: 1, records: [] }),
65
+ normalize: (value) => {
66
+ const source = value && typeof value === 'object' && Array.isArray(value.records) ? value : { records: [] };
67
+ return {
68
+ version: 1,
69
+ records: source.records.filter((row) => row && typeof row === 'object'
70
+ && typeof row.id === 'string' && typeof row.sessionId === 'string'
71
+ && typeof row.channelId === 'string' && typeof row.botId === 'string'
72
+ && typeof row.key === 'string').map((row) => ({
73
+ id: row.id,
74
+ channelId: row.channelId,
75
+ botId: row.botId,
76
+ key: row.key,
77
+ sessionId: row.sessionId,
78
+ turn: Number.isInteger(row.turn) ? row.turn : null,
79
+ startedAt: Number.isFinite(row.startedAt) ? row.startedAt : null,
80
+ timedOutAt: Number.isFinite(row.timedOutAt) ? row.timedOutAt : Date.now(),
81
+ reason: typeof row.reason === 'string' ? row.reason : 'timeout',
82
+ attempts: Number.isInteger(row.attempts) ? row.attempts : 0,
83
+ lastError: typeof row.lastError === 'string' ? row.lastError : null,
84
+ })),
85
+ };
86
+ },
87
+ logger,
88
+ label: '延迟交付记录',
89
+ });
90
+
91
+ /** `${channelId}:${botId}` → 渠道注册的补发函数(渠道建桥时注册)。 */
92
+ const deliverers = new Map();
93
+ /** id → 定时器(unref,不拖住进程退出)。 */
94
+ const timers = new Map();
95
+ let stopped = false;
96
+ let seq = 0;
97
+
98
+ const keyOf = (row) => `${row.channelId}:${row.botId}`;
99
+ const find = (id) => store.snapshot().records.find((row) => row.id === id) ?? null;
100
+
101
+ function clearTimer(id) {
102
+ const timer = timers.get(id);
103
+ if (timer) {
104
+ clearTimeout(timer);
105
+ timers.delete(id);
106
+ }
107
+ }
108
+
109
+ /** 记一条:落盘 + 起盯。 */
110
+ async function persist(records) {
111
+ await store.update(() => ({ version: 1, records }));
112
+ }
113
+
114
+ async function drop(id, why) {
115
+ clearTimer(id);
116
+ const row = find(id);
117
+ if (!row) return;
118
+ await persist(store.snapshot().records.filter((item) => item.id !== id));
119
+ logger.info?.(`[dsh-chat] 延迟交付记录已清理(${why}):${row.channelId}/${row.botId} ${row.key}`
120
+ + ` 会话=${row.sessionId}`);
121
+ }
122
+
123
+ /** 一次复查:还在跑就继续等,空闲且有正文就补发。 */
124
+ async function check(id) {
125
+ if (stopped) return;
126
+ const row = find(id);
127
+ if (!row) {
128
+ clearTimer(id);
129
+ return;
130
+ }
131
+ const age = Date.now() - (row.timedOutAt ?? Date.now());
132
+ let state = null;
133
+ try {
134
+ state = await probe({ record: row });
135
+ } catch (error) {
136
+ // 读不到不算"跑完了":记下来继续盯(失败必须可见)。
137
+ logger.warn?.(`[dsh-chat] 延迟交付复查失败(${row.key}):${error?.message ?? error}`);
138
+ await bump(id, { lastError: error?.message ?? String(error) });
139
+ }
140
+ if (state?.exists === false) {
141
+ await drop(id, '会话已不存在');
142
+ return;
143
+ }
144
+ if (state?.rebound === true) {
145
+ // 这个聊天已经换到别的会话了:旧结果发过去是错的,直接作废。
146
+ await drop(id, '聊天已换绑到别的会话');
147
+ return;
148
+ }
149
+ if (state && state.running !== true) {
150
+ const text = typeof state.text === 'string' ? state.text.trim() : '';
151
+ if (text) {
152
+ const deliver = deliverers.get(keyOf(row));
153
+ if (typeof deliver !== 'function') {
154
+ // 渠道还没注册(例如重启后先读到了记录):再等等,别丢。
155
+ logger.warn?.(`[dsh-chat] 延迟交付还没有可用的发送器(${keyOf(row)}),继续等待`);
156
+ await bump(id, {});
157
+ } else {
158
+ try {
159
+ await deliver({ key: row.key, text: text.slice(0, MAX_TEXT), record: row });
160
+ logger.info?.(`[dsh-chat] 延迟交付已补发:${row.channelId}/${row.botId} ${row.key}`
161
+ + ` ${text.length} 字(超时后 ${Math.round(age / 1000)} 秒)`);
162
+ await drop(id, '已补发');
163
+ return;
164
+ } catch (error) {
165
+ logger.warn?.(`[dsh-chat] 延迟交付补发失败(${row.key}):${error?.message ?? error}`);
166
+ await bump(id, { lastError: error?.message ?? String(error) });
167
+ }
168
+ }
169
+ } else {
170
+ await bump(id, {});
171
+ }
172
+ } else {
173
+ await bump(id, {});
174
+ }
175
+ if (Date.now() - (row.timedOutAt ?? Date.now()) >= maxAgeMs) {
176
+ await drop(id, '超过盯守时限');
177
+ return;
178
+ }
179
+ arm(id, intervalMs);
180
+ }
181
+
182
+ async function bump(id, patch) {
183
+ const records = store.snapshot().records.map((row) => (row.id === id
184
+ ? { ...row, attempts: (row.attempts ?? 0) + 1, ...patch }
185
+ : row));
186
+ await persist(records);
187
+ }
188
+
189
+ function arm(id, delay) {
190
+ clearTimer(id);
191
+ if (stopped) return;
192
+ /**
193
+ * 定时复查是**后台**跑的:它自己抛错没人接——一次落盘失败就会变成
194
+ * unhandledRejection(进程可能因此退出)。这里必须自己收口并留日志。
195
+ */
196
+ const timer = setTimeout(() => {
197
+ void check(id).catch((error) => {
198
+ logger.warn?.(`[dsh-chat] 延迟交付复查异常(${id}):${error?.message ?? error}`);
199
+ });
200
+ }, delay);
201
+ timer.unref?.();
202
+ timers.set(id, timer);
203
+ }
204
+
205
+ return {
206
+ path: store.path,
207
+
208
+ /** 等磁盘文档就绪(与其它 store 一致)。 */
209
+ ready: () => store.ready(),
210
+
211
+ /**
212
+ * 渠道注册"怎么把补发内容发回这个会话"。同一 channel/bot 后注册的覆盖先前的。
213
+ * 注册时顺手把该 bot 的既有记录重新盯起来(重启后的续盯)。
214
+ */
215
+ register({ channelId, botId, deliver }) {
216
+ if (typeof channelId !== 'string' || !channelId) throw new TypeError('register 需要 channelId。');
217
+ if (typeof botId !== 'string' || !botId) throw new TypeError('register 需要 botId。');
218
+ if (typeof deliver !== 'function') throw new TypeError('register 需要 deliver 函数。');
219
+ deliverers.set(`${channelId}:${botId}`, deliver);
220
+ for (const row of store.snapshot().records) {
221
+ if (row.channelId === channelId && row.botId === botId && !timers.has(row.id)) {
222
+ arm(row.id, firstCheckMs);
223
+ }
224
+ }
225
+ },
226
+
227
+ registerCount: () => deliverers.size,
228
+
229
+ /**
230
+ * 登记一条待交付记录(超时那一刻调用)。
231
+ *
232
+ * @param record - `{ channelId, botId, key, sessionId, turn?, startedAt?, reason? }`。
233
+ * @returns 记录 id。
234
+ */
235
+ async schedule({ channelId, botId, key, sessionId, turn = null, startedAt = null, reason = 'timeout' }) {
236
+ await store.ready();
237
+ seq += 1;
238
+ const id = `df-${Date.now().toString(36)}-${seq}`;
239
+ const row = {
240
+ id,
241
+ channelId,
242
+ botId,
243
+ key,
244
+ sessionId,
245
+ turn,
246
+ startedAt,
247
+ timedOutAt: Date.now(),
248
+ reason,
249
+ attempts: 0,
250
+ lastError: null,
251
+ };
252
+ const records = store.snapshot().records;
253
+ const sameKey = (item) => item.channelId === channelId && item.botId === botId && item.key === key;
254
+ const mine = records.filter(sameKey);
255
+ // 同一个会话键最多留 MAX_PER_KEY 条:超出丢最旧的(连同它的定时器一起清)。
256
+ const kept = [...mine, row].slice(-MAX_PER_KEY);
257
+ for (const dropped of mine) {
258
+ if (!kept.includes(dropped)) clearTimer(dropped.id);
259
+ }
260
+ await persist([...records.filter((item) => !sameKey(item)), ...kept]);
261
+ logger.info?.(`[dsh-chat] 这一轮超时了,登记待交付:${channelId}/${botId} ${key}`
262
+ + ` 会话=${sessionId} turn=${turn ?? '?'}(${reason}),${Math.round(firstCheckMs / 1000)} 秒后开始复查`);
263
+ arm(id, firstCheckMs);
264
+ return id;
265
+ },
266
+
267
+ /** 立刻复查一条(测试与排查用)。 */
268
+ checkNow: check,
269
+
270
+ /** 还没交付完的记录(设置页/诊断用)。 */
271
+ list: () => store.snapshot().records.map((row) => ({ ...row })),
272
+
273
+ /** 某个会话键的待交付记录作废(`/stop`、解绑、换绑时调用)。 */
274
+ async forgetKey({ channelId, botId, key, reason = '用户停止' }) {
275
+ const records = store.snapshot().records;
276
+ const doomed = records.filter((row) => row.channelId === channelId
277
+ && row.botId === botId && row.key === key);
278
+ if (doomed.length === 0) return 0;
279
+ for (const row of doomed) clearTimer(row.id);
280
+ await persist(records.filter((row) => !doomed.includes(row)));
281
+ logger.info?.(`[dsh-chat] 作废 ${doomed.length} 条待交付记录(${reason}):${channelId}/${botId} ${key}`);
282
+ return doomed.length;
283
+ },
284
+
285
+ /** 停止所有定时器(进程收摊/测试用);记录保留在磁盘上。 */
286
+ stop() {
287
+ stopped = true;
288
+ for (const id of [...timers.keys()]) clearTimer(id);
289
+ },
290
+ };
291
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * 主动投递(hub 所有):把一段文本推给指定渠道的指定会话。
3
+ *
4
+ * 分工:
5
+ * - hub 持有"目标清单"(存在每机器人设置的 `deliveryTargets`,沿用上游结构)
6
+ * 与调度逻辑;
7
+ * - 渠道提供两件事:`send({ target, text })`(怎么发)与 `discover(botId)`
8
+ * (从自己的会话记录里发现可投递对象)。
9
+ *
10
+ * 这样定时任务/脚本只需要 `dshChat.delivery.send({ channelId, botId, targetId, text })`,
11
+ * 不必知道任何平台细节;新增渠道也只要实现这两个方法。
12
+ *
13
+ * @module dsh-chat/host/delivery
14
+ */
15
+
16
+ import { stat } from 'node:fs/promises';
17
+ import { basename, isAbsolute, resolve } from 'node:path';
18
+
19
+ /** 目标 id 语法(与渠道 id 同款安全字符集)。 */
20
+ const TARGET_ID = /^[A-Za-z0-9_-]{1,64}$/;
21
+ /** 出站文件上限:飞书 im/v1/files 的硬限制就是 30MB,超过它没有任何渠道能发出去。 */
22
+ const MAX_FILE_BYTES = 30 * 1024 * 1024;
23
+ const FILE_NAME_MAX = 120;
24
+ /** 按图片发出去会得到预览与缩略图,比当附件更好用(渠道按 kind 自己决定消息类型)。 */
25
+ const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
26
+ const TARGET_NAME_MAX = 80;
27
+ const ROUTE_MAX_KEYS = 8;
28
+ const ROUTE_VALUE_MAX = 256;
29
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
30
+ const CONTROL_CHARACTER_TEST = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
31
+
32
+ function isPlainObject(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function deliveryError(code, message) {
37
+ const error = new Error(message);
38
+ error.code = code;
39
+ return error;
40
+ }
41
+
42
+ /** route 里只允许"短标量":不存 Secret、不存嵌套结构。 */
43
+ function normalizeRoute(route) {
44
+ if (!isPlainObject(route)) throw deliveryError('chat/bad-target', '投递目标的 route 必须是对象。');
45
+ const keys = Object.keys(route);
46
+ if (keys.length === 0 || keys.length > ROUTE_MAX_KEYS) {
47
+ throw deliveryError('chat/bad-target', `投递目标的 route 需要 1–${ROUTE_MAX_KEYS} 个字段。`);
48
+ }
49
+ const normalized = {};
50
+ for (const key of keys) {
51
+ const value = route[key];
52
+ if (!/^[A-Za-z][A-Za-z0-9]{0,31}$/u.test(key)) {
53
+ throw deliveryError('chat/bad-target', `route 字段名不合法:${key}`);
54
+ }
55
+ if (typeof value !== 'string' || !value.trim() || value.length > ROUTE_VALUE_MAX
56
+ || CONTROL_CHARACTER_TEST.test(value)) {
57
+ throw deliveryError('chat/bad-target', `route.${key} 必须是非空短字符串。`);
58
+ }
59
+ normalized[key] = value.replace(CONTROL_CHARACTERS, '').trim();
60
+ }
61
+ return Object.freeze(normalized);
62
+ }
63
+
64
+ /**
65
+ * 校验一个投递目标。
66
+ *
67
+ * `renamed` 标记"这个名字是用户自己起的":渠道补名字(`decorateTargets`)时不许覆盖它
68
+ * ——否则用户刚改完的名字,下一次打开设置页就被平台群名顶回去了。
69
+ *
70
+ * @param input - { id, name?, kind, route, renamed? }。
71
+ * @returns 冻结后的目标。
72
+ */
73
+ export function normalizeTarget(input) {
74
+ if (!isPlainObject(input)) throw deliveryError('chat/bad-target', '投递目标必须是对象。');
75
+ const { id, name, kind, route, renamed } = input;
76
+ if (typeof id !== 'string' || !TARGET_ID.test(id)) {
77
+ throw deliveryError('chat/bad-target', '投递目标 id 只能是 1–64 位字母/数字/下划线/连字符。');
78
+ }
79
+ if (kind !== 'direct' && kind !== 'group') {
80
+ throw deliveryError('chat/bad-target', '投递目标 kind 只能是 direct 或 group。');
81
+ }
82
+ const label = typeof name === 'string' ? name.replace(CONTROL_CHARACTERS, '').trim() : '';
83
+ if (label.length > TARGET_NAME_MAX) {
84
+ throw deliveryError('chat/bad-target', `投递目标名称不得超过 ${TARGET_NAME_MAX} 个字符。`);
85
+ }
86
+ return Object.freeze({
87
+ id,
88
+ name: label,
89
+ kind,
90
+ route: normalizeRoute(route),
91
+ // 空名字等于"取消自定义",这时渠道给什么名字就用什么。
92
+ renamed: renamed === true && label.length > 0,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * 把一次"要发的文件"归一化:解析路径、校验存在与大小、收敛文件名。
98
+ *
99
+ * 相对路径按**该机器人的工作区**解析——agent 生成报表时用的就是会话工作目录,
100
+ * 让它写 `报表.xlsx` 而不是绝对路径才是顺手的。
101
+ *
102
+ * @param options - { path, name, workspace }。
103
+ * @returns 冻结的 { path, name, size }。
104
+ */
105
+ async function resolveOutboundFile({ path: inputPath, name, workspace }) {
106
+ const raw = typeof inputPath === 'string' ? inputPath.trim() : '';
107
+ if (!raw) throw deliveryError('chat/bad-file', '发送文件需要 path。');
108
+ const absolute = isAbsolute(raw) ? raw : resolve(workspace ?? process.cwd(), raw);
109
+ let stats;
110
+ try {
111
+ stats = await stat(absolute);
112
+ } catch {
113
+ throw deliveryError('chat/file-not-found', `找不到文件:${absolute}`);
114
+ }
115
+ if (!stats.isFile()) throw deliveryError('chat/bad-file', `不是普通文件:${absolute}`);
116
+ if (stats.size === 0) throw deliveryError('chat/bad-file', `文件是空的,无法发送:${absolute}`);
117
+ if (stats.size > MAX_FILE_BYTES) {
118
+ const mb = (stats.size / 1024 / 1024).toFixed(1);
119
+ throw deliveryError('chat/file-too-large',
120
+ `文件 ${mb}MB 超过 ${MAX_FILE_BYTES / 1024 / 1024}MB 上限:${absolute}`);
121
+ }
122
+ const label = typeof name === 'string' ? name.replace(CONTROL_CHARACTERS, '').trim() : '';
123
+ const finalName = (label || basename(absolute)).slice(0, FILE_NAME_MAX);
124
+ const ext = finalName.split('.').pop()?.toLowerCase() ?? '';
125
+ const kind = IMAGE_EXTENSIONS.has(ext) ? 'image' : 'file';
126
+ return Object.freeze({ path: absolute, name: finalName, size: stats.size, kind });
127
+ }
128
+
129
+ /**
130
+ * 会话身份键:同一会话可能以不同 id 出现(旧数据 `tgt_xxx` vs 渠道派生的 `group_xxx`),
131
+ * 因此判重按"类型 + 路由"而不是 id。
132
+ *
133
+ * @param target - 归一化后的目标。
134
+ * @returns 稳定的字符串键。
135
+ */
136
+ function routeKey(target) {
137
+ const route = Object.entries(target.route)
138
+ .sort(([left], [right]) => left.localeCompare(right))
139
+ .map(([key, value]) => `${key}=${value}`)
140
+ .join('\u0001');
141
+ return `${target.kind}\u0000${route}`;
142
+ }
143
+
144
+ /** 容错归一化(读取历史数据用):坏条目丢弃而不是让整表读不出来。 */
145
+ function normalizeStoredTargets(value) { if (!isPlainObject(value)) return {};
146
+ const targets = {};
147
+ for (const [id, target] of Object.entries(value)) {
148
+ try {
149
+ targets[id] = normalizeTarget({ ...target, id });
150
+ } catch {
151
+ // 丢弃坏条目:一条脏数据不该让该机器人所有投递目标失效。
152
+ }
153
+ }
154
+ return targets;
155
+ }
156
+
157
+ /**
158
+ * 创建投递服务。
159
+ *
160
+ * @param options - { settings, logger }。
161
+ * @returns 投递服务。
162
+ */
163
+ /**
164
+ * 创建投递服务。
165
+ *
166
+ * @param options - { settings, sessionStore?, logger? }。
167
+ * `sessionStore` 用来把该机器人**真实聊过的会话**补成候选(见 list 里的说明),
168
+ * 缺席时只依赖渠道的 `discover()`。
169
+ * @returns 投递服务。
170
+ */
171
+ export function createDeliveryService({ settings, sessionStore = null, logger = console }) {
172
+ if (!settings?.read) throw new TypeError('投递服务需要每机器人设置存储。');
173
+ /** @type {Map<string, object>} channelId → 渠道投递实现 */
174
+ const providers = new Map();
175
+
176
+ return Object.freeze({
177
+ /**
178
+ * 渠道注册投递实现(实例创建时由注册表调用,注销时释放)。
179
+ *
180
+ * @param channelId - 渠道 id。
181
+ * @param provider - `{ send({ botId, target, text }), discover?({ botId }) }`。
182
+ * @returns 注销函数。
183
+ */
184
+ attach(channelId, provider) {
185
+ if (typeof provider?.send !== 'function') {
186
+ throw new TypeError(`渠道 ${channelId} 的投递实现缺少 send。`);
187
+ }
188
+ providers.set(channelId, provider);
189
+ return () => {
190
+ if (providers.get(channelId) === provider) providers.delete(channelId);
191
+ };
192
+ },
193
+
194
+ /** @returns 该渠道是否支持发送文件(`sendFile` 可选,能力缺席要能被查出来)。 */
195
+ supportsFile: (channelId) => typeof providers.get(channelId)?.sendFile === 'function',
196
+
197
+ /** @returns 该渠道是否具备主动投递能力。 */
198
+ supports: (channelId) => providers.has(channelId),
199
+
200
+ /** @returns 已保存的投递目标(含渠道发现的候选,候选不落盘)。 */
201
+ async list({ channelId, botId }) {
202
+ const saved = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
203
+ const provider = providers.get(channelId);
204
+ const discovered = [];
205
+ if (typeof provider?.discover === 'function') {
206
+ try {
207
+ discovered.push(...(await provider.discover({ botId })) ?? []);
208
+ } catch (error) {
209
+ logger.warn?.(`[dsh-chat] 渠道 ${channelId} 发现投递目标失败:${error?.message ?? error}`);
210
+ }
211
+ }
212
+ /**
213
+ * 候选的第二个来源:hub 自己的**持久**会话绑定表。
214
+ *
215
+ * 只靠 `discover()`(渠道运行时状态)的话,重启后运行时是空的,设置页就一个候选都没有
216
+ * ——用户会说"看不到添加的入口"。而绑定表里的会话本来就是这个机器人真实聊过的,
217
+ * 由渠道用 `targetFromKey` 把会话键翻译成目标(平台概念只在渠道里)。
218
+ */
219
+ if (typeof provider?.targetFromKey === 'function' && sessionStore) {
220
+ try {
221
+ await sessionStore.ready?.();
222
+ for (const key of Object.keys(sessionStore.entries(channelId, botId))) {
223
+ try {
224
+ const target = provider.targetFromKey(key);
225
+ if (target) discovered.push(target);
226
+ } catch {
227
+ // 单个键翻译失败不该毁掉整份清单
228
+ }
229
+ }
230
+ } catch (error) {
231
+ logger.warn?.(`[dsh-chat] 读取会话绑定失败:${error?.message ?? error}`);
232
+ }
233
+ }
234
+ const savedList = Object.values(saved);
235
+ // 同一个会话可能有两套 id(旧设置里的 tgt_xxx 与渠道派生的 group_xxx),
236
+ // 因此除了 id,还要按"类型 + 路由"判重,否则设置页和 agent 会看到重复条目。
237
+ const known = new Set(savedList.map(routeKey));
238
+ const candidates = [];
239
+ for (const candidate of discovered) {
240
+ try {
241
+ const target = normalizeTarget(candidate);
242
+ const key = routeKey(target);
243
+ if (saved[target.id] || known.has(key)) continue;
244
+ known.add(key);
245
+ candidates.push(Object.freeze({ ...target, discovered: true }));
246
+ } catch {
247
+ // 忽略无法识别的候选
248
+ }
249
+ }
250
+ /**
251
+ * 让人认得出:渠道把 `oc_xxx` / `ou_xxx` 换成群名 / 人名(只有渠道认识平台概念)。
252
+ * 已保存目标也要补——它们当年保存时存下来的常常就是掩码 id。
253
+ * 渠道没实现、或某个名字拿不到,就保持原名称,绝不因此少列目标。
254
+ */
255
+ let listed = [...savedList, ...candidates];
256
+ if (typeof provider?.decorateTargets === 'function' && listed.length > 0) {
257
+ try {
258
+ const decorated = await provider.decorateTargets({
259
+ botId,
260
+ targets: listed.map((target) => ({ ...target })),
261
+ });
262
+ if (Array.isArray(decorated) && decorated.length === listed.length) {
263
+ listed = listed.map((target, index) => {
264
+ // 用户自己起过名字的,平台名字不许顶掉它(真机上会表现为"改完又变回去")。
265
+ if (target.renamed) return target;
266
+ const name = decorated[index]?.name;
267
+ return typeof name === 'string' && name ? { ...target, name } : target;
268
+ });
269
+ }
270
+ } catch (error) {
271
+ logger.warn?.(`[dsh-chat] 渠道 ${channelId} 补充目标名称失败:${error?.message ?? error}`);
272
+ }
273
+ }
274
+ return Object.freeze({
275
+ targets: Object.freeze(listed),
276
+ canSend: providers.has(channelId),
277
+ });
278
+ },
279
+
280
+ /**
281
+ * 保存(或覆盖)一个投递目标。
282
+ *
283
+ * @param options - { channelId, botId, target }。
284
+ */
285
+ async save({ channelId, botId, target }) {
286
+ const normalized = normalizeTarget(target);
287
+ const current = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
288
+ await settings.write(channelId, botId, {
289
+ deliveryTargets: { ...current, [normalized.id]: normalized },
290
+ });
291
+ return normalized;
292
+ },
293
+
294
+ /**
295
+ * 给一个**已保存**的目标改名字(用户自定义名)。
296
+ *
297
+ * 为什么需要:目标名字来自平台(群名/人名),但微信拿不到昵称、飞书缺权限时只有
298
+ * `oc_xxx` / `ou_xxx`——设置页里一排掩码 id,人认不出哪个是哪个。
299
+ * 传空名字 = 取消自定义,回到渠道给的名字。
300
+ */
301
+ async rename({ channelId, botId, targetId, name }) {
302
+ const current = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
303
+ const target = current[targetId];
304
+ if (!target) {
305
+ throw deliveryError('chat/unknown-target',
306
+ `找不到投递目标 ${targetId}(先保存为投递目标,再改名)。`);
307
+ }
308
+ const label = typeof name === 'string' ? name.trim() : '';
309
+ const renamed = normalizeTarget({ ...target, name: label, renamed: label.length > 0 });
310
+ await settings.write(channelId, botId, {
311
+ deliveryTargets: { ...current, [targetId]: renamed },
312
+ });
313
+ return renamed;
314
+ },
315
+
316
+ /** 删除一个投递目标。 */
317
+ async remove({ channelId, botId, targetId }) {
318
+ const current = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
319
+ if (!Object.hasOwn(current, targetId)) return false;
320
+ const next = { ...current };
321
+ delete next[targetId];
322
+ await settings.write(channelId, botId, { deliveryTargets: next });
323
+ return true;
324
+ },
325
+
326
+ /**
327
+ * 发一条文本。
328
+ *
329
+ * @param options - { channelId, botId, targetId, text }。
330
+ * @returns 渠道返回的发送结果。
331
+ */
332
+ async send({ channelId, botId, targetId, text }) {
333
+ const provider = providers.get(channelId);
334
+ if (!provider) {
335
+ throw deliveryError('chat/delivery-unavailable', `渠道 ${channelId} 不支持主动投递。`);
336
+ }
337
+ const content = typeof text === 'string' ? text.trim() : '';
338
+ if (!content) throw deliveryError('chat/empty-text', '投递内容不能为空。');
339
+ const saved = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
340
+ const target = saved[targetId];
341
+ if (!target) {
342
+ throw deliveryError('chat/unknown-target', `找不到投递目标 ${targetId}(先在设置页保存或改用候选目标)。`);
343
+ }
344
+ return provider.send({ botId, target, text: content });
345
+ },
346
+
347
+ /**
348
+ * 发一个文件。
349
+ *
350
+ * 与文本同样的安全边界:**只能发给已保存的目标**;文件本身必须是存在、非空、
351
+ * 不超过上限的普通文件。
352
+ *
353
+ * @param options - { channelId, botId, targetId, path, name? }。
354
+ * @returns 渠道返回的发送结果。
355
+ */
356
+ async sendFile({ channelId, botId, targetId, path, name }) {
357
+ const provider = providers.get(channelId);
358
+ if (!provider) {
359
+ throw deliveryError('chat/delivery-unavailable', `渠道 ${channelId} 不支持主动投递。`);
360
+ }
361
+ if (typeof provider.sendFile !== 'function') {
362
+ throw deliveryError('chat/delivery-unsupported', `渠道 ${channelId} 暂不支持发送文件。`);
363
+ }
364
+ const saved = normalizeStoredTargets(settings.read(channelId, botId).deliveryTargets);
365
+ const target = saved[targetId];
366
+ if (!target) {
367
+ throw deliveryError('chat/unknown-target', `找不到投递目标 ${targetId}(先在设置页保存或改用候选目标)。`);
368
+ }
369
+ const file = await resolveOutboundFile({
370
+ path,
371
+ name,
372
+ workspace: settings.read(channelId, botId).workspace,
373
+ });
374
+ return provider.sendFile({ botId, target, file });
375
+ },
376
+ });
377
+ }