@xmanrui/dsh-im 0.6.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 (42) hide show
  1. package/README.md +22 -4
  2. package/lib/client.js +649 -357
  3. package/lib/index.js +120 -111
  4. package/package.json +1 -1
  5. package/plugin-src/client/build.mjs +1 -1
  6. package/plugin-src/client/i18n.js +14 -0
  7. package/plugin-src/client/index.js +11 -3
  8. package/plugin-src/client/styles.js +49 -8
  9. package/plugin-src/client/workspace-directory-picker.js +230 -0
  10. package/plugin-src/client/workspace-editor.js +38 -65
  11. package/scripts/verify-package.mjs +5 -0
  12. package/src/channels/dingtalk/dingtalk-bridge.mjs +363 -9
  13. package/src/channels/dingtalk/harness-client.mjs +16 -310
  14. package/src/channels/discord/discord-api.mjs +1 -1
  15. package/src/channels/discord/discord-runtime.mjs +11 -1
  16. package/src/channels/discord/harness-client.mjs +10 -2
  17. package/src/channels/feishu/bridge.mjs +525 -51
  18. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  19. package/src/channels/feishu/harness-client.mjs +16 -279
  20. package/src/channels/qq/harness-client.mjs +10 -2
  21. package/src/channels/qq/qq-bridge.mjs +383 -29
  22. package/src/channels/qq/qq-runtime.mjs +14 -3
  23. package/src/channels/shared/bot-workspace-store.mjs +185 -4
  24. package/src/channels/shared/harness-client.mjs +825 -0
  25. package/src/channels/shared/harness-question.mjs +85 -0
  26. package/src/channels/shared/harness-session-binding.mjs +110 -0
  27. package/src/channels/shared/text-harness-bridge.mjs +439 -25
  28. package/src/channels/shared/workspace-command.mjs +212 -16
  29. package/src/channels/shared/workspace-session.mjs +22 -9
  30. package/src/channels/slack/harness-client.mjs +10 -2
  31. package/src/channels/slack/slack-runtime.mjs +11 -1
  32. package/src/channels/telegram/harness-client.mjs +10 -2
  33. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  34. package/src/channels/wecom/harness-client.mjs +10 -2
  35. package/src/channels/wecom/wecom-bridge.mjs +389 -15
  36. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  37. package/src/channels/weixin/harness-client.mjs +16 -270
  38. package/src/channels/weixin/weixin-api.mjs +1 -1
  39. package/src/channels/weixin/weixin-bridge.mjs +406 -24
  40. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  41. package/src/channels/whatsapp/harness-client.mjs +10 -2
  42. 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
  '',
@@ -15,20 +23,55 @@ const HELP_TEXT = [
15
23
  '/new 开启一个全新会话',
16
24
  '/workspace 工作区绝对路径 切换工作区',
17
25
  '/workspacelist 列出工作区绝对路径',
26
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
27
+ '/session Session ID 将当前聊天绑定到指定会话',
18
28
  '/status 检查连接状态',
19
29
  '/help 显示本帮助',
20
30
  ].join('\n');
