@xmanrui/dsh-im 0.7.0 → 0.7.2

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 (30) hide show
  1. package/lib/index.js +119 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +423 -8
  4. package/src/channels/dingtalk/harness-client.mjs +16 -366
  5. package/src/channels/discord/discord-api.mjs +1 -1
  6. package/src/channels/discord/discord-runtime.mjs +11 -1
  7. package/src/channels/discord/harness-client.mjs +10 -2
  8. package/src/channels/feishu/bridge.mjs +571 -50
  9. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  10. package/src/channels/feishu/harness-client.mjs +16 -335
  11. package/src/channels/qq/harness-client.mjs +10 -2
  12. package/src/channels/qq/qq-bridge.mjs +428 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-approval.mjs +472 -0
  15. package/src/channels/shared/harness-client.mjs +858 -0
  16. package/src/channels/shared/harness-question.mjs +85 -0
  17. package/src/channels/shared/text-harness-bridge.mjs +486 -24
  18. package/src/channels/slack/harness-client.mjs +10 -2
  19. package/src/channels/slack/slack-runtime.mjs +11 -1
  20. package/src/channels/telegram/harness-client.mjs +10 -2
  21. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  22. package/src/channels/wecom/harness-client.mjs +10 -2
  23. package/src/channels/wecom/wecom-bridge.mjs +434 -14
  24. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  25. package/src/channels/weixin/harness-client.mjs +16 -326
  26. package/src/channels/weixin/weixin-api.mjs +1 -1
  27. package/src/channels/weixin/weixin-bridge.mjs +451 -22
  28. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  29. package/src/channels/whatsapp/harness-client.mjs +10 -2
  30. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -5,9 +5,18 @@ import {
5
5
  isBotSender,
6
6
  splitText,
7
7
  } from './message-utils.mjs';
8
+ import {
9
+ harnessAnswerForQuestion,
10
+ harnessQuestionText,
11
+ validHarnessQuestion,
12
+ } from '../shared/harness-question.mjs';
13
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
8
14
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
9
15
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
10
16
 
