@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,169 @@
1
+ /**
2
+ * 渠道日志落盘(hub 所有):把 `[dsh-chat-*]` 这些行同时写进文件。
3
+ *
4
+ * 为什么需要它:出故障时唯一的现场是"用户那台终端",而终端输出既搜不了、也不会随
5
+ * 会话导出带走——排查时只能靠人肉回忆滚屏。"发了没反应"这类问题,必须有可检索的日志。
6
+ *
7
+ * 约束:
8
+ * - **日志本身绝不能成为故障源**:任何 IO 异常都被吞掉(最多 stderr 提一句);
9
+ * - 异步追加 + 单文件上限 + 一次轮转(`x.log` → `x.log.1`),不无限增长;
10
+ * - 只负责文件,不改变原有 logger 的行为。
11
+ *
12
+ * @module dsh-chat/host/file-log
13
+ */
14
+
15
+ import { appendFile, mkdir, rename, stat } from 'node:fs/promises';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
19
+ const LEVELS = ['debug', 'info', 'warn', 'error'];
20
+
21
+ function stamp() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ /** 单行日志的长度上限:SDK 会把整个 axios 请求对象丢进来。 */
26
+ const MAX_FIELD_CHARS = 2000;
27
+
28
+ function clip(text) {
29
+ return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
30
+ }
31
+
32
+ /**
33
+ * HTTP 客户端错误(axios 形态)压成一行摘要。
34
+ *
35
+ * 飞书 SDK 失败时把整个错误对象丢进 logger:`config`/`request`/`response` 三份且互相
36
+ * 引用,串起来一次几 KB,而真正的信息只有状态码、平台 code 和 msg。凑巧的是这类失败
37
+ * 往往每十分钟重试一次——现场就被它自己淹了。所以这类对象只留摘要。
38
+ */
39
+ function httpErrorSummary(value) {
40
+ const status = value?.response?.status ?? value?.status;
41
+ if (!status || (!value?.config && !value?.request && !value?.response)) return null;
42
+ const data = value.response?.data ?? {};
43
+ const code = data?.code ?? value?.code;
44
+ const message = String(data?.msg ?? value?.message ?? '').replace(/\s+/gu, ' ').trim();
45
+ const parts = [`HTTP ${status}`];
46
+ if (value.statusText) parts.push(String(value.statusText));
47
+ if (code !== undefined && code !== null && code !== '') parts.push(`code=${code}`);
48
+ if (message) parts.push(message);
49
+ return parts.join(' ');
50
+ }
51
+
52
+ /** 把任意一个参数压成一行可读文本。 */
53
+ function oneLine(value) {
54
+ if (typeof value === 'string') return value;
55
+ if (value === null || value === undefined) return String(value);
56
+ if (value instanceof Error) {
57
+ return `${value.name}: ${value.message}${value.code ? `(code ${value.code})` : ''}`;
58
+ }
59
+ if (typeof value !== 'object') return String(value);
60
+ // SDK 常把参数打包成数组(`logger.error([err, ctx])`),逐个压好再拼。
61
+ if (Array.isArray(value)) return clip(value.map(oneLine).filter((part) => part !== '').join(' '));
62
+ const http = httpErrorSummary(value);
63
+ if (http) return clip(http);
64
+ try {
65
+ const seen = new WeakSet();
66
+ const text = JSON.stringify(value, (key, item) => {
67
+ if (typeof item === 'object' && item !== null) {
68
+ if (seen.has(item)) return '[循环引用]';
69
+ seen.add(item);
70
+ }
71
+ return item;
72
+ });
73
+ if (typeof text !== 'string') return String(value);
74
+ return clip(text);
75
+ } catch {
76
+ return String(value);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 拼一行日志正文。
82
+ *
83
+ * 第三方 SDK(飞书)是按 `logger.error(obj, obj)` 调的:直接 `String()` 只会写出
84
+ * `[object Object],[object Object]`,而日志是排查时唯一的现场——这种行等于没写。
85
+ */
86
+ function describe(message, rest = []) {
87
+ return [message, ...rest].map(oneLine).filter((part) => part !== '').join(' ');
88
+ }
89
+
90
+ /**
91
+ * 创建一个按大小轮转的日志文件写入口。
92
+ *
93
+ * @param options - { path, maxBytes }。
94
+ * @returns { write, path };`write` 永不抛错。
95
+ */
96
+ export function createLogFileSink({ path, maxBytes = DEFAULT_MAX_BYTES } = {}) {
97
+ if (typeof path !== 'string' || !path) throw new TypeError('日志文件需要 path。');
98
+ let queue = Promise.resolve();
99
+ let size = null;
100
+ let warned = false;
101
+
102
+ async function rotateIfNeeded(nextLength) {
103
+ if (size === null) {
104
+ try {
105
+ size = (await stat(path)).size;
106
+ } catch {
107
+ size = 0;
108
+ }
109
+ }
110
+ if (size > 0 && size + nextLength > maxBytes) {
111
+ await rename(path, `${path}.1`).catch(() => {});
112
+ size = 0;
113
+ }
114
+ }
115
+
116
+ function write(line) {
117
+ const text = `${line}\n`;
118
+ queue = queue.then(async () => {
119
+ try {
120
+ await mkdir(dirname(path), { recursive: true });
121
+ await rotateIfNeeded(text.length);
122
+ await appendFile(path, text, 'utf8');
123
+ size = (size ?? 0) + text.length;
124
+ } catch (error) {
125
+ if (!warned) {
126
+ warned = true;
127
+ // 只提醒一次,且不改变调用方流程。
128
+ process.stderr.write(`[dsh-chat] 写日志文件失败(${path}):${error?.message ?? error}\n`);
129
+ }
130
+ }
131
+ });
132
+ return queue;
133
+ }
134
+
135
+ return { write, path, flush: () => queue };
136
+ }
137
+
138
+ /**
139
+ * 把一个 logger 包成"原样转发 + 落盘"。
140
+ *
141
+ * @param options - { logger, sink, scope }。
142
+ * @returns 与入参同形的 logger(缺哪个级别就补一个空实现)。
143
+ */
144
+ export function withFileSink({ logger, sink, scope = '' }) {
145
+ if (!sink?.write) return logger;
146
+ const wrapped = {};
147
+ for (const level of LEVELS) {
148
+ const inner = typeof logger?.[level] === 'function' ? logger[level].bind(logger) : null;
149
+ wrapped[level] = (message, ...rest) => {
150
+ try {
151
+ inner?.(message, ...rest);
152
+ } finally {
153
+ // 消息里通常已经带 `[dsh-chat-<渠道>]` 前缀,别再加一遍。
154
+ const text = describe(message, rest);
155
+ const prefix = scope && !text.startsWith('[') ? `[${scope}] ` : '';
156
+ sink.write(`${stamp()} ${level.toUpperCase().padEnd(5)} ${prefix}${text}`);
157
+ }
158
+ };
159
+ }
160
+ // 第三方 SDK(如飞书)会调 trace:给它一个落点,免得日志调用本身把流程打断。
161
+ if (typeof wrapped.trace !== 'function') wrapped.trace = wrapped.debug;
162
+ // 自定义 logger 可能还带别的方法,原样保留。
163
+ return Object.assign(Object.create(Object.getPrototypeOf(logger ?? {}) ?? Object.prototype), logger ?? {}, wrapped);
164
+ }
165
+
166
+ /** 日志目录(hub 数据目录下,所有渠道共用一处,方便一起看)。 */
167
+ export function channelLogPath(logsDir, name) {
168
+ return join(logsDir, `${name}.log`);
169
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 每会话的来源提示词登记表(host 侧,进程内单例语义)。
3
+ *
4
+ * 提示词 RPC 不携带消息来源,所以渠道在派发 prompt 时把"本次生效的增强提示词"
5
+ * 发布进来;host 把它物化成该 Session 的动态提示词上下文,只在渲染文本变化时
6
+ * 追加一条持久快照。key 是 Session id,因此"指定用户 / 指定群"各属独立会话时
7
+ * 天然各自生效。
8
+ *
9
+ * @module dsh-chat/host/guidance
10
+ */
11
+
12
+ /** 动态提示词上下文名。 */
13
+ export const SOURCE_GUIDANCE_CONTEXT = 'dsh-chat:source-guidance';
14
+
15
+ /** 上下文拼接顺序:跟在策略类事实之后。 */
16
+ export const SOURCE_GUIDANCE_ORDER = 125;
17
+
18
+ /** 单会话提示词上限(与设置上限一致)。 */
19
+ const GUIDANCE_MAX_LENGTH = 8_000;
20
+
21
+ /** 保留会话数上限,避免被废弃会话无限撑大。 */
22
+ const MAX_SESSIONS = 1_024;
23
+
24
+ /**
25
+ * 创建提示词登记表。
26
+ *
27
+ * @returns { publish, get, forget, size }。
28
+ */
29
+ export function createGuidanceRegistry() {
30
+ /** @type {Map<string, string>} */
31
+ const bySession = new Map();
32
+
33
+ return {
34
+ /**
35
+ * 发布一个会话当前生效的提示词;空值表示清除。
36
+ *
37
+ * @param sessionId - 目标 Session。
38
+ * @param guidance - 提示词正文。
39
+ */
40
+ publish(sessionId, guidance) {
41
+ if (typeof sessionId !== 'string' || !sessionId) return;
42
+ const text = typeof guidance === 'string' ? guidance.slice(0, GUIDANCE_MAX_LENGTH) : '';
43
+ bySession.delete(sessionId);
44
+ if (!text.trim()) return;
45
+ bySession.set(sessionId, text);
46
+ while (bySession.size > MAX_SESSIONS) {
47
+ bySession.delete(bySession.keys().next().value);
48
+ }
49
+ },
50
+
51
+ /**
52
+ * @param sessionId - Session id。
53
+ * @returns 该会话的提示词,未登记时为 undefined。
54
+ */
55
+ get(sessionId) {
56
+ return typeof sessionId === 'string' ? bySession.get(sessionId) : undefined;
57
+ },
58
+
59
+ /**
60
+ * 会话离开时清掉登记。
61
+ *
62
+ * @param sessionId - Session id。
63
+ */
64
+ forget(sessionId) {
65
+ if (typeof sessionId === 'string') bySession.delete(sessionId);
66
+ },
67
+
68
+ /** @returns 当前登记数量。 */
69
+ get size() {
70
+ return bySession.size;
71
+ },
72
+ };
73
+ }
package/host/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * dsh-chat Hub 的 host 打包入口。
3
+ *
4
+ * @module dsh-chat/host
5
+ */
6
+
7
+ export { apply, inject, name } from './plugin.mjs';
@@ -0,0 +1,330 @@
1
+ /**
2
+ * 人在环交互回传(hub 所有):把 agent 的**提问**与**审批**送到 IM 里问、并从 IM 里收答案。
3
+ *
4
+ * 为什么要在 hub:这两件事与平台无关——渲染问题、等待回答、解析回答、超时兜底,
5
+ * 所有渠道一模一样;渠道只提供两样东西:
6
+ * 1. 怎么把一段文本发到某个会话(`attach` 时传入的 `send`);
7
+ * 2. 入站文本先交给 `offer`,被认领了就不要再去跑模型。
8
+ *
9
+ * 关键取舍:
10
+ * - **超时/取消就交回其他应答方**(浏览器 UI):`handle` 返回 null,上层 `next()`。
11
+ * 这样即使 IM 那侧没人回,也不会把这一轮卡死。
12
+ * - 只有已绑定的会话、且该渠道接入了 IM 回传时才认领;否则一样让给浏览器。
13
+ * - 未识别的回复在审批场景里按"拒绝"处理(fail closed),并留下日志。
14
+ *
15
+ * @module dsh-chat/host/interactions
16
+ */
17
+
18
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
19
+
20
+ const APPROVE_PATTERN = /^(允许|同意|可以|好|好的|是|执行|ok|okay|yes|y|allow|approve)$/i;
21
+ const REJECT_PATTERN = /^(拒绝|不允许|不同意|不可以|不行|不要|不用|否|不|取消|no|n|deny|reject|cancel)$/i;
22
+
23
+ function waiterKey(channelId, botId, key) {
24
+ return `${channelId}\u0000${botId}\u0000${key}`;
25
+ }
26
+
27
+ /**
28
+ * 把一个问题渲染成一条 IM 文本。
29
+ *
30
+ * @param question - DSH 的 `AskUserQuestionItem`。
31
+ * @param options - { position, total }:多问题时给出进度。
32
+ * @returns 文本。
33
+ */
34
+ export function renderQuestion(question, { position = 0, total = 1 } = {}) {
35
+ const header = question?.header || '需要你确认';
36
+ const lines = [total > 1 ? `❓ ${header}(${position}/${total})` : `❓ ${header}`, ''];
37
+ lines.push(String(question?.question ?? ''));
38
+ if (question?.detail) {
39
+ lines.push('', String(question.detail));
40
+ }
41
+ const options = Array.isArray(question?.options) ? question.options : [];
42
+ if (options.length > 0) {
43
+ lines.push('');
44
+ options.forEach((option, index) => {
45
+ lines.push(`${index + 1}. ${option.label}${option.description ? ` —— ${option.description}` : ''}`);
46
+ });
47
+ lines.push('');
48
+ lines.push(question?.multiSelect
49
+ ? '可以回复多个编号(例如 1,3),也可以直接回复文字。'
50
+ : '回复编号或选项原文即可,也可以直接回复文字。');
51
+ } else {
52
+ lines.push('', '直接回复你的答案。');
53
+ }
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /**
58
+ * 把一个问题渲染成卡片正文(不带按钮):用于"多选/自由文本"这类按钮表达不了的提问,
59
+ * 好处是仍然留在**同一张卡片**里,不会再单独发一条文本消息把聊天记录撑满。
60
+ *
61
+ * @param question - DSH 的 `AskUserQuestionItem`。
62
+ * @returns 若干行文本(markdown)。
63
+ */
64
+ export function renderQuestionLines(question) {
65
+ const lines = [];
66
+ const header = question?.header ? `**${question.header}**\n` : '';
67
+ lines.push(`${header}${question?.question ?? ''}`);
68
+ if (question?.detail) lines.push('', String(question.detail));
69
+ const options = Array.isArray(question?.options) ? question.options : [];
70
+ if (options.length > 0) {
71
+ lines.push('');
72
+ options.forEach((option, index) => {
73
+ lines.push(`${index + 1}. **${option.label}**${option.description ? ` —— ${option.description}` : ''}`);
74
+ });
75
+ lines.push('', question?.multiSelect
76
+ ? '可多选:回复编号(例如 `1,3`),也可以直接回复文字。'
77
+ : '回复编号或选项文字即可,也可以直接回复文字。');
78
+ } else {
79
+ lines.push('', '直接回复你的文字答案。');
80
+ }
81
+ return lines;
82
+ }
83
+
84
+ /**
85
+ * 渲染一次审批请求。
86
+ *
87
+ * @param request - `ApprovalRequestEvent`。
88
+ * @returns 文本。
89
+ */
90
+ export function renderApproval(request) {
91
+ const lines = ['⚠️ 需要授权', ''];
92
+ lines.push(`工具:${request?.toolName ?? '未知'}`);
93
+ if (request?.reason) lines.push(`原因:${request.reason}`);
94
+ lines.push('', '回复「允许」执行一次,或「拒绝」取消。');
95
+ return lines.join('\n');
96
+ }
97
+
98
+ /**
99
+ * 把用户回复解析成一个问题的答案。
100
+ *
101
+ * 支持:编号(`2`)、选项原文、多选(`1,3`)、以及任意自由文本(走 `custom`)。
102
+ *
103
+ * @param question - `AskUserQuestionItem`。
104
+ * @param reply - 用户回复的原文。
105
+ * @returns `AskUserQuestionAnswerItem`。
106
+ */
107
+ export function parseAnswer(question, reply) {
108
+ const raw = String(reply ?? '').trim();
109
+ const options = Array.isArray(question?.options) ? question.options : [];
110
+ // 选项原文本身可能带空格(例如「排查某个 App 异常」),所以先整体比一次;
111
+ // 多选才按逗号/顿号拆,绝不用空格拆——那会把选项标签拆碎。
112
+ const exact = options.find((option) => option.label === raw);
113
+ if (exact) return { id: String(question?.id ?? ''), selected: [exact.label] };
114
+ const tokens = question?.multiSelect
115
+ ? raw.split(/[,,、;;]+/).map((token) => token.trim()).filter(Boolean)
116
+ : [raw];
117
+ const selected = [];
118
+ const unmatched = [];
119
+ for (const token of tokens) {
120
+ const byIndex = /^\d+$/.test(token) ? options[Number(token) - 1] : undefined;
121
+ const byLabel = byIndex ?? options.find((option) => option.label === token);
122
+ if (byLabel) {
123
+ if (!selected.includes(byLabel.label)) selected.push(byLabel.label);
124
+ continue;
125
+ }
126
+ unmatched.push(token);
127
+ }
128
+ const answer = { id: String(question?.id ?? ''), selected };
129
+ if (unmatched.length > 0) {
130
+ // 一个选项都没匹配上时保留用户原文(多选拆分不该改写他的说法)。
131
+ answer.custom = unmatched.length === tokens.length ? raw : unmatched.join(' ');
132
+ }
133
+ return answer;
134
+ }
135
+
136
+ /**
137
+ * 解析审批回复。
138
+ *
139
+ * @param reply - 用户回复的原文。
140
+ * @returns 'allowed-once' | 'rejected' | null(认不出来)。
141
+ */
142
+ export function parseApproval(reply) {
143
+ const raw = String(reply ?? '').trim();
144
+ if (APPROVE_PATTERN.test(raw)) return 'allowed-once';
145
+ if (REJECT_PATTERN.test(raw)) return 'rejected';
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * 创建交互服务。
151
+ *
152
+ * @param options - { logger, timeoutMs }。
153
+ * @returns { attach, has, offer, handle }。
154
+ */
155
+ export function createInteractionService({ logger = console, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
156
+ /** `${channelId}\u0000${botId}` → send({ key, text }) */
157
+ const senders = new Map();
158
+ /** `${channelId}\u0000${botId}\u0000${key}` → { resolve } */
159
+ const waiters = new Map();
160
+
161
+ function senderFor(channelId, botId) {
162
+ return senders.get(`${channelId}\u0000${botId}`) ?? null;
163
+ }
164
+
165
+ /**
166
+ * 等一条回答;超时或取消返回 null(= 让给其他应答方)。
167
+ *
168
+ * @param options - { channelId, botId, key, kind, signal, budgetMs }。
169
+ * @returns `{ text, questionId }` 或 null。
170
+ */
171
+ function wait({ channelId, botId, key, kind, signal, budgetMs }) {
172
+ const timeout = Math.max(0, Number.isFinite(budgetMs) ? budgetMs : timeoutMs);
173
+ return new Promise((resolve) => {
174
+ const id = waiterKey(channelId, botId, key);
175
+ let settled = false;
176
+ const finish = (value) => {
177
+ if (settled) return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ signal?.removeEventListener?.('abort', onAbort);
181
+ if (waiters.get(id)?.resolve === entry.resolve) waiters.delete(id);
182
+ if (value === null) {
183
+ logger.warn?.(`[dsh-chat] ${kind} 在 IM 里没有得到回答,交回其他应答方`
184
+ + `(${channelId}/${botId}/${key})`);
185
+ }
186
+ resolve(value);
187
+ };
188
+ const entry = { resolve: (value) => finish(value) };
189
+ const timer = setTimeout(() => finish(null), timeout);
190
+ const onAbort = () => finish(null);
191
+ signal?.addEventListener?.('abort', onAbort, { once: true });
192
+ waiters.set(id, entry);
193
+ });
194
+ }
195
+
196
+ return Object.freeze({
197
+ /**
198
+ * 渠道接入 IM 回传:给出"怎么把文本发到这个机器人的某个会话"。
199
+ *
200
+ * @param options - { channelId, botId, send({ key, text }) }。
201
+ * @returns 注销函数。
202
+ */
203
+ attach({ channelId, botId, send, sendQuestion, sendQuestions, sendApproval }) {
204
+ if (typeof send !== 'function') throw new TypeError('交互回传需要渠道提供 send。');
205
+ const id = `${channelId}\u0000${botId}`;
206
+ senders.set(id, {
207
+ send,
208
+ // 可选:渠道能把问题/审批渲染成平台原生交互(飞书的按钮卡片),比纯文本好用得多。
209
+ // `sendQuestions` 是"一批问题一张卡、回答后就地更新"的形态,优先用它。
210
+ sendQuestions: typeof sendQuestions === 'function'
211
+ ? sendQuestions
212
+ : (typeof sendQuestion === 'function' ? null : null),
213
+ sendQuestion: typeof sendQuestion === 'function' ? sendQuestion : null,
214
+ sendApproval: typeof sendApproval === 'function' ? sendApproval : null,
215
+ });
216
+ return () => {
217
+ if (senders.get(id)?.send === send) senders.delete(id);
218
+ };
219
+ },
220
+
221
+ /** @returns 该渠道是否接入了 IM 回传(未接入则一律让给浏览器 UI)。 */
222
+ has: (channelId) => [...senders.keys()].some((id) => id.startsWith(`${channelId}\u0000`)),
223
+
224
+ /**
225
+ * 入站文本先过这里:属于某个待回答的问题/审批就认领,调用方**不要**再跑模型。
226
+ *
227
+ * @param options - { channelId, botId, key, text }。
228
+ * @returns 是否被认领。
229
+ */
230
+ offer({ channelId, botId, key, text, questionId }) {
231
+ const entry = waiters.get(waiterKey(channelId, botId, key));
232
+ if (!entry) return false;
233
+ logger.info?.(`[dsh-chat] IM 回答已认领:${channelId}/${botId}/${key}`
234
+ + `${questionId ? ` 问题=${questionId}` : ''}`);
235
+ entry.resolve({ text, questionId });
236
+ return true;
237
+ },
238
+
239
+ /**
240
+ * 应答一次提问/审批。
241
+ *
242
+ * @param options - { kind: 'question'|'approval', channelId, botId, key, request }。
243
+ * @returns 提问返回 `{ answers }`,审批返回 outcome 字符串;无法应答时返回 null。
244
+ */
245
+ async handle({ kind, channelId, botId, key, request }) {
246
+ const sender = senderFor(channelId, botId);
247
+ if (!sender) return null;
248
+
249
+ if (kind === 'approval') {
250
+ logger.info?.(`[dsh-chat] 审批已发往 IM:${channelId}/${botId}/${key}`
251
+ + ` 工具=${request?.toolName ?? '?'} 方式=${sender.sendApproval ? '卡片' : '文本'}`);
252
+ if (sender.sendApproval) {
253
+ await sender.sendApproval({ key, request });
254
+ } else {
255
+ await sender.send({ key, text: renderApproval(request) });
256
+ }
257
+ const reply = await wait({ channelId, botId, key, kind: '审批', signal: request?.signal });
258
+ if (reply === null) return null;
259
+ const outcome = parseApproval(reply.text);
260
+ if (outcome === null) {
261
+ logger.warn?.(`[dsh-chat] 审批回复无法识别(${JSON.stringify(reply.text)}),按拒绝处理`);
262
+ return 'rejected';
263
+ }
264
+ return outcome;
265
+ }
266
+
267
+ const questions = Array.isArray(request?.questions) ? request.questions : [];
268
+ if (questions.length === 0) return null;
269
+
270
+ // 一批问题只用一张卡片(渠道支持的话),回答后就地更新——避免每问一条把聊天记录撑满。
271
+ let useCard = typeof sender.sendQuestions === 'function';
272
+ const answered = new Map();
273
+ const render = async ({ final = false } = {}) => {
274
+ if (!useCard) return;
275
+ try {
276
+ await sender.sendQuestions({
277
+ key,
278
+ questions,
279
+ answered: Object.fromEntries(answered),
280
+ final,
281
+ });
282
+ } catch (error) {
283
+ logger.warn?.(`[dsh-chat] 提问卡片更新失败:${error?.message ?? error}`);
284
+ }
285
+ };
286
+
287
+ logger.info?.(`[dsh-chat] 提问已发往 IM:${channelId}/${botId}/${key}`
288
+ + ` 问题数=${questions.length} 方式=${useCard ? '卡片' : '文本'}`);
289
+ if (useCard) {
290
+ // 首发失败要真的退回文本,否则用户什么都看不到、这一轮白等。
291
+ try {
292
+ await sender.sendQuestions({ key, questions, answered: {}, final: false });
293
+ } catch (error) {
294
+ useCard = false;
295
+ logger.warn?.(`[dsh-chat] 提问卡片发送失败,回退为文本:${error?.message ?? error}`);
296
+ }
297
+ }
298
+ if (!useCard) {
299
+ for (const [index, question] of questions.entries()) {
300
+ await sender.send({
301
+ key,
302
+ text: renderQuestion(question, { position: index + 1, total: questions.length }),
303
+ });
304
+ }
305
+ }
306
+
307
+ const deadline = Date.now() + timeoutMs;
308
+ while (answered.size < questions.length) {
309
+ const budgetMs = deadline - Date.now();
310
+ if (budgetMs <= 0) return null;
311
+ const reply = await wait({
312
+ channelId, botId, key, kind: '提问', signal: request?.signal, budgetMs,
313
+ });
314
+ if (reply === null) return null;
315
+ // 按钮点击带 questionId(可以按任意顺序点);手打文字则答给"第一个还没答的问题"。
316
+ const target = reply.questionId
317
+ ? questions.find((item) => item?.id === reply.questionId && !answered.has(item.id))
318
+ : questions.find((item) => !answered.has(item?.id));
319
+ if (!target) {
320
+ logger.info?.(`[dsh-chat] 忽略无法归属的回答(questionId=${reply.questionId ?? '无'})`);
321
+ continue;
322
+ }
323
+ answered.set(target.id, parseAnswer(target, reply.text));
324
+ await render();
325
+ }
326
+ await render({ final: true });
327
+ return { answers: questions.map((question) => answered.get(question?.id)) };
328
+ },
329
+ });
330
+ }