21
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
+
22
59
  export class FeishuHarnessBridge {
23
60
  #client;
24
61
  #channel;
25
62
  #harness;
26
63
  #state;
27
64
  #queues = new Map();
65
+ #pendingInteractions = new Map();
66
+ #interactionKeys = new Map();
67
+ #resolvedQuestionReplies = new Map();
28
68
  #acceptedMessageIds = new Set();
69
+ #interactionTasks = new Set();
29
70
  #status;
30
71
  #allowedSenderOpenIds;
31
72
  #replyTimeoutMs;
73
+ #logger;
74
+ #signal;
32
75
 
33
76
  constructor({
34
77
  client,
@@ -37,8 +80,13 @@ export class FeishuHarnessBridge {
37
80
  state,
38
81
  status,
39
82
  allowedSenderOpenIds = new Set(),
40
- replyTimeoutMs = 600000,
83
+ replyTimeoutMs = 600_000,
84
+ logger = console,
85
+ signal,
41
86
  }) {
87
+ if (!client || !harness || !state || !status) {
88
+ throw new TypeError('Feishu bridge dependencies are required');
89
+ }
42
90
  this.#client = client;
43
91
  this.#channel = channel;
44
92
  this.#harness = harness;
@@ -46,55 +94,171 @@ export class FeishuHarnessBridge {
46
94
  this.#status = status;
47
95
  this.#allowedSenderOpenIds = allowedSenderOpenIds;
48
96
  this.#replyTimeoutMs = replyTimeoutMs;
97
+ this.#logger = logger;
98
+ this.#signal = signal;
99
+ ensureStatus(this.#status);
49
100
  }
50
101
 
51
102
  accept(event) {
52
- const messageId = event?.message?.message_id;
53
- 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();
54
106
  if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
55
107
  this.#status.messagesRejected += 1;
56
108
  this.#status.lastRejectedAt = new Date().toISOString();
57
- console.warn('[bridge] ignored a message from a sender outside the allowlist');
58
- 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();
59
123
  }
60
- if (this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) return;
124
+
61
125
  this.#acceptedMessageIds.add(messageId);
62
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
+ }
63
194
 
64
- const key = conversationKey(event);
195
+ #enqueueMessage(event, messageId, key, processingReaction, {
196
+ releaseMessageId = true,
197
+ alreadyRecorded = false,
198
+ finalize = true,
199
+ } = {}) {
65
200
  const previous = this.#queues.get(key) ?? Promise.resolve();
66
- const task = previous
201
+ const work = previous
67
202
  .catch(() => undefined)
68
- .then(() => this.#handle(event, key))
69
- .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
70
- .catch(async (error) => {
71
- console.error('[bridge] message handling failed:', error.message);
72
- this.#status.lastError = error.message;
73
- await this.#finishReaction(messageId, processingReaction, 'ERROR');
74
- await this.#send(
75
- event.message.chat_id,
76
- '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
77
- ).catch(() => undefined);
78
- })
79
- .finally(() => {
80
- this.#acceptedMessageIds.delete(messageId);
81
- if (this.#queues.get(key) === task) this.#queues.delete(key);
82
- });
83
- 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);
84
235
  }
85
236
 
86
237
  async waitForIdle() {
87
- 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
+ ]);
88
245
  }
89
246
 
90
- async #handle(event, key) {
247
+ async #handle(event, key, { alreadyRecorded = false } = {}) {
248
+ this.#signal?.throwIfAborted();
91
249
  const messageId = event.message.message_id;
92
- await this.#state.markSeen(messageId);
93
- this.#status.lastMessageAt = new Date().toISOString();
94
- 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
+ }
95
256
 
96
257
  const text = extractText(event);
97
- if (!text) return;
258
+ if (!text) {
259
+ await this.#send(event.message.chat_id, '目前仅支持文字消息。');
260
+ return;
261
+ }
98
262
 
99
263
  if (text === '/help') {
100
264
  await this.#send(event.message.chat_id, HELP_TEXT);
@@ -106,11 +270,11 @@ export class FeishuHarnessBridge {
106
270
  return;
107
271
  }
108
272
  if (text === '/status') {
109
- await this.#harness.ensureRunning();
273
+ await this.#harness.ensureRunning({ signal: this.#signal });
110
274
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
111
275
  return;
112
276
  }
113
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
277
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
114
278
  if (workspaceCommand) {
115
279
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
116
280
  await this.#send(event.message.chat_id, reply);
@@ -118,11 +282,29 @@ export class FeishuHarnessBridge {
118
282
  return;
119
283
  }
120
284
 
121
- console.info(`[bridge] processing ${event.message.chat_type} message ${messageId}`);
122
- await this.#answerWithStream(event, key, text);
123
- this.#status.messagesReplied += 1;
124
- this.#status.lastReplyAt = new Date().toISOString();
125
- 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
+ };
126
308
  }