17
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
18
+ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
19
+
11
20
  const HELP_TEXT = [
12
21
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
13
22
  '',
@@ -21,16 +30,50 @@ const HELP_TEXT = [
21
30
  '/help 显示本帮助',
22
31
  ].join('\n');
23
32
 
33
+ function nonEmptyString(value) {
34
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
35
+ }
36
+
37
+ function senderOpenId(event) {
38
+ return nonEmptyString(event?.sender?.sender_id?.open_id)
39
+ ?? nonEmptyString(event?.sender?.sender_id?.user_id);
40
+ }
41
+
42
+ function canClaimInteractionReply(event, pending) {
43
+ return pending.needsPresentation !== true
44
+ && pending.questions[pending.index]
45
+ && senderOpenId(event) === pending.actor
46
+ && event?.message?.message_type === 'text'
47
+ && nonEmptyString(extractText(event));
48
+ }
49
+
50
+ function ensureStatus(status) {
51
+ for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected']) {
52
+ status[key] ??= 0;
53
+ }
54
+ status.lastMessageAt ??= null;
55
+ status.lastReplyAt ??= null;
56
+ status.lastRejectedAt ??= null;
57
+ status.lastError ??= null;
58
+ }
59
+
24
60
  export class FeishuHarnessBridge {
25
61
  #client;
26
62
  #channel;
27
63
  #harness;
28
64
  #state;
29
65
  #queues = new Map();
66
+ #pendingInteractions = new Map();
67
+ #interactionKeys = new Map();
68
+ #resolvedQuestionReplies = new Map();
30
69
  #acceptedMessageIds = new Set();
70
+ #interactionTasks = new Set();
71
+ #approvals;
31
72
  #status;
32
73
  #allowedSenderOpenIds;
33
74
  #replyTimeoutMs;
75
+ #logger;
76
+ #signal;
34
77
 
35
78
  constructor({
36
79
  client,
@@ -39,8 +82,13 @@ export class FeishuHarnessBridge {
39
82
  state,
40
83
  status,
41
84
  allowedSenderOpenIds = new Set(),
42
- replyTimeoutMs = 600000,
85
+ replyTimeoutMs = 600_000,
86
+ logger = console,
87
+ signal,
43
88
  }) {
89
+ if (!client || !harness || !state || !status) {
90
+ throw new TypeError('Feishu bridge dependencies are required');
91
+ }
44
92
  this.#client = client;
45
93
  this.#channel = channel;
46
94
  this.#harness = harness;
@@ -48,55 +96,210 @@ export class FeishuHarnessBridge {
48
96
  this.#status = status;
49
97
  this.#allowedSenderOpenIds = allowedSenderOpenIds;
50
98
  this.#replyTimeoutMs = replyTimeoutMs;
99
+ this.#logger = logger;
100
+ this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
101
+ this.#signal = signal;
102
+ ensureStatus(this.#status);
51
103
  }
52
104
 
53
105
  accept(event) {
54
- const messageId = event?.message?.message_id;
55
- if (!messageId || isBotSender(event) || event?.message?.message_type !== 'text') return;
106
+ if (this.#signal?.aborted) return Promise.resolve();
107
+ const messageId = nonEmptyString(event?.message?.message_id);
108
+ if (!messageId || isBotSender(event)) return Promise.resolve();
56
109
  if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
57
110
  this.#status.messagesRejected += 1;
58
111
  this.#status.lastRejectedAt = new Date().toISOString();
59
- console.warn('[bridge] ignored a message from a sender outside the allowlist');
60
- return;
112
+ this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
113
+ return Promise.resolve();
114
+ }
115
+ if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
116
+ return Promise.resolve();
61
117
  }
62
- if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return;
118
+
119
+ let key;
120
+ try {
121
+ key = conversationKey(event);
122
+ } catch {
123
+ this.#status.messagesRejected += 1;
124
+ this.#status.lastRejectedAt = new Date().toISOString();
125
+ return Promise.resolve();
126
+ }
127
+
63
128
  this.#acceptedMessageIds.add(messageId);
64
129
  const processingReaction = this.#addReaction(messageId, 'OnIt');
130
+ if (this.#isResolvedQuestionReply(event, key)) {
131
+ const current = Promise.resolve()
132
+ .then(() => this.#discardResolvedInteractionReply(event, messageId))
133
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
134
+ .catch((error) => this.#handleMessageFailure(
135
+ event,
136
+ messageId,
137
+ processingReaction,
138
+ error,
139
+ ))
140
+ .finally(() => this.#acceptedMessageIds.delete(messageId));
141
+ return current;
142
+ }
143
+ const pending = this.#pendingInteractions.get(key);
144
+ const approvalReply = this.#approvals.claimReply({
145
+ key,
146
+ actor: senderOpenId(event),
147
+ messageId,
148
+ text: extractText(event) ?? '',
149
+ addressed: event?.message?.chat_type === 'p2p'
150
+ || (Array.isArray(event?.message?.mentions) && event.message.mentions.length > 0),
151
+ hasPendingQuestion: Boolean(pending),
152
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
153
+ ? pending.queue
154
+ : null,
155
+ isQuestionPending: () => this.#pendingInteractions.has(key),
156
+ send: (text) => this.#send(event.message.chat_id, text),
157
+ });
158
+ if (approvalReply) {
159
+ const processing = approvalReply.process(async () => {
160
+ if (this.#state.hasSeen(messageId)) return false;
161
+ await this.#state.markSeen(messageId);
162
+ this.#status.lastMessageAt = new Date().toISOString();
163
+ this.#status.messagesReceived += 1;
164
+ return true;
165
+ });
166
+ let current;
167
+ current = processing
168
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
169
+ .catch((error) => this.#handleMessageFailure(
170
+ event,
171
+ messageId,
172
+ processingReaction,
173
+ error,
174
+ ))
175
+ .finally(() => {
176
+ this.#acceptedMessageIds.delete(messageId);
177
+ this.#interactionTasks.delete(current);
178
+ });
179
+ this.#interactionTasks.add(current);
180
+ return current;
181
+ }
182
+ if (pending && senderOpenId(event) !== pending.actor) {
183
+ return this.#enqueueMessage(event, messageId, key, processingReaction);
184
+ }
185
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
186
+ return this.#enqueueMessage(event, messageId, key, processingReaction);
187
+ }
188
+ if (pending) {
189
+ if (canClaimInteractionReply(event, pending)) pending.claimedReplyMessageId = messageId;
190
+ const previous = pending.queue ?? Promise.resolve();
191
+ const processing = previous
192
+ .catch(() => undefined)
193
+ .then(() => this.#processInteractionReply(
194
+ event,
195
+ messageId,
196
+ key,
197
+ pending,
198
+ processingReaction,
199
+ ));
200
+ pending.queue = processing;
65
201
 
66
- const key = conversationKey(event);
202
+ const releaseInteraction = () => {
203
+ if (pending.claimedReplyMessageId === messageId) {
204
+ pending.claimedReplyMessageId = null;
205
+ }
206
+ if (pending.queue === processing) pending.queue = null;
207
+ };
208
+ let current;
209
+ current = processing
210
+ .then(
211
+ () => {
212
+ releaseInteraction();
213
+ return this.#finishReaction(messageId, processingReaction, 'DONE');
214
+ },
215
+ (error) => {
216
+ releaseInteraction();
217
+ return this.#handleMessageFailure(
218
+ event,
219
+ messageId,
220
+ processingReaction,
221
+ error,
222
+ );
223
+ },
224
+ )
225
+ .finally(() => {
226
+ releaseInteraction();
227
+ this.#acceptedMessageIds.delete(messageId);
228
+ this.#interactionTasks.delete(current);
229
+ });
230
+ this.#interactionTasks.add(current);
231
+ return current;
232
+ }
233
+ return this.#enqueueMessage(event, messageId, key, processingReaction);
234
+ }
235
+
236
+ #enqueueMessage(event, messageId, key, processingReaction, {
237
+ releaseMessageId = true,
238
+ alreadyRecorded = false,
239
+ finalize = true,
240
+ } = {}) {
67
241
  const previous = this.#queues.get(key) ?? Promise.resolve();
68
- const task = previous
242
+ const work = previous
69
243
  .catch(() => undefined)
70
- .then(() => this.#handle(event, key))
71
- .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
72
- .catch(async (error) => {
73
- console.error('[bridge] message handling failed:', error.message);
74
- this.#status.lastError = error.message;
75
- await this.#finishReaction(messageId, processingReaction, 'ERROR');
76
- await this.#send(
77
- event.message.chat_id,
78
- '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
79
- ).catch(() => undefined);
80
- })
81
- .finally(() => {
82
- this.#acceptedMessageIds.delete(messageId);
83
- if (this.#queues.get(key) === task) this.#queues.delete(key);
84
- });
85
- this.#queues.set(key, task);
244
+ .then(() => this.#handle(event, key, { alreadyRecorded }));
245
+ const settled = finalize
246
+ ? work
247
+ .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
248
+ .catch((error) => this.#handleMessageFailure(
249
+ event,
250
+ messageId,
251
+ processingReaction,
252
+ error,
253
+ ))
254
+ : work;
255
+ let current;
256
+ current = settled.finally(() => {
257
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
258
+ if (this.#queues.get(key) === current) this.#queues.delete(key);
259
+ });
260
+ this.#queues.set(key, current);
261
+ return current;
262
+ }
263
+
264
+ async #handleMessageFailure(event, messageId, processingReaction, error) {
265
+ if (this.#signal?.aborted) {
266
+ await this.#removeProcessingReaction(messageId, processingReaction);
267
+ return;
268
+ }
269
+ this.#logger.error?.('[dsh-feishu] message handling failed:', error?.message ?? String(error));
270
+ this.#status.lastError = error?.message ?? String(error);
271
+ await this.#finishReaction(messageId, processingReaction, 'ERROR');
272
+ await this.#send(
273
+ event.message.chat_id,
274
+ '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
275
+ ).catch(() => undefined);
86
276
  }
