@xmanrui/dsh-im 0.7.0 → 0.7.1

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