@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.0

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +15 -0
  3. package/README.md +56 -2
  4. package/client.js +4189 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/coexistence.md +112 -0
  7. package/docs/dsh-feishu-channels-plan.md +1406 -0
  8. package/docs/ui-revamp-plan.md +263 -0
  9. package/docs/verification.md +120 -0
  10. package/icon.svg +17 -0
  11. package/locale/en.json +6 -0
  12. package/locale/zh.json +6 -0
  13. package/package.json +76 -4
  14. package/src/channels/connection-state.js +12 -0
  15. package/src/channels/diagnostics.js +77 -0
  16. package/src/channels/factory.js +20 -0
  17. package/src/channels/feishu-app.js +267 -0
  18. package/src/channels/feishu.js +399 -0
  19. package/src/channels/media.js +9 -0
  20. package/src/channels/meta.js +24 -0
  21. package/src/channels/qr/feishu.js +84 -0
  22. package/src/channels/qr/hub.js +229 -0
  23. package/src/channels/qr/render.js +8 -0
  24. package/src/channels/qr/shared.js +50 -0
  25. package/src/engine/abort.js +35 -0
  26. package/src/engine/access.js +23 -0
  27. package/src/engine/agent-options.js +19 -0
  28. package/src/engine/approval.js +70 -0
  29. package/src/engine/assistant-settings.js +38 -0
  30. package/src/engine/assistant-text.js +11 -0
  31. package/src/engine/atomic-file.js +45 -0
  32. package/src/engine/chat-commands.js +752 -0
  33. package/src/engine/choice-delivery.js +20 -0
  34. package/src/engine/choices.js +103 -0
  35. package/src/engine/command-locale.js +70 -0
  36. package/src/engine/command-messages.js +434 -0
  37. package/src/engine/command-permissions.js +38 -0
  38. package/src/engine/command-replies.js +59 -0
  39. package/src/engine/credentials.js +54 -0
  40. package/src/engine/deferred-delivery.js +260 -0
  41. package/src/engine/delivery-history.js +39 -0
  42. package/src/engine/file-delivery.js +215 -0
  43. package/src/engine/file-input.js +52 -0
  44. package/src/engine/file-log.js +25 -0
  45. package/src/engine/gateway.js +1337 -0
  46. package/src/engine/image-input.js +31 -0
  47. package/src/engine/json-state.js +25 -0
  48. package/src/engine/keyed-queue.js +20 -0
  49. package/src/engine/merge.js +56 -0
  50. package/src/engine/message-progress.js +274 -0
  51. package/src/engine/question.js +201 -0
  52. package/src/engine/quiet-logger.js +25 -0
  53. package/src/engine/reply-stream.js +86 -0
  54. package/src/engine/router.js +713 -0
  55. package/src/engine/secure-file.js +8 -0
  56. package/src/engine/seen-store.js +43 -0
  57. package/src/engine/session-export.js +64 -0
  58. package/src/engine/session-history.js +30 -0
  59. package/src/engine/session-id.js +62 -0
  60. package/src/engine/session-store.js +109 -0
  61. package/src/engine/session-title.js +29 -0
  62. package/src/engine/split.js +291 -0
  63. package/src/engine/workspace-path.js +9 -0
  64. package/src/index.js +62 -0
  65. package/src/manager.js +1325 -0