87
277
 
88
278
  async waitForIdle() {
89
- await Promise.allSettled([...this.#queues.values()]);
279
+ await Promise.allSettled([
280
+ ...this.#queues.values(),
281
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
282
+ pending.queue ? [pending.queue] : []
283
+ )),
284
+ ...this.#interactionTasks,
285
+ ]);
90
286
  }
91
287
 
92
- async #handle(event, key) {
288
+ async #handle(event, key, { alreadyRecorded = false } = {}) {
289
+ this.#signal?.throwIfAborted();
93
290
  const messageId = event.message.message_id;
94
- await this.#state.markSeen(messageId);
95
- this.#status.lastMessageAt = new Date().toISOString();
96
- this.#status.messagesReceived += 1;
291
+ if (!alreadyRecorded) {
292
+ if (this.#state.hasSeen(messageId)) return;
293
+ await this.#state.markSeen(messageId);
294
+ this.#status.lastMessageAt = new Date().toISOString();
295
+ this.#status.messagesReceived += 1;
296
+ }
97
297
 
98
298
  const text = extractText(event);
99
- if (!text) return;
299
+ if (!text) {
300
+ await this.#send(event.message.chat_id, '目前仅支持文字消息。');
301
+ return;
302
+ }
100
303
 
101
304
  if (text === '/help') {
102
305
  await this.#send(event.message.chat_id, HELP_TEXT);
@@ -108,7 +311,7 @@ export class FeishuHarnessBridge {
108
311
  return;
109
312
  }
110
313
  if (text === '/status') {
111
- await this.#harness.ensureRunning();
314
+ await this.#harness.ensureRunning({ signal: this.#signal });
112
315
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
113
316
  return;
114
317
  }
@@ -120,11 +323,30 @@ export class FeishuHarnessBridge {
120
323
  return;
121
324
  }
122
325
 
123
- console.info(`[bridge] processing ${event.message.chat_type} message ${messageId}`);
124
- await this.#answerWithStream(event, key, text);
125
- this.#status.messagesReplied += 1;
126
- this.#status.lastReplyAt = new Date().toISOString();
127
- this.#status.lastError = null;
326
+ this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
327
+ try {
328
+ await this.#answerWithStream(event, key, text);
329
+ this.#status.messagesReplied += 1;
330
+ this.#status.lastReplyAt = new Date().toISOString();
331
+ this.#status.lastError = null;
332
+ } finally {
333
+ await this.#cancelPendingInteraction(key);
334
+ await this.#approvals.closeRoute(key);
335
+ }
336
+ }
337
+
338
+ #interactionAskOptions(event, key) {
339
+ return {
340
+ timeoutMs: this.#replyTimeoutMs,
341
+ signal: this.#signal,
342
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
343
+ key,
344
+ actor: senderOpenId(event),
345
+ chatId: event.message.chat_id,
346
+ requiresMention: event.message.chat_type !== 'p2p',
347
+ }),
348
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
349
+ };
128
350
  }