127
309
 
128
310
  async #answerWithStream(event, key, text) {
@@ -134,7 +316,9 @@ export class FeishuHarnessBridge {
134
316
  state: this.#state,
135
317
  key,
136
318
  text,
137
- askOptions: { timeoutMs: this.#replyTimeoutMs },
319
+ createOptions: { signal: this.#signal },
320
+ existsOptions: { signal: this.#signal },
321
+ askOptions: this.#interactionAskOptions(event, key),
138
322
  });
139
323
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
140
324
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
@@ -147,18 +331,21 @@ export class FeishuHarnessBridge {
147
331
  await this.#channel.stream(chatId, {
148
332
  markdown: async (controller) => {
149
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
+ };
150
341
  ({ answer: completedAnswer } = await askInWorkspaceSession({
151
342
  harness: this.#harness,
152
343
  state: this.#state,
153
344
  key,
154
345
  text,
155
- askOptions: {
156
- timeoutMs: this.#replyTimeoutMs,
157
- onUpdate: async (update) => {
158
- await controller.setContent(this.#progressText(update));
159
- this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
160
- },
161
- },
346
+ createOptions: { signal: this.#signal },
347
+ existsOptions: { signal: this.#signal },
348
+ askOptions,
162
349
  }));
163
350
  await controller.setContent(completedAnswer);
164
351
  },
@@ -167,26 +354,308 @@ export class FeishuHarnessBridge {
167
354
  } catch (error) {
168
355
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
169
356
  if (completedAnswer) {
170
- 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
+ );
171
361
  for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
172
362
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
173
363
  return;
174
364
  }
175
365
  if (promptStarted) throw error;
176
366
 
177
- 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);
178
368
  const { answer } = await askInWorkspaceSession({
179
369
  harness: this.#harness,
180
370
  state: this.#state,
181
371
  key,
182
372
  text,
183
- askOptions: { timeoutMs: this.#replyTimeoutMs },
373
+ createOptions: { signal: this.#signal },
374
+ existsOptions: { signal: this.#signal },
375
+ askOptions: this.#interactionAskOptions(event, key),
184
376
  });
185
377
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
186
378
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
187
379
  }
188
380
  }
189
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
+
190
659
  #progressText(update) {
191
660
  if (update.type === 'text' && update.text) return update.text;
192
661
  if (update.type === 'tool') {
@@ -204,12 +673,12 @@ export class FeishuHarnessBridge {
204
673
  return reactionId;
205
674
  } catch (error) {
206
675
  this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
207
- console.warn(`[bridge] unable to add ${emojiType} reaction:`, error.message);
676
+ this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
208
677
  return null;
209
678
  }
210
679
  }
211
680
 
212
- async #finishReaction(messageId, processingReaction, finalEmojiType) {
681
+ async #removeProcessingReaction(messageId, processingReaction) {
213
682
  const reactionId = await processingReaction;
214
683
  if (reactionId && this.#channel?.removeReaction) {
215
684
  try {
@@ -217,9 +686,13 @@ export class FeishuHarnessBridge {
217
686
  this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
218
687
  } catch (error) {
219
688
  this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
220
- console.warn('[bridge] unable to remove processing reaction:', error.message);
689
+ this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
221
690
  }
222
691
  }
692
+ }
693
+
694
+ async #finishReaction(messageId, processingReaction, finalEmojiType) {
695
+ await this.#removeProcessingReaction(messageId, processingReaction);
223
696
  await this.#addReaction(messageId, finalEmojiType);
224
697
  }
225
698
 
@@ -235,5 +708,6 @@ export class FeishuHarnessBridge {
235
708
  if (response?.code && response.code !== 0) {
236
709
  throw new Error(`Feishu send failed: ${response.msg || response.code}`);
237
710
  }
711
+ return nonEmptyString(response?.data?.message_id);
238
712
  }
239
713
  }