@@ -0,0 +1,1337 @@
1
+ import { DeferredDelivery, DeliveryRejected, DeliveryUnavailable } from './deferred-delivery.js';
2
+ import { sleepWithSignal } from './abort.js';
3
+ import { assistantText } from './assistant-text.js';
4
+ import { readDeliveryHistory } from './delivery-history.js';
5
+ import { FileInputError, filePromptParts } from './file-input.js';
6
+ import { ChoiceStore } from './choices.js';
7
+ import { MessageProgress, ProgressTracker } from './message-progress.js';
8
+ import { notice, replyText, withReplyLocale } from './command-locale.js';
9
+ import { FileDelivery } from './file-delivery.js';
10
+ import { ChatCommands } from './chat-commands.js';
11
+ import { canExecuteCommand, normalizeCommandPermissions } from './command-permissions.js';
12
+ import { ImageInputError, imageInputFailure, imagePromptPart } from './image-input.js';
13
+ import { APPROVAL_WAIT_MS, ApprovalBroker } from './approval.js';
14
+ import { SessionMerger } from './merge.js';
15
+ import { KeyedSerialQueue } from './keyed-queue.js';
16
+ import { SessionRouter } from './router.js';
17
+ import { initialSessionTitle, readSessionTitle } from './session-title.js';
18
+ import { readSessionHistory } from './session-history.js';
19
+ import { decideAccess } from './access.js';
20
+ import { splitText } from './split.js';
21
+ import { ReplyStreamHub, isAssistantTextDelta } from './reply-stream.js';
22
+ import { QuestionBroker, formatUserQuestion, validUserQuestion, } from './question.js';
23
+ /** 分片发送的退避表(毫秒):第一次立即,随后 1s/3s/8s/20s,累计约 32s。 */
24
+ const SEND_RETRY_DELAYS_MS = [0, 1_000, 3_000, 8_000, 20_000];
25
+ /**
26
+ * 一条回复(含它的所有分片)允许用于等待重试的总时长。
27
+ * 退避是按分片走的,没有这个上限时一条多分片回复能把回合拖到几分钟。
28
+ */
29
+ const SEND_RETRY_BUDGET_MS = 30_000;
30
+ /**
31
+ * 平台是否已经明确答复「本次发送没有送达」。
32
+ *
33
+ * 只认渠道显式抛出的 {@link DeliveryRejected}(例如部分渠道
34
+ * `/ilink/bot/sendmessage ret=-2 … prepare failed`、HTTP 429/503):
35
+ * 这类响应说明请求到达平台但未被受理,重发不会让用户收到两条。
36
+ *
37
+ * 不按「是不是普通 Error」推断:渠道里的普通错误还可能来自本地校验、
38
+ * 上传或媒体处理,也可能是平台已部分处理后的 5xx。超时与网络中断下
39
+ * 请求可能已经落地。这些都保持「送达未知」,只由用户用 `/delivery retry` 决定。
40
+ */
41
+ export function isDefiniteSendFailure(error) {
42
+ return error instanceof DeliveryRejected;
43
+ }
44
+ const USER_QUESTION_WRAPPER = Symbol('dsh-feishu-channels.user-question-wrapper');
45
+ export class ImEngine {
46
+ ctx;
47
+ store;
48
+ seen;
49
+ config;
50
+ log;
51
+ onUnauthorized;
52
+ resolveConfig;
53
+ resolvePrivateAccess;
54
+ resolveCommandPermissions;
55
+ sendRetryDelaysMs;
56
+ deferred;
57
+ deferredTimer;
58
+ recovering = false;
59
+ recoveryCursor = 0;
60
+ recoveryScope = new AbortController();
61
+ observedTurns = new Map();
62
+ channels = new Map();
63
+ router;
64
+ broker = new ApprovalBroker();
65
+ questions = new QuestionBroker();
66
+ merger;
67
+ extraAllow = new Map();
68
+ sessionActors = new Map();
69
+ chatActors = new Map();
70
+ interactionMessageIds = new Map();
71
+ questionActors = new Map();
72
+ questionDeliveries = new Map();
73
+ questionPromptDelivered = new Set();
74
+ queues = new Map();
75
+ interactionQueues = new Map();
76
+ streams = new ReplyStreamHub();
77
+ disposeEvents = [];
78
+ wrappedUserQuestionServices = new WeakSet();
79
+ legacyServiceTimer;
80
+ disposed = false;
81
+ questionSelections = new Map();
82
+ choices;
83
+ progress = new ProgressTracker(result => {
84
+ void this.notifyCompletion(result).catch(() => this.log('[im-progress] 完成通知发送失败,不重试任务'));
85
+ }, (sessionId, turn, delivered, rejected) => {
86
+ try {
87
+ this.deferred.complete(sessionId, turn, delivered, rejected);
88
+ }
89
+ catch {
90
+ this.log('[im-delivery] 交付确认落盘失败,保留未知状态');
91
+ }
92
+ });
93
+ mergedMessages = new Map();
94
+ fileDelivery;
95
+ chatCommands;
96
+ commandScopes = new Map();
97
+ inputScopes = new Map();
98
+ /**
99
+ * @param sendRetryDelaysMs 分片发送退避表;测试可传 `[0]` 关闭等待。
100
+ */
101
+ constructor(ctx, store, seen, config, log, onUnauthorized, resolveConfig = () => config, resolvePrivateAccess = () => 'approved', resolveCommandPermissions = () => normalizeCommandPermissions(undefined), deliveryFile, sendRetryDelaysMs = SEND_RETRY_DELAYS_MS) {
102
+ this.ctx = ctx;
103
+ this.store = store;
104
+ this.seen = seen;
105
+ this.config = config;
106
+ this.log = log;
107
+ this.onUnauthorized = onUnauthorized;
108
+ this.resolveConfig = resolveConfig;
109
+ this.resolvePrivateAccess = resolvePrivateAccess;
110
+ this.resolveCommandPermissions = resolveCommandPermissions;
111
+ this.sendRetryDelaysMs = sendRetryDelaysMs;
112
+ this.deferred = new DeferredDelivery(deliveryFile);
113
+ if (deliveryFile) {
114
+ this.deferredTimer = setInterval(() => { void this.recoverDeliveries(); }, 30_000);
115
+ this.deferredTimer.unref();
116
+ }
117
+ this.choices = new ChoiceStore(log);
118
+ // DSH 的真实 agents 类型比路由器所需的最小会话契约更严格,在此处完成边界适配。
119
+ this.router = new SessionRouter(ctx, store, config, log, resolveConfig, {
120
+ onDeliveryStopped: binding => this.streams.reset(`${binding.channelId}:${binding.chatId}`, binding.sessionId),
121
+ });
122
+ this.fileDelivery = new FileDelivery(ctx, log);
123
+ this.chatCommands = new ChatCommands(ctx, this.router, id => this.questions.has(id) || this.broker.has(id), (id, msg) => { if (msg.userId)
124
+ this.sessionActors.set(id, msg.userId); }, (channel, msg, text, choices, session, allowNumber = true) => this.choices.show(channel, msg, text, choices, session, undefined, allowNumber ? undefined : replyText('点击按钮或发送对应命令;按钮 15 分钟内有效,普通文字继续聊天。'), allowNumber));
125
+ this.merger = new SessionMerger((config.mergeTimeoutSecs || 5) * 1000, (key, text) => {
126
+ const sep = key.indexOf(':');
127
+ const channelId = key.slice(0, sep);
128
+ const rest = key.slice(sep + 1);
129
+ const channel = this.channels.get(channelId);
130
+ if (!channel)
131
+ return;
132
+ const merged = { chatId: rest.split(':').slice(1).join(':') || rest, text, kind: rest.startsWith('group:') ? 'group' : 'dm' };
133
+ // 合并窗口回调不在任何请求链路里,必须自兜底,否则 rejection 无人接
134
+ void this.inject(channel, merged, this.takeMergedMessages(key, merged)).catch((error) => {
135
+ const detail = error instanceof Error ? error.message : String(error);
136
+ this.log(`[${channelId}] 合并投递失败: ${detail}`);
137
+ channel.send(merged.chatId, notice(this.ctx, '消息处理失败,请查看本机日志。')).catch(() => undefined);
138
+ });
139
+ });
140
+ const on = this.ctx.on;
141
+ if (typeof on === 'function') {
142
+ for (const kind of ['inserted', 'claimed', 'discarded']) {
143
+ this.disposeEvents.push(on(`agent/inbox/${kind}`, (...args) => {
144
+ const payload = args[0];
145
+ if (kind === 'claimed' && Number.isSafeInteger(payload.turn)) {
146
+ const id = String(payload.agent?.session?.id ?? payload.agent?.id ?? '');
147
+ this.deferred.claim(id, payload.turn, payload.message?.source?.rpcId ?? payload.message?.id ?? '');
148
+ }
149
+ this.progress.inbox(kind, payload);
150
+ }, { global: true }));
151
+ }
152
+ this.disposeEvents.push(on('session/event', (...args) => {
153
+ void this.onSessionEvent(args[0], args[1]);
154
+ }, { global: true }));
155
+ // 新版宿主将增量移出持久化事件;复用同一投递入口的绑定、准入与冷回合检查。
156
+ this.disposeEvents.push(on('agent/assistant-stream', (...args) => {
157
+ const payload = args[0];
158
+ if (!payload?.agent?.session?.id || payload.frame?.type !== 'chunk' || !isAssistantTextDelta(payload.frame.chunk))
159
+ return;
160
+ void this.onSessionEvent(payload.agent.session, { type: 'assistant/chunk', data: { chunk: payload.frame.chunk } });
161
+ }, { global: true }));
162
+ this.disposeEvents.push(on('session/disposed', (...args) => {
163
+ const id = String(args[0]?.id ?? '');
164
+ if (id === '')
165
+ return;
166
+ this.cancelSessionInteractions(id);
167
+ void this.router.onHostDisposed(id);
168
+ }, { global: true }));
169
+ // 删除流程先卸载再移除日志;完成通知后重新校验,避免留下可见但打不开的索引。
170
+ this.disposeEvents.push(on('api-session/removed', (...args) => {
171
+ const id = args[0];
172
+ if (typeof id !== 'string' || id === '')
173
+ return;
174
+ return this.cleanupMissingSession(id).catch(error => {
175
+ this.log(`[im-session] 清理已删除会话索引失败: ${error instanceof Error ? error.message : String(error)}`);
176
+ });
177
+ }, { global: true }));
178
+ this.disposeEvents.push(on('approval/request', (...args) => {
179
+ const req = args[0];
180
+ const next = args[1];
181
+ return this.onApproval(req, next);
182
+ }, { global: true, prepend: true }));
183
+ this.disposeEvents.push(on('user-questions/request', (...args) => {
184
+ const req = args[0];
185
+ const next = args[1];
186
+ return this.onUserQuestions(req, next);
187
+ }, { global: true, prepend: true }));
188
+ this.disposeEvents.push(on('internal/service', (...args) => {
189
+ if (args[0] === 'userQuestions' && this.installLegacyUserQuestionService(args[1])) {
190
+ if (this.legacyServiceTimer)
191
+ clearTimeout(this.legacyServiceTimer);
192
+ this.legacyServiceTimer = undefined;
193
+ }
194
+ }, { global: true }));
195
+ }
196
+ if (!this.installLegacyUserQuestionService())
197
+ this.scheduleLegacyUserQuestionService();
198
+ }
199
+ renameSession(sessionId, title) {
200
+ return this.router.rename(sessionId, title);
201
+ }
202
+ async removeSession(sessionId) {
203
+ return this.router.remove(sessionId);
204
+ }
205
+ async cleanupMissingSession(sessionId) {
206
+ const removed = await this.router.cleanupMissing(sessionId);
207
+ if (removed)
208
+ this.cancelSessionInteractions(sessionId);
209
+ return removed;
210
+ }
211
+ async ensureSession(sessionId) {
212
+ return this.router.ensure(sessionId);
213
+ }
214
+ setModel(provider, model, reasoningEffort) {
215
+ this.config.provider = provider;
216
+ this.config.model = model;
217
+ this.config.reasoningEffort = reasoningEffort;
218
+ for (const channelId of this.inputScopes.keys())
219
+ this.cancelInputs(channelId);
220
+ void this.router.disposeAll();
221
+ }
222
+ setCwd(cwd) {
223
+ this.config.cwd = cwd;
224
+ for (const channelId of this.inputScopes.keys())
225
+ this.cancelInputs(channelId);
226
+ void this.router.disposeAll();
227
+ }
228
+ setPermission(permission) {
229
+ this.config.permissionPreset = permission;
230
+ for (const channelId of this.inputScopes.keys())
231
+ this.cancelInputs(channelId);
232
+ void this.router.disposeAll();
233
+ }
234
+ attachMappedSessions() {
235
+ return this.router.attachMappedSessions(() => !this.disposed);
236
+ }
237
+ register(channel) {
238
+ this.channels.set(channel.id, channel);
239
+ channel.setMessageHandler((msg) => this.enqueue(channel.id, msg));
240
+ }
241
+ unregister(channelId) {
242
+ this.cancelInputs(channelId);
243
+ this.channels.delete(channelId);
244
+ }
245
+ addAllowed(channelId, userId) {
246
+ const id = userId.trim();
247
+ if (!id)
248
+ return;
249
+ let set = this.extraAllow.get(channelId);
250
+ if (!set) {
251
+ set = new Set();
252
+ this.extraAllow.set(channelId, set);
253
+ }
254
+ set.add(id);
255
+ }
256
+ async reloadChannel(channelId, options = {}) {
257
+ this.cancelInputs(channelId);
258
+ for (const sessionId of this.router.sessionIdsForChannel(channelId)) {
259
+ this.cancelSessionInteractions(sessionId, new Error('账号配置已更新'));
260
+ }
261
+ if (options.resetSessions)
262
+ await this.router.resetChannelSessions(channelId);
263
+ else
264
+ await this.router.disposeChannel(channelId);
265
+ }
266
+ clearAllowed(channelId) {
267
+ this.extraAllow.delete(channelId);
268
+ }
269
+ dispose() {
270
+ this.disposed = true;
271
+ if (this.deferredTimer)
272
+ clearInterval(this.deferredTimer);
273
+ this.recoveryScope.abort();
274
+ this.choices.clear();
275
+ this.progress.cancel();
276
+ this.mergedMessages.clear();
277
+ this.interactionMessageIds.clear();
278
+ this.chatCommands.clear();
279
+ for (const scope of this.commandScopes.values())
280
+ scope.abort();
281
+ this.commandScopes.clear();
282
+ for (const channelId of this.inputScopes.keys())
283
+ this.cancelInputs(channelId);
284
+ if (this.legacyServiceTimer)
285
+ clearTimeout(this.legacyServiceTimer);
286
+ for (const off of this.disposeEvents)
287
+ off();
288
+ this.fileDelivery.dispose();
289
+ this.broker.dispose();
290
+ this.questions.dispose();
291
+ this.merger.dispose();
292
+ void this.router.disposeAll();
293
+ }
294
+ enqueue(channelId, msg) {
295
+ const key = `${channelId}:${msg.chatId}`;
296
+ // 宿主命令可能等待交互;停止和审批回答不能排在该命令后面造成死锁。
297
+ const binding = this.router.lookup(channelId, msg.kind === 'group' ? 'group' : 'dm', msg.chatId);
298
+ if (this.commandScopes.has(key) && (/^\/stop(?:\s|$)/i.test(msg.text.trim()) || (binding && (this.questions.has(binding.sessionId) || this.broker.has(binding.sessionId)) && !msg.text.trim().startsWith('/')))) {
299
+ void this.handleInbound(channelId, msg);
300
+ return;
301
+ }
302
+ const prev = this.queues.get(key) ?? Promise.resolve();
303
+ const current = prev.catch(() => undefined).then(() => this.handleInbound(channelId, msg));
304
+ this.queues.set(key, current);
305
+ void current.finally(() => {
306
+ if (this.queues.get(key) === current)
307
+ this.queues.delete(key);
308
+ });
309
+ }
310
+ userAllowed(channelId, userId) {
311
+ if (!userId)
312
+ return false;
313
+ return this.extraAllow.get(channelId)?.has(userId) === true;
314
+ }
315
+ cancelSessionInteractions(sessionId, reason) {
316
+ this.progress.cancel(undefined, sessionId);
317
+ this.deferred.release(sessionId);
318
+ this.observedTurns.delete(sessionId);
319
+ this.broker.cancel(sessionId);
320
+ this.questionSelections.delete(sessionId);
321
+ this.questions.cancel(sessionId, reason);
322
+ this.sessionActors.delete(sessionId);
323
+ this.interactionMessageIds.delete(sessionId);
324
+ this.questionActors.delete(sessionId);
325
+ this.questionDeliveries.delete(sessionId);
326
+ this.questionPromptDelivered.delete(sessionId);
327
+ }
328
+ isAuthorized(channelId, channel, msg) {
329
+ if (msg.userId && this.resolvePrivateAccess(channelId) === 'all')
330
+ return true;
331
+ const local = channel.authorizes?.(msg.userId ?? '');
332
+ if (local === false)
333
+ return false;
334
+ if (local === true)
335
+ return true;
336
+ return this.userAllowed(channelId, msg.userId);
337
+ }
338
+ async rejectUnauthorized(channelId, channel, msg) {
339
+ this.log(`[${channelId}] 拒绝未授权用户 ${msg.userId || '(无 userId)'}`);
340
+ const hint = withReplyLocale(this.ctx, () => this.onUnauthorized?.(channelId, msg) ?? replyText('未授权:请管理员在设置 → 飞书频道 中批准你的访问。'));
341
+ if (msg.kind !== 'group') {
342
+ await channel.send(msg.chatId, hint).catch(() => undefined);
343
+ }
344
+ }
345
+ async handleInbound(channelId, msg) {
346
+ const channel = this.channels.get(channelId);
347
+ if (!channel)
348
+ return;
349
+ try {
350
+ if (msg.messageId && this.seen.has(`${channelId}:${msg.messageId}`))
351
+ return;
352
+ if (msg.messageId)
353
+ this.seen.add(`${channelId}:${msg.messageId}`);
354
+ const decision = decideAccess({
355
+ userAllowed: this.isAuthorized(channelId, channel, msg),
356
+ kind: msg.kind === 'group' ? 'group' : 'dm',
357
+ addressed: msg.addressed,
358
+ });
359
+ if (decision === 'ignore')
360
+ return;
361
+ if (msg.userId)
362
+ this.chatActors.set(`${channelId}:${msg.kind === 'group' ? 'group' : 'dm'}:${msg.chatId}`, msg.userId);
363
+ if (decision === 'deny') {
364
+ if (!msg.media?.length && await this.answerApproval(channelId, msg, this.approvalVerdict(msg.text)))
365
+ return;
366
+ await this.rejectUnauthorized(channelId, channel, msg);
367
+ return;
368
+ }
369
+ if (!/^\/delivery(?:\s|$)/i.test(msg.text.trim()))
370
+ void this.recoverDeliveries(channel, msg);
371
+ let text = msg.text.trim();
372
+ const kind = msg.kind === 'group' ? 'group' : 'dm';
373
+ const binding = this.router.lookup(channelId, kind, msg.chatId);
374
+ const selected = this.choices.resolve(channelId, msg, binding?.sessionId, !binding || (!this.questions.has(binding.sessionId) && !this.broker.has(binding.sessionId)));
375
+ if (selected === '') {
376
+ await this.deliver(channel, msg.chatId, withReplyLocale(this.ctx, () => replyText('选项已失效或不属于当前操作,请重新打开 /menu。')));
377
+ return;
378
+ }
379
+ if (selected?.startsWith('#question:') && binding) {
380
+ const current = this.questions.current(binding.sessionId);
381
+ if (!current)
382
+ return;
383
+ this.interactionMessageIds.set(binding.sessionId, msg.messageId);
384
+ const index = Number(selected.slice('#question:'.length));
385
+ const values = this.questionSelections.get(binding.sessionId) ?? new Set();
386
+ if (values.has(index))
387
+ values.delete(index);
388
+ else
389
+ values.add(index);
390
+ this.questionSelections.set(binding.sessionId, values);
391
+ await this.deliverQuestionInteraction(binding.sessionId, channel, msg.chatId, this.formatQuestion(current.question, current.index, current.total, { requiresMention: kind === 'group', selected: values }), this.questions.signal(binding.sessionId));
392
+ return;
393
+ }
394
+ if (selected !== undefined) {
395
+ text = selected;
396
+ msg = { ...msg, text, actionToken: undefined };
397
+ }
398
+ if (text.startsWith('/') && !msg.media?.length) {
399
+ if (!canExecuteCommand(this.resolveCommandPermissions(channelId), kind, msg.userId)) {
400
+ await this.deliver(channel, msg.chatId, withReplyLocale(this.ctx, () => replyText('当前聊天未开启命令权限,可以继续正常对话。')));
401
+ return;
402
+ }
403
+ const command = text.split(/\s+/, 1)[0]?.toLowerCase();
404
+ const mergeKey = `${channelId}:${kind}:${msg.chatId}`;
405
+ if (command === '/stop') {
406
+ this.merger.cancel(mergeKey);
407
+ this.mergedMessages.delete(mergeKey);
408
+ }
409
+ else if (this.merger.has(mergeKey)) {
410
+ await this.deliver(channel, msg.chatId, withReplyLocale(this.ctx, () => replyText('上一条消息正在合并,尚未执行本次命令。等待提交后再发 {0}。', command || '/help')));
411
+ return;
412
+ }
413
+ if ((command === '/new' || command === '/clear')
414
+ && binding
415
+ && (this.questions.has(binding.sessionId) || this.broker.has(binding.sessionId))) {
416
+ await this.deliver(channel, msg.chatId, withReplyLocale(this.ctx, () => replyText('请先完成当前问题或审批,再执行 /{0}。', command.slice(1))));
417
+ return;
418
+ }
419
+ const reply = await this.handleCommand(channel, msg);
420
+ if (reply)
421
+ await this.deliver(channel, msg.chatId, reply);
422
+ return;
423
+ }
424
+ if (binding && this.questions.has(binding.sessionId)) {
425
+ const actor = this.questionActors.get(binding.sessionId) ?? this.sessionActors.get(binding.sessionId);
426
+ if (actor && msg.userId !== actor) {
427
+ await this.deliver(channel, msg.chatId, notice(this.ctx, '只有发起当前任务的用户可以回答这个问题。'));
428
+ return;
429
+ }
430
+ if (!text || (msg.media?.length ?? 0) > 0) {
431
+ await this.deliver(channel, msg.chatId, notice(this.ctx, '请用文字回答当前问题。'));
432
+ return;
433
+ }
434
+ if (this.questions.isReady(binding.sessionId))
435
+ this.interactionMessageIds.set(binding.sessionId, msg.messageId);
436
+ const result = this.questions.answer(binding.sessionId, text);
437
+ if (result.handled) {
438
+ this.questionSelections.delete(binding.sessionId);
439
+ if (result.waitingPresentation) {
440
+ await this.deliver(channel, msg.chatId, notice(this.ctx, '问题详情仍在发送,请稍后再回答。'));
441
+ return;
442
+ }
443
+ if (result.next) {
444
+ const nextQuestion = result.next;
445
+ const signal = this.questions.signal(binding.sessionId);
446
+ const delivery = await this.retryInteraction(channel, () => this.deliverQuestionInteraction(binding.sessionId, channel, msg.chatId, this.formatQuestion(nextQuestion.question, nextQuestion.index, nextQuestion.total, { requiresMention: kind === 'group' }), signal));
447
+ if (delivery.status === 'aborted') {
448
+ this.questions.cancel(binding.sessionId, signal?.reason ?? new DOMException('Aborted', 'AbortError'));
449
+ }
450
+ else if (delivery.status === 'failed') {
451
+ this.questions.cancel(binding.sessionId, new Error('下一个交互问题发送失败'));
452
+ }
453
+ else
454
+ this.questions.activate(binding.sessionId);
455
+ }
456
+ return;
457
+ }
458
+ }
459
+ if (!msg.media?.length && await this.answerApproval(channelId, msg, this.approvalVerdict(text)))
460
+ return;
461
+ if (binding && this.broker.has(binding.sessionId) && !msg.media?.length) {
462
+ await this.deliver(channel, msg.chatId, notice(this.ctx, '还有未完成的审批,请回复批准或拒绝,或点按钮。'));
463
+ return;
464
+ }
465
+ if (msg.media && msg.media.length > 0) {
466
+ await this.inject(channel, msg);
467
+ return;
468
+ }
469
+ if (!text)
470
+ return;
471
+ if (channel.skipMerge) {
472
+ await this.inject(channel, { ...msg, text });
473
+ return;
474
+ }
475
+ const mergeKey = `${channelId}:${msg.kind === 'group' ? 'group' : 'dm'}:${msg.chatId}`;
476
+ this.mergedMessages.set(mergeKey, [...(this.mergedMessages.get(mergeKey) ?? []), msg]);
477
+ const merged = this.merger.ingest(mergeKey, text);
478
+ if (merged.kind === 'flushed' && merged.text) {
479
+ await this.inject(channel, { ...msg, text: merged.text }, this.takeMergedMessages(mergeKey, msg));
480
+ }
481
+ }
482
+ catch (error) {
483
+ this.log(`[${channelId}] 处理失败: ${error instanceof Error ? error.message : String(error)}`);
484
+ await channel.send(msg.chatId, error instanceof FileInputError ? error.message : msg.media?.some(media => media.kind === 'file') ? withReplyLocale(this.ctx, () => replyText('文件输入失败,整条消息未提交。请检查文件大小和格式后重新发送,或在网页 Chat 上传。')) : msg.media?.some(media => media.kind === 'image') ? withReplyLocale(this.ctx, () => imageInputFailure(error)) : notice(this.ctx, '消息处理失败,请查看本机日志。')).catch(() => undefined);
485
+ }
486
+ }
487
+ validDelivery(entry) {
488
+ const channel = this.channels.get(entry.channelId);
489
+ if (this.disposed || !channel)
490
+ return false;
491
+ const binding = this.router.lookup(entry.channelId, entry.message.kind ?? 'dm', entry.message.chatId);
492
+ return binding?.sessionId === entry.sessionId && decideAccess({ userAllowed: this.isAuthorized(entry.channelId, channel, entry.message), kind: entry.message.kind, addressed: true }) === 'allow';
493
+ }
494
+ async recoverDelivery(entry, fresh = false, explicit = false) {
495
+ return this.deferred.recover(entry.id, async (current) => {
496
+ const signal = AbortSignal.any([this.recoveryScope.signal, AbortSignal.timeout(30_000)]);
497
+ const result = await readDeliveryHistory(this.ctx, current.sessionId, current.id, signal, this.channels.get(current.channelId)?.skipInterimText);
498
+ if (!result)
499
+ return undefined;
500
+ return withReplyLocale(this.ctx, () => ({ turn: result.turn, text: [result.text ? replyText('补发此前任务的结果(不会重新执行任务):') : '', result.text,
501
+ result.kind === 'completed' ? replyText('此前任务已完成。文件请在原会话查看。') : result.kind === 'error' ? replyText('此前任务失败,已生成的内容保留。') : replyText('此前任务已停止,已生成的内容保留。')].filter(Boolean).join('\n\n') }));
502
+ }, current => this.validDelivery(current), async (current, text) => {
503
+ const channel = this.channels.get(current.channelId);
504
+ if (!channel || (!fresh && !channel.canDeliverDeferred?.()))
505
+ throw new DeliveryUnavailable();
506
+ await channel.send(current.message.chatId, text);
507
+ }, text => splitText(text, Math.max(1, (this.channels.get(entry.channelId)?.maxMessageLength ?? 2000) - 32)), explicit);
508
+ }
509
+ async recoverDeliveries(channel, message) {
510
+ if (this.recovering || this.disposed)
511
+ return;
512
+ this.recovering = true;
513
+ try {
514
+ const pending = this.deferred.list(channel?.id, message).filter(entry => ['waiting', 'ready'].includes(entry.status));
515
+ const start = message ? 0 : this.recoveryCursor % Math.max(1, pending.length);
516
+ const batch = [...pending.slice(start), ...pending.slice(0, start)].slice(0, 20);
517
+ this.recoveryCursor = start + batch.length;
518
+ for (const entry of batch) {
519
+ if (this.disposed)
520
+ break;
521
+ try {
522
+ await this.recoverDelivery(entry, !!message);
523
+ }
524
+ catch (error) {
525
+ this.log(`[im-delivery] 单条记录暂时无法恢复: ${error instanceof Error ? error.name : 'Error'}`);
526
+ }
527
+ }
528
+ }
529
+ catch (error) {
530
+ this.log(`[im-delivery] 补发检查失败: ${error instanceof Error ? error.name : 'Error'}`);
531
+ }
532
+ finally {
533
+ this.recovering = false;
534
+ }
535
+ }
536
+ async deliveryCommand(channel, message) {
537
+ return withReplyLocale(this.ctx, async () => {
538
+ const args = message.text.trim().split(/\s+/).slice(1);
539
+ const entries = this.deferred.list(channel.id, message);
540
+ if (args.length) {
541
+ const entry = entries.find(item => item.id === args[1]);
542
+ if (args.length !== 2 || args[0] !== 'retry' || !entry)
543
+ return replyText('用法:/delivery;补发指定结果:/delivery retry 记录ID。记录仅属于当前聊天和发起人。');
544
+ if (!this.validDelivery(entry))
545
+ return replyText('无法补发:请先恢复原会话绑定及访问权限。不会发送到其他会话。');
546
+ const result = await this.recoverDelivery(entry, true, true);
547
+ if (result)
548
+ return replyText('暂未找到可补发的结果,本次未发送,自动补发状态未改变。可稍后重试,或在网页查看原会话。');
549
+ }
550
+ const current = this.deferred.list(channel.id, message).slice(-10).reverse();
551
+ if (!current.length)
552
+ return replyText('没有近期交付记录。直接发送消息开始任务;查看最近记录:/history。');
553
+ const states = {
554
+ rejected: replyText('未提交任务,不需要补发'), waiting: replyText('等待原任务结果'), ready: replyText('等待渠道可发送'), sending: replyText('正在补发'), sent: replyText('已发送'),
555
+ unknown: replyText('送达未知,自动补发已暂停'), blocked: replyText('绑定或权限已变化,补发已暂停'), expired: replyText('已过期,请在网页查看原会话'),
556
+ };
557
+ return [replyText('最近交付记录(最多 10 条,保留 7 天):'), ...current.map(entry => `${entry.id} — ${states[entry.status]}`), '',
558
+ replyText('补发:/delivery retry 记录ID。送达未知的结果可能重复;只补发文字,不重新执行任务,文件请在网页查看。')].join('\n');
559
+ });
560
+ }
561
+ async handleCommand(channel, msg) {
562
+ const key = `${channel.id}:${msg.chatId}`;
563
+ if (/^\/stop(?:\s|$)/i.test(msg.text.trim()))
564
+ this.commandScopes.get(key)?.abort();
565
+ const scope = new AbortController();
566
+ this.commandScopes.set(key, scope);
567
+ try {
568
+ if (/^\/delivery(?:\s|$)/i.test(msg.text.trim()))
569
+ return await this.deliveryCommand(channel, msg);
570
+ return await this.chatCommands.execute(channel, msg, scope.signal);
571
+ }
572
+ catch (error) {
573
+ this.log(`[${channel.id}] 命令失败: ${error instanceof Error ? error.message : String(error)}`);
574
+ return withReplyLocale(this.ctx, () => scope.signal.aborted ? replyText('命令已取消。查看当前状态:/status') : replyText('命令执行失败:{0}\n\n查看用法:/help;确认当前会话:/status', error instanceof Error ? error.message : replyText('请查看本机日志。')));
575
+ }
576
+ finally {
577
+ if (this.commandScopes.get(key) === scope)
578
+ this.commandScopes.delete(key);
579
+ }
580
+ }
581
+ takeMergedMessages(key, fallback) {
582
+ const messages = this.mergedMessages.get(key) ?? [fallback];
583
+ this.mergedMessages.delete(key);
584
+ return messages;
585
+ }
586
+ async inject(channel, msg, sources = [msg]) {
587
+ if (this.disposed || this.channels.get(channel.id) !== channel)
588
+ return;
589
+ let scope = this.inputScopes.get(channel.id);
590
+ if (!scope)
591
+ this.inputScopes.set(channel.id, scope = new AbortController());
592
+ const { signal } = scope;
593
+ // 账号级发送方式:steer 作为补充指令进入下一步边界,不再显示「排队中」。
594
+ const sendMode = this.resolveConfig(channel.id).sendMode === 'steer' ? 'steer' : 'queue';
595
+ const items = sources.map(source => new MessageProgress(channel, source, this.ctx, this.log, sendMode === 'steer' ? 'processing' : 'queued'));
596
+ let accepted = false;
597
+ let submitted = false;
598
+ let requestId;
599
+ let reject = () => { for (const item of items)
600
+ item.finish('error'); };
601
+ try {
602
+ const kind = msg.kind === 'group' ? 'group' : 'dm';
603
+ const initialTitle = initialSessionTitle(msg.text) || initialSessionTitle(msg.media?.find(item => item.name)?.name || '');
604
+ const title = initialTitle || msg.username || msg.chatId;
605
+ const binding = await this.router.getOrCreate(channel.id, kind, msg.chatId, title);
606
+ if (initialTitle)
607
+ this.router.setTitle(binding.sessionId, initialTitle, 'message');
608
+ const content = [];
609
+ if (msg.text.trim())
610
+ content.push({ type: 'text', text: msg.text.trim() });
611
+ for (const media of msg.media ?? []) {
612
+ if (media.kind === 'image')
613
+ content.push(await imagePromptPart(media));
614
+ else if (media.kind === 'voice-text' && media.text)
615
+ content.push({ type: 'text', text: `[语音] ${media.text}` });
616
+ else if (media.kind !== 'file' && media.path)
617
+ content.push({ type: 'text', text: `[附件 ${media.name ?? media.kind}] ${media.path}` });
618
+ }
619
+ const files = msg.media?.filter(media => media.kind === 'file') ?? [];
620
+ if (files.length)
621
+ content.push(...await withReplyLocale(this.ctx, () => filePromptParts(this.ctx, binding.sessionId, files, signal)));
622
+ if (signal.aborted || content.length === 0)
623
+ return;
624
+ if (msg.userId)
625
+ this.sessionActors.set(binding.sessionId, msg.userId);
626
+ this.interactionMessageIds.set(binding.sessionId, msg.messageId);
627
+ this.streams.reset(`${channel.id}:${msg.chatId}`);
628
+ if (signal.aborted)
629
+ return;
630
+ requestId = crypto.randomUUID();
631
+ this.deferred.begin(requestId, binding.sessionId, channel.id, { ...msg, userId: msg.userId ?? sources[0]?.userId });
632
+ reject = this.progress.begin(binding.sessionId, requestId, items);
633
+ if (content.some(part => part.type === 'image' || part.type === 'file')) {
634
+ // Use Chat's public admission entry: session-local model selection, shared
635
+ // model-switch serialization, and durable attachment validation/storage.
636
+ // Optional lookup preserves text-only operation on older Hosts. Keep
637
+ // strict lookup so a pending or unloading provider is never invoked.
638
+ const controller = this.ctx.get('sessionController');
639
+ if (!controller?.prompt)
640
+ throw new ImageInputError('当前 Host 不支持 Chat 图片输入,请升级 DeepSeek Harness。');
641
+ submitted = true;
642
+ await controller.prompt({ requestId, sessionId: binding.sessionId, mode: sendMode, content }, signal);
643
+ }
644
+ else {
645
+ submitted = true;
646
+ this.router.followup(binding, {
647
+ id: requestId,
648
+ role: 'user',
649
+ content,
650
+ source: { kind: 'user', rpcId: requestId },
651
+ }, sendMode);
652
+ }
653
+ accepted = true;
654
+ this.log(`[${channel.id}] 已注入 ${binding.sessionId}`);
655
+ }
656
+ catch (error) {
657
+ try {
658
+ if (requestId)
659
+ this.deferred.reject(requestId, !submitted);
660
+ }
661
+ catch {
662
+ this.log('[im-delivery] 注入失败记录落盘失败,已暂停本进程恢复');
663
+ }
664
+ try {
665
+ reject();
666
+ }
667
+ catch {
668
+ this.log('[im-progress] 注入失败后的进度清理失败');
669
+ }
670
+ throw error;
671
+ }
672
+ finally {
673
+ if (!accepted)
674
+ for (const item of items)
675
+ item.finish('cancelled');
676
+ }
677
+ }
678
+ cancelInputs(channelId) {
679
+ if (!this.disposed)
680
+ this.deferred.block(channelId);
681
+ this.choices.clear(channelId);
682
+ this.progress.cancel(channelId);
683
+ for (const key of this.mergedMessages.keys())
684
+ if (key.startsWith(channelId + ':')) {
685
+ this.mergedMessages.delete(key);
686
+ this.merger.cancel(key);
687
+ }
688
+ for (const [key, scope] of this.commandScopes)
689
+ if (key.startsWith(channelId + ':'))
690
+ scope.abort();
691
+ this.inputScopes.get(channelId)?.abort();
692
+ this.inputScopes.delete(channelId);
693
+ }
694
+ approvalVerdict(text) {
695
+ const value = text.trim().toLowerCase();
696
+ if (['批准', '同意', 'yes', 'y', 'allow'].includes(value))
697
+ return true;
698
+ if (['拒绝', '不同意', 'no', 'n', 'reject', 'deny'].includes(value))
699
+ return false;
700
+ return undefined;
701
+ }
702
+ async answerApproval(channelId, msg, allow) {
703
+ if (allow === undefined || msg.media?.length)
704
+ return false;
705
+ const binding = this.router.lookup(channelId, msg.kind === 'group' ? 'group' : 'dm', msg.chatId);
706
+ if (!binding)
707
+ return false;
708
+ if (!this.broker.has(binding.sessionId))
709
+ return false;
710
+ // 群聊里只让发起当前任务的人批准,其他成员即使能发言也不能替其决定。
711
+ if (msg.kind === 'group') {
712
+ const actor = this.sessionActors.get(binding.sessionId);
713
+ if (actor && msg.userId && msg.userId !== actor) {
714
+ await this.channels.get(channelId)?.send(msg.chatId, notice(this.ctx, '只有发起当前任务的用户可以批准或拒绝。')).catch(() => undefined);
715
+ return true;
716
+ }
717
+ }
718
+ if (!this.broker.isReady(binding.sessionId)) {
719
+ await this.channels.get(channelId)?.send(msg.chatId, notice(this.ctx, '审批详情仍在发送,请稍后再回复。')).catch(() => undefined);
720
+ return true;
721
+ }
722
+ this.interactionMessageIds.set(binding.sessionId, msg.messageId);
723
+ const ok = this.broker.answer(binding.sessionId, allow);
724
+ if (ok)
725
+ await this.channels.get(channelId)?.send(msg.chatId, withReplyLocale(this.ctx, () => allow ? replyText('已批准。') : replyText('已拒绝。')));
726
+ return ok;
727
+ }
728
+ async onApproval(req, next) {
729
+ const id = String(req.agent?.session?.id ?? req.agent?.id ?? req.session?.id ?? '');
730
+ const resume = this.progress.waiting(id, true);
731
+ try {
732
+ return await this.handleApproval(req, next);
733
+ }
734
+ finally {
735
+ resume();
736
+ }
737
+ }
738
+ async handleApproval(req, next) {
739
+ const currentContract = req.agent !== undefined;
740
+ const rawSessionId = req.agent?.session?.id ?? req.agent?.id ?? req.session?.id;
741
+ const sessionId = rawSessionId ? String(rawSessionId) : '';
742
+ if (!sessionId || !this.router.bindingForSession(sessionId))
743
+ return next();
744
+ const refused = currentContract ? 'rejected' : { behavior: 'reject' };
745
+ const cancelled = currentContract ? 'cancelled' : { behavior: 'reject' };
746
+ if (req.signal?.aborted)
747
+ return cancelled;
748
+ return await this.runInteraction(sessionId, async () => {
749
+ if (this.disposed || req.signal?.aborted)
750
+ return cancelled;
751
+ const binding = this.router.bindingForSession(sessionId);
752
+ const channel = binding ? this.channels.get(binding.channelId) : undefined;
753
+ if (!binding || !channel)
754
+ return refused;
755
+ // 只记录真实发言人;不能拿 chatId 冒充用户,否则群聊会把发起者判成外人。
756
+ const knownActor = this.sessionActors.get(sessionId)
757
+ ?? this.chatActors.get(`${binding.channelId}:${binding.kind}:${binding.chatId}`);
758
+ if (knownActor)
759
+ this.sessionActors.set(sessionId, knownActor);
760
+ const prompt = await withReplyLocale(this.ctx, () => this.approvalPrompt(req));
761
+ const wait = this.broker.wait(sessionId, APPROVAL_WAIT_MS, req.signal);
762
+ if (!wait)
763
+ return refused;
764
+ const ticket = this.broker.token(sessionId);
765
+ const delivery = await this.retryInteraction(channel, () => this.deliverInteraction(channel, binding.chatId, prompt, req.signal, {
766
+ sessionId, message: { chatId: binding.chatId, userId: knownActor ?? binding.chatId, kind: binding.kind, text: '', messageId: this.interactionMessageIds.get(sessionId) },
767
+ choices: withReplyLocale(this.ctx, () => [{ label: replyText('批准一次'), value: 'allow' }, { label: replyText('拒绝'), value: 'reject' }]),
768
+ valid: () => this.broker.token(sessionId) === ticket && this.broker.isReady(sessionId),
769
+ }));
770
+ if (delivery.status === 'aborted' || req.signal?.aborted) {
771
+ this.broker.cancel(sessionId);
772
+ if (delivery.deliveredAny)
773
+ await this.announceInteractionCancelled(channel, binding.chatId, '审批');
774
+ return cancelled;
775
+ }
776
+ if (delivery.status === 'failed') {
777
+ this.broker.cancel(sessionId);
778
+ return refused;
779
+ }
780
+ this.broker.activate(sessionId);
781
+ const verdict = await wait;
782
+ if (req.signal?.aborted) {
783
+ if (delivery.deliveredAny)
784
+ await this.announceInteractionCancelled(channel, binding.chatId, '审批');
785
+ return cancelled;
786
+ }
787
+ if (verdict === 'allow')
788
+ return currentContract ? 'allowed-once' : { behavior: 'allow' };
789
+ if (verdict === 'reject')
790
+ return currentContract ? 'rejected' : { behavior: 'reject' };
791
+ if (verdict === 'timeout' && delivery.deliveredAny && !this.disposed) {
792
+ await this.announceApprovalTimedOut(channel, binding.chatId);
793
+ }
794
+ return cancelled;
795
+ }, req.signal, () => cancelled);
796
+ }
797
+ async onUserQuestions(req, next) {
798
+ const rawSessionId = req.agent?.session?.id ?? req.agent?.id;
799
+ const sessionId = rawSessionId ? String(rawSessionId) : '';
800
+ if (!sessionId || !this.router.bindingForSession(sessionId))
801
+ return next();
802
+ const questions = req.questions;
803
+ if (!Array.isArray(questions)
804
+ || questions.length === 0
805
+ || questions.some((question) => !validUserQuestion(question))) {
806
+ throw new Error('无法在当前聊天展示这个问题');
807
+ }
808
+ if (req.signal?.aborted) {
809
+ throw req.signal.reason ?? new DOMException('Aborted', 'AbortError');
810
+ }
811
+ const initialBinding = this.router.bindingForSession(sessionId);
812
+ const actor = this.sessionActors.get(sessionId) ?? (initialBinding ? this.chatActors.get(`${initialBinding.channelId}:${initialBinding.kind}:${initialBinding.chatId}`) : undefined);
813
+ if (actor)
814
+ this.sessionActors.set(sessionId, actor);
815
+ const typedQuestions = questions;
816
+ return await this.runInteraction(sessionId, async () => {
817
+ if (this.disposed || req.signal?.aborted) {
818
+ throw req.signal?.reason ?? new DOMException('Aborted', 'AbortError');
819
+ }
820
+ const binding = this.router.bindingForSession(sessionId);
821
+ const channel = binding ? this.channels.get(binding.channelId) : undefined;
822
+ if (!binding || !channel)
823
+ throw new Error('当前聊天已断开,无法在原聊天提问');
824
+ const wait = this.questions.begin(sessionId, typedQuestions, req.signal);
825
+ if (!wait)
826
+ throw new Error('当前聊天已有未完成的问题');
827
+ // Abort can reject while the prompt send is still in flight; attach the
828
+ // observer now, then await the same promise after presentation completes.
829
+ void wait.catch(() => undefined);
830
+ if (actor)
831
+ this.questionActors.set(sessionId, actor);
832
+ const resume = this.progress.waiting(sessionId, true);
833
+ try {
834
+ const delivery = await this.retryInteraction(channel, () => this.deliverQuestionInteraction(sessionId, channel, binding.chatId, this.formatQuestion(typedQuestions[0], 0, typedQuestions.length, { requiresMention: binding.kind === 'group' }), req.signal));
835
+ if (delivery.status === 'aborted' || req.signal?.aborted) {
836
+ throw req.signal?.reason ?? new DOMException('Aborted', 'AbortError');
837
+ }
838
+ if (delivery.status === 'failed') {
839
+ this.questions.cancel(sessionId, new Error('交互问题未能发送到原聊天'));
840
+ throw new Error('交互问题未能发送到原聊天');
841
+ }
842
+ this.questions.activate(sessionId);
843
+ return await wait;
844
+ }
845
+ catch (error) {
846
+ if (req.signal?.aborted) {
847
+ await this.questionDeliveries.get(sessionId)?.catch(() => undefined);
848
+ if (this.questionPromptDelivered.has(sessionId)) {
849
+ await this.announceInteractionCancelled(channel, binding.chatId, '问题');
850
+ }
851
+ }
852
+ throw error;
853
+ }
854
+ finally {
855
+ resume();
856
+ this.questionSelections.delete(sessionId);
857
+ this.questionActors.delete(sessionId);
858
+ this.questionPromptDelivered.delete(sessionId);
859
+ }
860
+ }, req.signal, () => {
861
+ throw req.signal?.reason ?? new DOMException('Aborted', 'AbortError');
862
+ });
863
+ }
864
+ /**
865
+ * DSH 0.1.1-rc.2 exposes a mutable provider behind a stable service.ask.
866
+ * Decorate the service so later provider registrations remain visible through
867
+ * the original service implementation, while non-IM sessions keep its path.
868
+ */
869
+ scheduleLegacyUserQuestionService() {
870
+ let attempt = 0;
871
+ const retry = () => {
872
+ if (this.disposed)
873
+ return;
874
+ const delay = Math.min(25 * 2 ** attempt, 2_000);
875
+ this.legacyServiceTimer = setTimeout(() => {
876
+ this.legacyServiceTimer = undefined;
877
+ if (this.installLegacyUserQuestionService())
878
+ return;
879
+ attempt += 1;
880
+ if (attempt === 8)
881
+ this.log('[interaction] 暂未发现 userQuestions service,保留 waterfall 并继续监听');
882
+ retry();
883
+ }, delay);
884
+ this.legacyServiceTimer.unref?.();
885
+ };
886
+ retry();
887
+ }
888
+ installLegacyUserQuestionService(candidate) {
889
+ if (this.disposed)
890
+ return false;
891
+ if (candidate === undefined) {
892
+ try {
893
+ candidate = this.ctx.get?.('userQuestions', false);
894
+ }
895
+ catch {
896
+ return false;
897
+ }
898
+ }
899
+ if (!candidate || typeof candidate !== 'object')
900
+ return false;
901
+ const service = candidate;
902
+ const currentDescriptor = Reflect.getOwnPropertyDescriptor(service, 'ask');
903
+ const currentAsk = currentDescriptor?.value;
904
+ if (this.wrappedUserQuestionServices.has(service) || currentAsk?.[USER_QUESTION_WRAPPER] === this)
905
+ return true;
906
+ if (typeof service.ask !== 'function')
907
+ return false;
908
+ const originalAsk = service.ask;
909
+ const originalDescriptor = currentDescriptor;
910
+ const wrappedAsk = (request) => this.onUserQuestions(request, () => originalAsk.call(service, request));
911
+ Object.defineProperty(wrappedAsk, USER_QUESTION_WRAPPER, { value: this });
912
+ try {
913
+ service.ask = wrappedAsk;
914
+ }
915
+ catch {
916
+ return false;
917
+ }
918
+ if (Reflect.getOwnPropertyDescriptor(service, 'ask')?.value !== wrappedAsk)
919
+ return false;
920
+ this.wrappedUserQuestionServices.add(service);
921
+ this.log('[interaction] 已接管 userQuestions service 的 IM 会话');
922
+ this.disposeEvents.push(() => {
923
+ if (Reflect.getOwnPropertyDescriptor(service, 'ask')?.value !== wrappedAsk)
924
+ return;
925
+ if (originalDescriptor)
926
+ Reflect.defineProperty(service, 'ask', originalDescriptor);
927
+ else
928
+ Reflect.deleteProperty(service, 'ask');
929
+ });
930
+ return true;
931
+ }
932
+ /**
933
+ * 同一会话的人机交互严格串行。队首只有在用户回复、AbortSignal 或会话销毁时释放;
934
+ * 审批等待有上限。到期由插件按取消答复宿主,不和网页审批抢决定;宿主信号或会话销毁仍优先,且不发超时通知。
935
+ */
936
+ runInteraction(sessionId, task, signal, onAbort) {
937
+ const previous = this.interactionQueues.get(sessionId) ?? Promise.resolve();
938
+ let abortedBeforeStart = false;
939
+ let started = false;
940
+ const scheduled = previous.catch(() => undefined).then(() => {
941
+ started = true;
942
+ if (abortedBeforeStart)
943
+ return undefined;
944
+ return task();
945
+ });
946
+ const tail = scheduled.then(() => undefined, () => undefined);
947
+ this.interactionQueues.set(sessionId, tail);
948
+ void tail.then(() => {
949
+ if (this.interactionQueues.get(sessionId) === tail)
950
+ this.interactionQueues.delete(sessionId);
951
+ });
952
+ if (!signal || !onAbort)
953
+ return scheduled;
954
+ return new Promise((resolve, reject) => {
955
+ let settled = false;
956
+ const finish = (callback) => {
957
+ if (settled)
958
+ return;
959
+ settled = true;
960
+ signal.removeEventListener('abort', abort);
961
+ callback();
962
+ };
963
+ const abort = () => {
964
+ if (!started)
965
+ abortedBeforeStart = true;
966
+ Promise.resolve().then(onAbort).then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
967
+ };
968
+ signal.addEventListener('abort', abort, { once: true });
969
+ scheduled.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
970
+ if (signal.aborted)
971
+ abort();
972
+ });
973
+ }
974
+ async approvalPrompt(req) {
975
+ const toolName = req.toolName?.trim() || '工具操作';
976
+ const lines = [
977
+ replyText('DeepSeek Harness 需要你的审批:'),
978
+ '',
979
+ replyText('工具:{0}', toolName),
980
+ ];
981
+ const callId = req.callId?.trim();
982
+ const session = req.agent?.session;
983
+ const events = callId ? await readSessionHistory(this.ctx, session) ?? [] : [];
984
+ const event = callId ? events.findLast((item) => {
985
+ if (item.type === 'tool/call')
986
+ return item.data?.callId === callId;
987
+ if (item.type === 'tool/code-dispatch-start' || item.type === 'tool/ptc-dispatch-start')
988
+ return item.data?.subCallId === callId;
989
+ return false;
990
+ }) : undefined;
991
+ const args = event?.data?.arguments;
992
+ let rendered = '';
993
+ try {
994
+ rendered = typeof args === 'string' ? args : args === undefined ? '' : JSON.stringify(args, null, 2);
995
+ }
996
+ catch {
997
+ rendered = '';
998
+ }
999
+ if (rendered.trim() && rendered.length <= 6_000 && (!event?.data?.name || event.data.name === toolName)) {
1000
+ lines.push(replyText('操作参数:'), rendered);
1001
+ }
1002
+ else {
1003
+ lines.push(replyText('参数暂时无法读取。仍请在此批准或拒绝,不要到网页处理。'));
1004
+ }
1005
+ const reason = req.reason?.trim();
1006
+ if (reason)
1007
+ lines.push(replyText('原因:{0}', reason));
1008
+ lines.push('', replyText('请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / allow / no / reject)。'));
1009
+ lines.push(replyText('{0} 分钟内未回复将取消,这次不会执行。', APPROVAL_WAIT_MS / 60_000));
1010
+ return lines.join('\n');
1011
+ }
1012
+ onSessionEvent(session, event) {
1013
+ const id = String(session.id ?? '');
1014
+ if (event.type === 'turn/start' && Number.isSafeInteger(event.data?.turn)) {
1015
+ if (this.observedTurns.size >= 1024)
1016
+ this.observedTurns.delete(this.observedTurns.keys().next().value);
1017
+ this.observedTurns.set(id, event.data.turn);
1018
+ }
1019
+ if (event.type === 'user/message' && this.observedTurns.has(id))
1020
+ this.deferred.claim(id, this.observedTurns.get(id), event.data?.source?.rpcId ?? event.data?.id ?? '');
1021
+ const turn = event.data?.turn ?? this.observedTurns.get(id);
1022
+ if (['assistant/chunk', 'assistant/message', 'turn/end'].includes(event.type ?? '') && this.deferred.coldTurn(id, turn))
1023
+ return Promise.resolve();
1024
+ const tracked = this.deferred.turnEntries(id, turn);
1025
+ const binding = this.router.bindingForSession(id);
1026
+ const channel = binding && this.channels.get(binding.channelId);
1027
+ const unavailable = !!channel?.canDeliverDeferred && !channel.canDeliverDeferred();
1028
+ const invalid = tracked.some(entry => entry.status === 'blocked' || !this.validDelivery(entry));
1029
+ if (invalid)
1030
+ for (const entry of tracked)
1031
+ if (entry.status !== 'sent')
1032
+ this.deferred.patch(entry.id, { status: 'blocked' });
1033
+ const skip = (unavailable || invalid) && ['assistant/chunk', 'assistant/message', 'turn/end'].includes(event.type ?? '');
1034
+ const trackedEnd = event.type === 'turn/end' && this.progress.hasTurn(id, event.data?.turn);
1035
+ this.progress.event(id, event);
1036
+ const outcome = { ok: !skip, content: false };
1037
+ const work = skip ? Promise.resolve() : this.processSessionEvent(session, event, outcome, trackedEnd);
1038
+ if (event.type === 'assistant/message' && event.surfaceOp === 'append') {
1039
+ this.progress.delivery(id, event.data?.turn, work.then(() => !outcome.ok ? (outcome.definite ? 'rejected' : false) : outcome.content ? true : undefined, () => false));
1040
+ }
1041
+ return work.catch(error => { this.log(`[im-progress] 会话回复失败: ${error instanceof Error ? error.name : 'Error'}`); });
1042
+ }
1043
+ async processSessionEvent(session, event, outcome, trackedEnd = false) {
1044
+ const sessionId = session.id ? String(session.id) : '';
1045
+ if (!this.router.bindingForSession(sessionId))
1046
+ return;
1047
+ if (event.type === 'session/title') {
1048
+ const title = readSessionTitle(event.data);
1049
+ if (title) {
1050
+ this.router.setTitle(sessionId, title.title, title.source);
1051
+ }
1052
+ return;
1053
+ }
1054
+ // 静音会话:保留登记与标题同步,但不向聊天投递任何输出事件。
1055
+ if (this.router.isMuted(sessionId))
1056
+ return;
1057
+ const binding = this.router.bindingForSession(sessionId);
1058
+ const channel = binding ? this.channels.get(binding.channelId) : undefined;
1059
+ if (!binding || !channel)
1060
+ return;
1061
+ const streamKey = `${binding.channelId}:${binding.chatId}`;
1062
+ const stillDeliverable = () => {
1063
+ const current = this.router.bindingForSession(sessionId);
1064
+ return !this.disposed && current?.channelId === binding.channelId && current.chatId === binding.chatId
1065
+ && !this.router.isMuted(sessionId) && this.channels.get(binding.channelId) === channel;
1066
+ };
1067
+ const chunk = event.data?.chunk;
1068
+ const markAttempt = () => this.deferred.liveStart(sessionId, event.data?.turn ?? this.observedTurns.get(sessionId));
1069
+ if (event.type === 'assistant/chunk' && channel.beginReply && isAssistantTextDelta(chunk)) {
1070
+ markAttempt();
1071
+ // 事件回调不在请求链路里,流式更新失败必须自兜底,避免 unhandled rejection
1072
+ void this.streams.onTextDelta(streamKey, chunk.text, () => channel.beginReply(binding.chatId).catch(() => undefined), sessionId)
1073
+ .catch((error) => {
1074
+ this.log(`[${channel.id}] 流式更新失败: ${error instanceof Error ? error.message : String(error)}`);
1075
+ });
1076
+ return;
1077
+ }
1078
+ if (event.type === 'turn/end') {
1079
+ const reason = event.data?.reason;
1080
+ this.log(`[${channel.id}] 回合结束 ${sessionId}: ${reason?.kind ?? 'ok'}`);
1081
+ if (reason?.kind === 'error' && !trackedEnd) {
1082
+ const detail = reason.error?.message || '模型调用失败';
1083
+ this.log(`[${channel.id}] 回合失败 ${sessionId}: ${detail}`);
1084
+ const failed = notice(this.ctx, '助手没有生成回复,请查看本机日志。');
1085
+ const taken = await this.streams.take(streamKey, sessionId);
1086
+ if (taken.invalidated || !stillDeliverable())
1087
+ return;
1088
+ let failureDelivered;
1089
+ if (taken.stream) {
1090
+ failureDelivered = await taken.stream.finish([taken.text, failed].filter(Boolean).join('\n\n')).then(() => true).catch(() => this.deliver(channel, binding.chatId, failed));
1091
+ }
1092
+ else {
1093
+ failureDelivered = await this.deliver(channel, binding.chatId, failed);
1094
+ }
1095
+ // 仅在确已送达时标记,失败后同回合残留的 assistant/message 还有机会补发
1096
+ if (failureDelivered)
1097
+ this.streams.markDelivered(streamKey, sessionId);
1098
+ }
1099
+ return;
1100
+ }
1101
+ if (event.type === 'assistant/message') {
1102
+ let stopped = false;
1103
+ try {
1104
+ const text = assistantText(event.data?.message?.content, channel.skipInterimText);
1105
+ if (text === undefined) {
1106
+ this.log(`[${channel.id}] 跳过含工具调用的中间叙述 ${sessionId}`);
1107
+ return;
1108
+ }
1109
+ const taken = await this.streams.take(streamKey, sessionId);
1110
+ stopped = !!taken.invalidated || !stillDeliverable();
1111
+ if (stopped)
1112
+ return;
1113
+ if (taken.stream) {
1114
+ const finalText = text || taken.text;
1115
+ if (finalText) {
1116
+ outcome.content = true;
1117
+ markAttempt();
1118
+ let delivered = true;
1119
+ try {
1120
+ await taken.stream.finish(finalText);
1121
+ }
1122
+ catch (error) {
1123
+ // 请求可能已经到达渠道,不能自动补一份重复回复。
1124
+ this.log(`[${channel.id}] 流式收口结果未知,请通过 /delivery 查询: ${error instanceof Error ? error.message : String(error)}`);
1125
+ delivered = false;
1126
+ outcome.ok = false;
1127
+ this.streams.markDelivered(streamKey, sessionId);
1128
+ }
1129
+ outcome.ok = outcome.ok && delivered;
1130
+ if (delivered)
1131
+ this.streams.markDelivered(streamKey, sessionId);
1132
+ }
1133
+ return;
1134
+ }
1135
+ if (this.streams.consumeDelivered(streamKey, sessionId)) {
1136
+ this.log(`[${channel.id}] 忽略重复助手消息 ${sessionId}`);
1137
+ return;
1138
+ }
1139
+ if (text) {
1140
+ outcome.content = true;
1141
+ markAttempt();
1142
+ this.log(`[${channel.id}] 准备回复 ${sessionId},长度 ${text.length}`);
1143
+ const delivered = await this.deliver(channel, binding.chatId, text, outcome);
1144
+ outcome.ok = outcome.ok && delivered;
1145
+ }
1146
+ else {
1147
+ this.log(`[${channel.id}] 助手消息为空 ${sessionId}`);
1148
+ }
1149
+ }
1150
+ finally {
1151
+ const filesOk = await this.fileDelivery.deliver(session, event, () => {
1152
+ return !stopped && stillDeliverable() ? { channel, chatId: binding.chatId } : undefined;
1153
+ }, () => { outcome.content = true; markAttempt(); });
1154
+ outcome.ok = outcome.ok && filesOk;
1155
+ }
1156
+ }
1157
+ }
1158
+ /** 正常交付保持安静;仅异常结果追加必要文字提示。 */
1159
+ async notifyCompletion(result) {
1160
+ if (result.status === 'completed')
1161
+ return;
1162
+ const status = result.status;
1163
+ const first = result.items[0];
1164
+ if (!first)
1165
+ return;
1166
+ const { channel, message } = first;
1167
+ const valid = () => {
1168
+ const current = this.router.lookup(channel.id, message.kind ?? 'dm', message.chatId);
1169
+ return !this.disposed && this.channels.get(channel.id) === channel && current?.sessionId === result.sessionId
1170
+ && (message.kind === 'group' || this.isAuthorized(channel.id, channel, message))
1171
+ && (!channel.canDeliverDeferred || channel.canDeliverDeferred())
1172
+ && !this.progress.hasNewerTurn(result.sessionId, result.turn)
1173
+ && !this.questions.has(result.sessionId) && !this.broker.has(result.sessionId);
1174
+ };
1175
+ if (!valid())
1176
+ return;
1177
+ const terminalIds = [];
1178
+ const notified = await withReplyLocale(this.ctx, async () => {
1179
+ const text = ({
1180
+ empty: replyText('本次处理已结束,未返回可投递的结果。可补充要求后继续。'),
1181
+ error: replyText('本次处理失败,已返回的内容保留。请查看会话状态,或补充要求后重试。'),
1182
+ cancelled: replyText('本次处理已停止,已返回的内容保留。可直接发消息继续。'),
1183
+ 'delivery-failed': replyText('本次处理已结束,但部分回复或文件未能发送。查看交付记录:/delivery;完整结果和文件请在网页查看,不会自动重复执行任务。'),
1184
+ })[status];
1185
+ if (!valid())
1186
+ return;
1187
+ if (result.status === 'error' || result.status === 'cancelled' || result.status === 'empty') {
1188
+ for (const entry of this.deferred.list(channel.id, message))
1189
+ if (entry.sessionId === result.sessionId && entry.turn === result.turn && entry.status === 'waiting') {
1190
+ this.deferred.patch(entry.id, { status: 'unknown' });
1191
+ terminalIds.push(entry.id);
1192
+ }
1193
+ const taken = await this.streams.take(`${channel.id}:${message.chatId}`, result.sessionId);
1194
+ if (!valid())
1195
+ return;
1196
+ if (taken.stream) {
1197
+ const body = text;
1198
+ // 复用已有回复流发送异常说明;收口结果不明时不重复发送。
1199
+ await taken.stream.finish([taken.text, body].filter(Boolean).join('\n\n'));
1200
+ return true;
1201
+ }
1202
+ }
1203
+ const outcome = { ok: true };
1204
+ await this.deliver(channel, message.chatId, text, outcome);
1205
+ return outcome.ok;
1206
+ });
1207
+ if (notified)
1208
+ for (const id of terminalIds)
1209
+ this.deferred.patch(id, { status: 'sent' });
1210
+ }
1211
+ /** 逐片发送;返回是否至少送达过一片,供调用方决定是否标记已投递。 */
1212
+ async deliver(channel, chatId, text, outcome) {
1213
+ let deliveredAny = false;
1214
+ let ambiguous = false;
1215
+ // 重试预算按整条回复共享:钱花在第一个被拒的分片上,窗口一开后面的分片自然跟上。
1216
+ const deadline = Date.now() + SEND_RETRY_BUDGET_MS;
1217
+ for (const chunk of splitText(text, channel.maxMessageLength)) {
1218
+ const result = await this.sendChunk(channel, chatId, chunk, deadline);
1219
+ if (result.ok) {
1220
+ deliveredAny = true;
1221
+ this.log(`[${channel.id}] 已投递 ${chatId},长度 ${chunk.length}`);
1222
+ }
1223
+ else {
1224
+ if (outcome)
1225
+ outcome.ok = false;
1226
+ if (!isDefiniteSendFailure(result.error))
1227
+ ambiguous = true;
1228
+ this.log(`[${channel.id}] 回复失败: ${result.error instanceof Error ? result.error.message : String(result.error)}`);
1229
+ }
1230
+ }
1231
+ // 一片都没送到、且每次失败都是平台明确拒绝:不存在重复投递风险,交给自动补发。
1232
+ if (!deliveredAny && !ambiguous && outcome)
1233
+ outcome.definite = true;
1234
+ return deliveredAny;
1235
+ }
1236
+ /**
1237
+ * 发送一个分片,平台明确拒绝时按 {@link SEND_RETRY_DELAYS_MS} 退避重试。
1238
+ * 结果不明的失败(超时、网络中断)立即返回:请求可能已经送达。
1239
+ * 等待超过 `deadline` 就放弃,让整条回复的重试开销有上界。
1240
+ */
1241
+ async sendChunk(channel, chatId, chunk, deadline) {
1242
+ let error;
1243
+ for (const [attempt, delay] of this.sendRetryDelaysMs.entries()) {
1244
+ if (attempt > 0) {
1245
+ if (this.disposed || Date.now() + delay > deadline)
1246
+ break;
1247
+ this.log(`[${channel.id}] 平台拒收,${delay / 1000}s 后重试(第 ${attempt + 1}/${this.sendRetryDelaysMs.length} 次)`);
1248
+ // dispose 会中止 recoveryScope,退避等待随之立即结束,不拖住插件卸载。
1249
+ try {
1250
+ await sleepWithSignal(delay, this.recoveryScope.signal);
1251
+ }
1252
+ catch {
1253
+ break;
1254
+ }
1255
+ if (this.disposed)
1256
+ break;
1257
+ }
1258
+ try {
1259
+ await channel.send(chatId, chunk);
1260
+ return { ok: true };
1261
+ }
1262
+ catch (caught) {
1263
+ error = caught;
1264
+ if (!isDefiniteSendFailure(caught))
1265
+ return { ok: false, error };
1266
+ }
1267
+ }
1268
+ return { ok: false, error };
1269
+ }
1270
+ formatQuestion(...args) { return withReplyLocale(this.ctx, () => formatUserQuestion(...args)); }
1271
+ deliverQuestionInteraction(sessionId, channel, chatId, text, signal) {
1272
+ let tracked;
1273
+ const current = this.questions.current(sessionId);
1274
+ const binding = this.router.bindingForSession(sessionId);
1275
+ const actor = this.questionActors.get(sessionId) ?? this.sessionActors.get(sessionId) ?? (binding ? this.chatActors.get(`${binding.channelId}:${binding.kind}:${binding.chatId}`) : undefined) ?? (binding?.kind === 'dm' ? chatId : undefined);
1276
+ const selected = this.questionSelections.get(sessionId) ?? new Set();
1277
+ const choices = current?.question.options?.map((option, index) => ({
1278
+ label: (current.question.multiSelect && selected.has(index) ? '✓ ' : '') + option.label,
1279
+ value: current.question.multiSelect ? `#question:${index}` : String(index + 1),
1280
+ textIncluded: true,
1281
+ })) ?? [];
1282
+ if (current?.question.multiSelect && selected.size)
1283
+ choices.push({ label: withReplyLocale(this.ctx, () => replyText('提交所选')), value: [...selected].map(index => index + 1).join(','), textIncluded: true });
1284
+ tracked = this.deliverInteraction(channel, chatId, text, signal, current && actor && choices.length ? {
1285
+ sessionId, message: { chatId, userId: actor, kind: binding?.kind ?? 'dm', text: '', messageId: this.interactionMessageIds.get(sessionId) }, choices,
1286
+ valid: () => this.questions.isReady(sessionId) && this.questions.current(sessionId)?.question === current.question,
1287
+ } : undefined).then((result) => {
1288
+ if (result.deliveredAny)
1289
+ this.questionPromptDelivered.add(sessionId);
1290
+ return result;
1291
+ }).finally(() => {
1292
+ if (this.questionDeliveries.get(sessionId) === tracked)
1293
+ this.questionDeliveries.delete(sessionId);
1294
+ });
1295
+ this.questionDeliveries.set(sessionId, tracked);
1296
+ return tracked;
1297
+ }
1298
+ async announceApprovalTimedOut(channel, chatId) {
1299
+ await this.deliver(channel, chatId, notice(this.ctx, '审批已超时,这次未执行。还要的话请重新说明。'));
1300
+ }
1301
+ async announceInteractionCancelled(channel, chatId, kind) {
1302
+ await this.deliver(channel, chatId, notice(this.ctx, kind === '审批' ? '该审批已取消,无需回复。' : '该问题已取消,无需回复。'));
1303
+ }
1304
+ /** 同一条提示失败后再发一次。部分平台的正文和问题撞车时,第一次会失败,排空后的重试仍可在聊天里回答。 */
1305
+ async retryInteraction(channel, deliver) {
1306
+ const first = await deliver();
1307
+ if (first.status !== 'failed')
1308
+ return first;
1309
+ this.log(`[${channel.id}] 交互提示发送失败,重试一次`);
1310
+ return deliver();
1311
+ }
1312
+ /** 交互提示必须完整送达;任一分片失败或取消就不能继续在 IM 中收集决定。 */
1313
+ async deliverInteraction(channel, chatId, text, signal, interactive) {
1314
+ let deliveredAny = false;
1315
+ if (channel.sendChoices && interactive && text.length < channel.maxMessageLength - 512 && !signal?.aborted) {
1316
+ const result = await withReplyLocale(this.ctx, () => this.choices.show(channel, interactive.message, text, interactive.choices, interactive.sessionId, interactive.valid, replyText('点击按钮或按提示回复文字。')));
1317
+ if (!result)
1318
+ return { status: signal?.aborted ? 'aborted' : 'delivered', deliveredAny: true };
1319
+ }
1320
+ for (const chunk of splitText(text, channel.maxMessageLength)) {
1321
+ if (signal?.aborted)
1322
+ return { status: 'aborted', deliveredAny };
1323
+ try {
1324
+ await channel.send(chatId, chunk);
1325
+ deliveredAny = true;
1326
+ this.log(`[${channel.id}] 已投递交互 ${chatId},长度 ${chunk.length}`);
1327
+ }
1328
+ catch (error) {
1329
+ this.log(`[${channel.id}] 交互提示发送失败: ${error instanceof Error ? error.message : String(error)}`);
1330
+ return { status: 'failed', deliveredAny };
1331
+ }
1332
+ if (signal?.aborted)
1333
+ return { status: 'aborted', deliveredAny };
1334
+ }
1335
+ return { status: 'delivered', deliveredAny };
1336
+ }
1337
+ }