129
351
 
130
352
  async #answerWithStream(event, key, text) {
@@ -136,7 +358,9 @@ export class FeishuHarnessBridge {
136
358
  state: this.#state,
137
359
  key,
138
360
  text,
139
- askOptions: { timeoutMs: this.#replyTimeoutMs },
361
+ createOptions: { signal: this.#signal },
362
+ existsOptions: { signal: this.#signal },
363
+ askOptions: this.#interactionAskOptions(event, key),
140
364
  });
141
365
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
142
366
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
@@ -149,18 +373,21 @@ export class FeishuHarnessBridge {
149
373
  await this.#channel.stream(chatId, {
150
374
  markdown: async (controller) => {
151
375
  promptStarted = true;
376
+ const askOptions = {
377
+ ...this.#interactionAskOptions(event, key),
378
+ onUpdate: async (update) => {
379
+ await controller.setContent(this.#progressText(update));
380
+ this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
381
+ },
382
+ };
152
383
  ({ answer: completedAnswer } = await askInWorkspaceSession({
153
384
  harness: this.#harness,
154
385
  state: this.#state,
155
386
  key,
156
387
  text,
157
- askOptions: {
158
- timeoutMs: this.#replyTimeoutMs,
159
- onUpdate: async (update) => {
160
- await controller.setContent(this.#progressText(update));
161
- this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
162
- },
163
- },
388
+ createOptions: { signal: this.#signal },
389
+ existsOptions: { signal: this.#signal },
390
+ askOptions,
164
391
  }));
165
392
  await controller.setContent(completedAnswer);
166
393
  },
@@ -169,26 +396,315 @@ export class FeishuHarnessBridge {
169
396
  } catch (error) {
170
397
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
171
398
  if (completedAnswer) {
172
- console.warn('[bridge] native Feishu stream failed after generation; sending final text:', error.message);
399
+ this.#logger.warn?.(
400
+ '[dsh-feishu] native stream failed after generation; sending final text:',
401
+ error.message,
402
+ );
173
403
  for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
174
404
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
175
405
  return;
176
406
  }
177
407
  if (promptStarted) throw error;
178
408
 
179
- console.warn('[bridge] native Feishu stream unavailable; using text fallback:', error.message);
409
+ this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
180
410
  const { answer } = await askInWorkspaceSession({
181
411
  harness: this.#harness,
182
412
  state: this.#state,
183
413
  key,
184
414
  text,
185
- askOptions: { timeoutMs: this.#replyTimeoutMs },
415
+ createOptions: { signal: this.#signal },
416
+ existsOptions: { signal: this.#signal },
417
+ askOptions: this.#interactionAskOptions(event, key),
186
418
  });
187
419
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
188
420
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
189
421
  }
190
422
  }
191
423
 
424
+ async #processInteractionReply(event, messageId, key, expected, processingReaction) {
425
+ this.#signal?.throwIfAborted();
426
+ const current = this.#pendingInteractions.get(key);
427
+ const claimed = expected.claimedReplyMessageId === messageId;
428
+ if (!current || current !== expected || current.submitting) {
429
+ if (this.#isResolvedQuestionReply(event, key)) {
430
+ return this.#discardResolvedInteractionReply(event, messageId);
431
+ }
432
+ if (claimed && (!current || current !== expected)) {
433
+ return this.#discardResolvedInteractionReply(event, messageId);
434
+ }
435
+ return this.#enqueueMessage(event, messageId, key, processingReaction, {
436
+ releaseMessageId: false,
437
+ finalize: false,
438
+ });
439
+ }
440
+ if (this.#state.hasSeen(messageId)) return;
441
+ await this.#state.markSeen(messageId);
442
+ this.#status.lastMessageAt = new Date().toISOString();
443
+ this.#status.messagesReceived += 1;
444
+
445
+ const text = extractText(event);
446
+ if (!text) {
447
+ await this.#send(event.message.chat_id, '请用文字回答当前问题。');
448
+ return;
449
+ }
450
+
451
+ const pending = this.#pendingInteractions.get(key);
452
+ if (!pending || pending !== expected || pending.submitting) {
453
+ if (this.#isResolvedQuestionReply(event, key)) {
454
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
455
+ return;
456
+ }
457
+ if (claimed && (!pending || pending !== expected)) {
458
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT);
459
+ return;
460
+ }
461
+ return this.#enqueueMessage(event, messageId, key, processingReaction, {
462
+ releaseMessageId: false,
463
+ alreadyRecorded: true,
464
+ finalize: false,
465
+ });
466
+ }
467
+ pending.chatId = event.message.chat_id;
468
+ if (pending.needsPresentation) {
469
+ try {
470
+ await this.#presentInteraction(pending);
471
+ } catch {
472
+ this.#status.lastError = '飞书交互问题发送失败。';
473
+ this.#logger.error?.('[dsh-feishu] failed to retry an interaction question');
474
+ pending.interaction.reconnect?.();
475
+ }
476
+ return;
477
+ }
478
+ const question = pending.questions[pending.index];
479
+ if (!question) return;
480
+
481
+ pending.answers.push(harnessAnswerForQuestion(question, text));
482
+ pending.index += 1;
483
+ if (pending.index < pending.questions.length) {
484
+ if (pending.claimedReplyMessageId === messageId) {
485
+ pending.claimedReplyMessageId = null;
486
+ }
487
+ pending.needsPresentation = true;
488
+ try {
489
+ await this.#presentInteraction(pending);
490
+ } catch {
491
+ this.#status.lastError = '飞书交互问题发送失败。';
492
+ this.#logger.error?.('[dsh-feishu] failed to send the next interaction question');
493
+ pending.interaction.reconnect?.();
494
+ }
495
+ return;
496
+ }
497
+
498
+ pending.submitting = true;
499
+ try {
500
+ await pending.interaction.respond({
501
+ ok: true,
502
+ value: {
503
+ sessionId: pending.sessionId,
504
+ answer: { answers: pending.answers },
505
+ },
506
+ });
507
+ this.#rememberResolvedInteraction(key, pending);
508
+ this.#clearPendingInteraction(key, pending.interactionId);
509
+ this.#status.lastError = null;
510
+ } catch (error) {
511
+ if (this.#signal?.aborted) return;
512
+ if (this.#pendingInteractions.get(key) !== pending) return;
513
+ if (error?.code === 'interaction-not-pending') {
514
+ this.#rememberResolvedInteraction(key, pending);
515
+ this.#clearPendingInteraction(key, pending.interactionId);
516
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
517
+ return;
518
+ }
519
+ pending.submitting = false;
520
+ pending.answers.pop();
521
+ pending.index -= 1;
522
+ this.#status.lastError = '回答提交失败。';
523
+ this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
524
+ await this.#send(event.message.chat_id, '回答提交失败,请重新发送当前问题的答案。')
525
+ .catch(() => undefined);
526
+ }
527
+ }
528
+
529
+ async #handleInteraction(interaction, {
530
+ key,
531
+ actor,
532
+ chatId,
533
+ requiresMention,
534
+ }) {
535
+ if (await this.#approvals.handleRequested(interaction, {
536
+ key,
537
+ actor,
538
+ requiresMention,
539
+ send: (text) => this.#send(chatId, text),
540
+ })) return;
541
+
542
+ // Approval requests return above; the existing question state machine stays unchanged.
543
+ if (interaction?.kind !== 'question') return;
544
+ const questions = interaction?.payload?.questions;
545
+ const interactionId = typeof interaction?.interactionId === 'string'
546
+ ? interaction.interactionId
547
+ : interaction?.rpcId;
548
+ if (typeof interaction.rpcId !== 'string'
549
+ || typeof interactionId !== 'string'
550
+ || typeof interaction.sessionId !== 'string'
551
+ || !Array.isArray(questions)
552
+ || questions.length === 0
553
+ || questions.some((question) => !validHarnessQuestion(question))) {
554
+ this.#logger.warn?.('[dsh-feishu] ignored an invalid Harness question interaction');
555
+ return;
556
+ }
557
+
558
+ if (interaction.recovered === true) {
559
+ await interaction.respond({
560
+ ok: false,
561
+ error: {
562
+ code: 'cancelled',
563
+ message: 'Feishu safely cancelled an interaction left by an earlier client.',
564
+ details: {},
565
+ },
566
+ });
567
+ await this.#send(
568
+ chatId,
569
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
570
+ ).catch(() => undefined);
571
+ return;
572
+ }
573
+
574
+ const existing = this.#pendingInteractions.get(key);
575
+ if (existing?.interactionId === interactionId) {
576
+ existing.interaction = interaction;
577
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
578
+ return;
579
+ }
580
+ if (this.#interactionKeys.has(interactionId)) return;
581
+ if (existing) {
582
+ await interaction.respond({
583
+ ok: false,
584
+ error: {
585
+ code: 'cancelled',
586
+ message: 'Feishu is already handling another user interaction.',
587
+ details: {},
588
+ },
589
+ });
590
+ return;
591
+ }
592
+
593
+ const pending = {
594
+ kind: 'question',
595
+ interactionId,
596
+ sessionId: interaction.sessionId,
597
+ interaction,
598
+ key,
599
+ actor,
600
+ requiresMention,
601
+ questions,
602
+ answers: [],
603
+ index: 0,
604
+ chatId,
605
+ queue: null,
606
+ claimedReplyMessageId: null,
607
+ submitting: false,
608
+ needsPresentation: true,
609
+ questionMessageIds: new Set(),
610
+ inactive: false,
611
+ };
612
+ this.#pendingInteractions.set(key, pending);
613
+ this.#interactionKeys.set(pending.interactionId, key);
614
+ await this.#presentInteraction(pending);
615
+ }
616
+
617
+ async #handleInteractionResolved(resolution) {
618
+ if (await this.#approvals.handleResolved(resolution)) return;
619
+ const interactionId = resolution?.interactionId;
620
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
621
+ const key = this.#interactionKeys.get(interactionId);
622
+ if (!key) return;
623
+ const pending = this.#pendingInteractions.get(key);
624
+ if (pending) this.#rememberResolvedInteraction(key, pending);
625
+ this.#clearPendingInteraction(key, interactionId);
626
+ }
627
+
628
+ async #presentInteraction(pending) {
629
+ const question = pending.questions[pending.index];
630
+ if (!question) return;
631
+ const messageId = await this.#send(
632
+ pending.chatId,
633
+ harnessQuestionText(
634
+ question,
635
+ pending.index,
636
+ pending.questions.length,
637
+ { requiresMention: pending.requiresMention },
638
+ ),
639
+ );
640
+ if (messageId) {
641
+ pending.questionMessageIds.add(messageId);
642
+ if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
643
+ }
644
+ pending.needsPresentation = false;
645
+ }
646
+
647
+ #rememberResolvedInteraction(key, pending) {
648
+ const expiresAt = Date.now() + RESOLVED_REPLY_TTL_MS;
649
+ for (const messageId of pending.questionMessageIds ?? []) {
650
+ this.#resolvedQuestionReplies.set(messageId, { key, expiresAt });
651
+ }
652
+ }
653
+
654
+ #isResolvedQuestionReply(event, key) {
655
+ const now = Date.now();
656
+ for (const [messageId, resolution] of this.#resolvedQuestionReplies) {
657
+ if (resolution.expiresAt <= now) this.#resolvedQuestionReplies.delete(messageId);
658
+ }
659
+ for (const reference of [event?.message?.parent_id, event?.message?.root_id]) {
660
+ const resolution = this.#resolvedQuestionReplies.get(reference);
661
+ if (resolution?.key === key && resolution.expiresAt > now) return true;
662
+ }
663
+ return false;
664
+ }
665
+
666
+ async #discardResolvedInteractionReply(event, messageId) {
667
+ if (this.#state.hasSeen(messageId)) return;
668
+ await this.#state.markSeen(messageId);
669
+ this.#status.lastMessageAt = new Date().toISOString();
670
+ this.#status.messagesReceived += 1;
671
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
672
+ }
673
+
674
+ #takePendingInteraction(key, interactionId) {
675
+ const pending = this.#pendingInteractions.get(key);
676
+ if (!pending
677
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
678
+ this.#pendingInteractions.delete(key);
679
+ this.#interactionKeys.delete(pending.interactionId);
680
+ pending.inactive = true;
681
+ return pending;
682
+ }
683
+
684
+ #clearPendingInteraction(key, interactionId) {
685
+ return this.#takePendingInteraction(key, interactionId) !== null;
686
+ }
687
+
688
+ async #cancelPendingInteraction(key) {
689
+ const pending = this.#takePendingInteraction(key);
690
+ if (!pending || pending.kind !== 'question') return;
691
+ this.#rememberResolvedInteraction(key, pending);
692
+ try {
693
+ await pending.interaction.respond({
694
+ ok: false,
695
+ error: {
696
+ code: 'cancelled',
697
+ message: 'The Feishu interaction ended before the user answered.',
698
+ details: {},
699
+ },
700
+ }, { signal: AbortSignal.timeout(5_000) });
701
+ } catch (error) {
702
+ if (error?.code !== 'interaction-not-pending') {
703
+ this.#logger.warn?.('[dsh-feishu] failed to cancel a pending Harness interaction');
704
+ }
705
+ }
706
+ }
707
+
192
708
  #progressText(update) {
193
709
  if (update.type === 'text' && update.text) return update.text;
194
710
  if (update.type === 'tool') {
@@ -206,12 +722,12 @@ export class FeishuHarnessBridge {
206
722
  return reactionId;
207
723
  } catch (error) {
208
724
  this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
209
- console.warn(`[bridge] unable to add ${emojiType} reaction:`, error.message);
725
+ this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
210
726
  return null;
211
727
  }
212
728
  }
213
729
 
214
- async #finishReaction(messageId, processingReaction, finalEmojiType) {
730
+ async #removeProcessingReaction(messageId, processingReaction) {
215
731
  const reactionId = await processingReaction;
216
732
  if (reactionId && this.#channel?.removeReaction) {
217
733
  try {
@@ -219,9 +735,13 @@ export class FeishuHarnessBridge {
219
735
  this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
220
736
  } catch (error) {
221
737
  this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
222
- console.warn('[bridge] unable to remove processing reaction:', error.message);
738
+ this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
223
739
  }
224
740
  }
741
+ }
742
+
743
+ async #finishReaction(messageId, processingReaction, finalEmojiType) {
744
+ await this.#removeProcessingReaction(messageId, processingReaction);
225
745
  await this.#addReaction(messageId, finalEmojiType);
226
746
  }
227
747
 
@@ -237,5 +757,6 @@ export class FeishuHarnessBridge {
237
757
  if (response?.code && response.code !== 0) {
238
758
  throw new Error(`Feishu send failed: ${response.msg || response.code}`);
239
759
  }
760
+ return nonEmptyString(response?.data?.message_id);
240
761
  }
241
762
  }