@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
@@ -1,10 +1,24 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { askInWorkspaceSession } from './workspace-session.mjs';
3
+ import { HarnessApprovalQueue } from './harness-approval.mjs';
4
+ import {
5
+ harnessAnswerForQuestion,
6
+ harnessQuestionText,
7
+ validHarnessQuestion,
8
+ } from './harness-question.mjs';
9
+
10
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
3
11
 
4
12
  function cleanText(value) {
5
13
  return typeof value === 'string' ? value.trim() : '';
6
14
  }
7
15
 
16
+ function canClaimInteractionReply(message, pending, senderId) {
17
+ return pending.actor === senderId
18
+ && (message.kind !== 'group' || message.addressed === true)
19
+ && Boolean(cleanText(message.content));
20
+ }
21
+
8
22
  export function createTextBridgeStatus() {
9
23
  return {
10
24
  messagesReceived: 0,
@@ -25,7 +39,13 @@ export class TextHarnessBridge {
25
39
  #status;
26
40
  #logger;
27
41
  #replyTimeoutMs;
42
+ #signal;
28
43
  #queues = new Map();
44
+ #pendingInteractions = new Map();
45
+ #interactionKeys = new Map();
46
+ #acceptedMessageIds = new Set();
47
+ #approvalTasks = new Set();
48
+ #approvals;
29
49
 
30
50
  constructor({
31
51
  descriptor,
@@ -35,6 +55,7 @@ export class TextHarnessBridge {
35
55
  status = createTextBridgeStatus(),
36
56
  logger = console,
37
57
  replyTimeoutMs = 600_000,
58
+ signal,
38
59
  }) {
39
60
  if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
40
61
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
@@ -46,6 +67,11 @@ export class TextHarnessBridge {
46
67
  this.#status = status;
47
68
  this.#logger = logger;
48
69
  this.#replyTimeoutMs = replyTimeoutMs;
70
+ this.#signal = signal;
71
+ this.#approvals = new HarnessApprovalQueue({
72
+ label: descriptor.key,
73
+ logger,
74
+ });
49
75
  }
50
76
 
51
77
  get status() {
@@ -53,14 +79,98 @@ export class TextHarnessBridge {
53
79
  }
54
80
 
55
81
  accept(message) {
82
+ if (this.#signal?.aborted) return Promise.resolve();
56
83
  const conversationId = cleanText(message?.conversationId);
57
84
  const kind = message?.kind === 'group' ? 'group' : 'direct';
85
+ const normalized = { ...message, kind, conversationId };
86
+ const messageId = cleanText(normalized.messageId);
87
+ const senderId = cleanText(normalized.senderId);
88
+ if (!messageId || !senderId || !conversationId || normalized.senderIsBot === true
89
+ || this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
90
+ return Promise.resolve();
91
+ }
92
+ this.#acceptedMessageIds.add(messageId);
93
+
58
94
  const key = `${kind}:${conversationId}`;
95
+ const pending = this.#pendingInteractions.get(key);
96
+ const approval = this.#approvals.claimReply({
97
+ key,
98
+ actor: senderId,
99
+ messageId,
100
+ text: normalized.content,
101
+ addressed: normalized.kind !== 'group' || normalized.addressed === true,
102
+ hasPendingQuestion: Boolean(pending),
103
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
104
+ ? pending.queue
105
+ : null,
106
+ isQuestionPending: () => this.#pendingInteractions.has(key),
107
+ send: (text) => this.#bot.sendText(normalized.replyTarget, text),
108
+ });
109
+ if (approval) {
110
+ let task;
111
+ task = approval.process(async () => {
112
+ if (this.#state.hasSeen(messageId)) return false;
113
+ await this.#state.markSeen(messageId);
114
+ this.#status.messagesReceived += 1;
115
+ this.#status.lastMessageAt = new Date().toISOString();
116
+ return true;
117
+ })
118
+ .finally(() => {
119
+ this.#acceptedMessageIds.delete(messageId);
120
+ this.#approvalTasks.delete(task);
121
+ });
122
+ this.#approvalTasks.add(task);
123
+ return task;
124
+ }
125
+ if (pending && pending.actor !== senderId) {
126
+ return this.#enqueueMessage(normalized, messageId, senderId, key);
127
+ }
128
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
129
+ return this.#enqueueMessage(normalized, messageId, senderId, key);
130
+ }
131
+ if (pending) {
132
+ if (canClaimInteractionReply(normalized, pending, senderId)) {
133
+ pending.claimedReplyMessageId = messageId;
134
+ }
135
+ const previous = pending.queue ?? Promise.resolve();
136
+ const current = previous
137
+ .catch(() => undefined)
138
+ .then(() => this.#processInteractionReply(
139
+ normalized,
140
+ messageId,
141
+ senderId,
142
+ key,
143
+ pending,
144
+ ))
145
+ .finally(() => {
146
+ this.#acceptedMessageIds.delete(messageId);
147
+ if (pending.claimedReplyMessageId === messageId) {
148
+ pending.claimedReplyMessageId = null;
149
+ }
150
+ if (pending.queue === current) pending.queue = null;
151
+ });
152
+ pending.queue = current;
153
+ return current;
154
+ }
155
+ return this.#enqueueMessage(normalized, messageId, senderId, key);
156
+ }
157
+
158
+ #enqueueMessage(message, messageId, senderId, key, {
159
+ releaseMessageId = true,
160
+ alreadyRecorded = false,
161
+ } = {}) {
59
162
  const previous = this.#queues.get(key) ?? Promise.resolve();
60
163
  const current = previous
61
164
  .catch(() => undefined)
62
- .then(() => this.#process({ ...message, kind, conversationId }))
165
+ .then(() => this.#process(
166
+ message,
167
+ messageId,
168
+ senderId,
169
+ key,
170
+ { alreadyRecorded },
171
+ ))
63
172
  .finally(() => {
173
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
64
174
  if (this.#queues.get(key) === current) this.#queues.delete(key);
65
175
  });
66
176
  this.#queues.set(key, current);
@@ -68,29 +178,37 @@ export class TextHarnessBridge {
68
178
  }
69
179
 
70
180
  async waitForIdle() {
71
- await Promise.allSettled([...this.#queues.values()]);
181
+ await Promise.allSettled([
182
+ ...this.#queues.values(),
183
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
184
+ pending.queue ? [pending.queue] : []
185
+ )),
186
+ ...this.#approvalTasks,
187
+ ]);
72
188
  }
73
189
 
74
- async #process(message) {
75
- const messageId = cleanText(message.messageId);
76
- const senderId = cleanText(message.senderId);
77
- if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return;
78
- if (this.#state.hasSeen(messageId)) return;
79
-
80
- this.#status.messagesReceived += 1;
81
- this.#status.lastMessageAt = new Date().toISOString();
82
- if (message.kind === 'group' && message.addressed !== true) {
83
- this.#status.messagesRejected += 1;
84
- this.#status.lastRejectedAt = new Date().toISOString();
85
- return;
190
+ async #process(message, messageId, senderId, conversationKey, {
191
+ alreadyRecorded = false,
192
+ } = {}) {
193
+ if (!alreadyRecorded) {
194
+ if (this.#state.hasSeen(messageId)) return;
195
+ await this.#state.markSeen(messageId);
196
+ this.#status.messagesReceived += 1;
197
+ this.#status.lastMessageAt = new Date().toISOString();
86
198
  }
87
199
 
88
200
  const target = message.replyTarget;
89
201
  const text = cleanText(message.content);
202
+ let stream = null;
90
203
  try {
204
+ this.#signal?.throwIfAborted();
205
+ if (message.kind === 'group' && message.addressed !== true) {
206
+ this.#status.messagesRejected += 1;
207
+ this.#status.lastRejectedAt = new Date().toISOString();
208
+ return;
209
+ }
91
210
  if (!text) {
92
211
  await this.#bot.sendText(target, '目前仅支持文字消息。');
93
- await this.#state.markSeen(messageId);
94
212
  return;
95
213
  }
96
214
  const command = text.toLowerCase();
@@ -107,35 +225,29 @@ export class TextHarnessBridge {
107
225
  '/status 检查连接状态',
108
226
  '/help 显示本帮助',
109
227
  ].join('\n'));
110
- await this.#state.markSeen(messageId);
111
228
  return;
112
229
  }
113
230
  if (command === '/status') {
114
- await this.#harness.ensureRunning();
231
+ await this.#harness.ensureRunning({ signal: this.#signal });
115
232
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
116
- await this.#state.markSeen(messageId);
117
233
  return;
118
234
  }
119
- const conversationKey = `${message.kind}:${message.conversationId}`;
120
235
  const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
121
236
  if (workspaceCommand) {
122
237
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
123
238
  await this.#bot.sendText(target, reply);
124
239
  }
125
- await this.#state.markSeen(messageId);
126
240
  return;
127
241
  }
128
242
  if (command === '/new') {
129
243
  await this.#state.clearSession(conversationKey);
130
244
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
131
- await this.#state.markSeen(messageId);
132
245
  return;
133
246
  }
134
247
 
135
248
  await this.#bot.sendTyping?.(target).catch((error) => {
136
249
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
137
250
  });
138
- let stream = null;
139
251
  let streamFinished = false;
140
252
  if (typeof this.#bot.openStream === 'function') {
141
253
  try {
@@ -152,13 +264,23 @@ export class TextHarnessBridge {
152
264
  state: this.#state,
153
265
  key: conversationKey,
154
266
  text,
267
+ createOptions: this.#signal ? { signal: this.#signal } : undefined,
268
+ existsOptions: this.#signal ? { signal: this.#signal } : undefined,
155
269
  askOptions: {
156
270
  timeoutMs: this.#replyTimeoutMs,
271
+ signal: this.#signal,
157
272
  onUpdate: stream ? async (update) => {
158
273
  const progress = update.type === 'text' ? update.text
159
274
  : update.type === 'tool' ? `正在使用${update.name}…` : update.text;
160
275
  if (progress) await stream.update(progress);
161
276
  } : undefined,
277
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
278
+ key: conversationKey,
279
+ actor: senderId,
280
+ target,
281
+ requiresMention: message.kind === 'group',
282
+ }),
283
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
162
284
  },
163
285
  });
164
286
  if (stream) {
@@ -174,22 +296,362 @@ export class TextHarnessBridge {
174
296
  }
175
297
  }
176
298
  if (!streamFinished) await this.#bot.sendText(target, answer);
177
- await this.#state.markSeen(messageId);
178
299
  this.#status.messagesReplied += 1;
179
300
  this.#status.lastReplyAt = new Date().toISOString();
180
301
  this.#status.lastError = null;
181
302
  } catch (error) {
303
+ stream?.cancel?.();
304
+ if (this.#signal?.aborted) return;
182
305
  this.#status.lastError = error?.message ?? String(error);
183
306
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
184
307
  try {
185
308
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
186
- await this.#state.markSeen(messageId);
187
309
  } catch (sendError) {
188
310
  this.#logger.error?.(
189
311
  `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
190
312
  sendError,
191
313
  );
192
314
  }
315
+ } finally {
316
+ await Promise.allSettled([
317
+ this.#cancelPendingInteraction(conversationKey),
318
+ this.#approvals.closeRoute(conversationKey),
319
+ ]);
320
+ }
321
+ }
322
+
323
+ async #processInteractionReply(message, messageId, senderId, key, expected) {
324
+ if (this.#signal?.aborted) return;
325
+ const current = this.#pendingInteractions.get(key);
326
+ const claimed = expected.claimedReplyMessageId === messageId;
327
+ if (!current || current !== expected || current.submitting) {
328
+ if (claimed && (!current || current !== expected)) {
329
+ return this.#discardResolvedInteractionReply(message, messageId);
330
+ }
331
+ return this.#enqueueMessage(message, messageId, senderId, key, {
332
+ releaseMessageId: false,
333
+ });
334
+ }
335
+ if (this.#state.hasSeen(messageId)) return;
336
+ await this.#state.markSeen(messageId);
337
+ this.#status.messagesReceived += 1;
338
+ this.#status.lastMessageAt = new Date().toISOString();
339
+
340
+ if (message.kind === 'group' && message.addressed !== true) {
341
+ this.#status.messagesRejected += 1;
342
+ this.#status.lastRejectedAt = new Date().toISOString();
343
+ return;
344
+ }
345
+
346
+ const target = message.replyTarget;
347
+ const text = cleanText(message.content);
348
+ if (!text) {
349
+ try {
350
+ await this.#bot.sendText(target, '请用文字回答当前问题。');
351
+ } catch (error) {
352
+ this.#logger.error?.(
353
+ `[dsh-im:${this.#descriptor.key}] failed to reject a non-text interaction reply:`,
354
+ error,
355
+ );
356
+ }
357
+ return;
358
+ }
359
+
360
+ const pending = this.#pendingInteractions.get(key);
361
+ if (!pending || pending !== expected || pending.submitting) {
362
+ if (claimed && (!pending || pending !== expected)) {
363
+ return this.#discardResolvedInteractionReply(message, messageId, {
364
+ alreadyRecorded: true,
365
+ });
366
+ }
367
+ return this.#enqueueMessage(message, messageId, senderId, key, {
368
+ releaseMessageId: false,
369
+ alreadyRecorded: true,
370
+ });
371
+ }
372
+ pending.target = target;
373
+ if (pending.needsPresentation) {
374
+ const presentationWasInFlight = pending.presentationTask !== null;
375
+ try {
376
+ await this.#presentInteraction(pending);
377
+ } catch (error) {
378
+ this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`;
379
+ this.#logger.error?.(
380
+ `[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`,
381
+ error,
382
+ );
383
+ pending.interaction.reconnect?.();
384
+ return;
385
+ }
386
+ const presented = this.#pendingInteractions.get(key);
387
+ if (!presented || presented !== expected || presented.submitting) {
388
+ if (claimed && (!presented || presented !== expected)) {
389
+ return this.#discardResolvedInteractionReply(message, messageId, {
390
+ alreadyRecorded: true,
391
+ });
392
+ }
393
+ return this.#enqueueMessage(message, messageId, senderId, key, {
394
+ releaseMessageId: false,
395
+ alreadyRecorded: true,
396
+ });
397
+ }
398
+ // A reply can arrive after the platform accepted the question message but
399
+ // before its send promise settles. In that case it is already a valid
400
+ // answer. A message which itself retried a failed presentation is not.
401
+ if (!presentationWasInFlight) return;
402
+ }
403
+
404
+ const question = pending.questions[pending.index];
405
+ if (!question) return;
406
+ pending.answers.push(harnessAnswerForQuestion(question, text));
407
+ pending.index += 1;
408
+ if (pending.index < pending.questions.length) {
409
+ if (pending.claimedReplyMessageId === messageId) {
410
+ pending.claimedReplyMessageId = null;
411
+ }
412
+ pending.needsPresentation = true;
413
+ try {
414
+ await this.#presentInteraction(pending);
415
+ } catch (error) {
416
+ this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`;
417
+ this.#logger.error?.(
418
+ `[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`,
419
+ error,
420
+ );
421
+ pending.interaction.reconnect?.();
422
+ }
423
+ return;
424
+ }
425
+
426
+ pending.submitting = true;
427
+ try {
428
+ await pending.interaction.respond({
429
+ ok: true,
430
+ value: {
431
+ sessionId: pending.sessionId,
432
+ answer: { answers: pending.answers },
433
+ },
434
+ });
435
+ this.#clearPendingInteraction(key, pending.interactionId);
436
+ this.#status.lastError = null;
437
+ } catch (error) {
438
+ if (error?.code === 'interaction-not-pending') {
439
+ this.#clearPendingInteraction(key, pending.interactionId);
440
+ if (this.#signal?.aborted) return;
441
+ try {
442
+ await this.#bot.sendText(target, INTERACTION_RESOLVED_TEXT);
443
+ } catch (sendError) {
444
+ this.#logger.error?.(
445
+ `[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,
446
+ sendError,
447
+ );
448
+ }
449
+ return;
450
+ }
451
+ if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
452
+ pending.submitting = false;
453
+ pending.answers.pop();
454
+ pending.index -= 1;
455
+ this.#status.lastError = '回答提交失败。';
456
+ this.#logger.error?.(
457
+ `[dsh-im:${this.#descriptor.key}] failed to answer a Harness interaction:`,
458
+ error,
459
+ );
460
+ try {
461
+ await this.#bot.sendText(target, '回答提交失败,请重新发送当前问题的答案。');
462
+ } catch (sendError) {
463
+ this.#logger.error?.(
464
+ `[dsh-im:${this.#descriptor.key}] failed to send an interaction retry notice:`,
465
+ sendError,
466
+ );
467
+ }
468
+ }
469
+ }
470
+
471
+ async #handleInteraction(interaction, {
472
+ key,
473
+ actor,
474
+ target,
475
+ requiresMention,
476
+ }) {
477
+ if (interaction?.kind === 'approval') {
478
+ return this.#approvals.handleRequested(interaction, {
479
+ key,
480
+ actor,
481
+ requiresMention,
482
+ send: (text) => this.#bot.sendText(target, text),
483
+ });
484
+ }
485
+ if (interaction?.kind !== 'question') return;
486
+ const questions = interaction?.payload?.questions;
487
+ const interactionId = cleanText(interaction?.interactionId) || cleanText(interaction?.rpcId);
488
+ if (!cleanText(interaction?.rpcId)
489
+ || !interactionId
490
+ || !cleanText(interaction?.sessionId)
491
+ || !Array.isArray(questions)
492
+ || questions.length === 0
493
+ || questions.some((question) => !validHarnessQuestion(question))) {
494
+ this.#logger.warn?.(
495
+ `[dsh-im:${this.#descriptor.key}] ignored an invalid Harness question interaction`,
496
+ );
497
+ return;
498
+ }
499
+
500
+ if (interaction.recovered === true) {
501
+ await this.#respondCancellation(
502
+ interaction,
503
+ `${this.#descriptor.label} safely cancelled an interaction left by an earlier client.`,
504
+ );
505
+ try {
506
+ await this.#bot.sendText(
507
+ target,
508
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
509
+ );
510
+ } catch (error) {
511
+ this.#logger.error?.(
512
+ `[dsh-im:${this.#descriptor.key}] failed to send an interaction recovery notice:`,
513
+ error,
514
+ );
515
+ }
516
+ return;
517
+ }
518
+
519
+ const existing = this.#pendingInteractions.get(key);
520
+ if (existing?.interactionId === interactionId) {
521
+ existing.interaction = interaction;
522
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
523
+ return;
524
+ }
525
+ if (this.#interactionKeys.has(interactionId)) return;
526
+ if (existing) {
527
+ this.#logger.warn?.(
528
+ `[dsh-im:${this.#descriptor.key}] cancelled a second pending Harness question`,
529
+ );
530
+ await this.#respondCancellation(
531
+ interaction,
532
+ `${this.#descriptor.label} is already handling another user interaction.`,
533
+ );
534
+ return;
535
+ }
536
+
537
+ const pending = {
538
+ kind: 'question',
539
+ interactionId,
540
+ sessionId: interaction.sessionId,
541
+ interaction,
542
+ actor,
543
+ requiresMention,
544
+ questions,
545
+ answers: [],
546
+ index: 0,
547
+ target,
548
+ queue: null,
549
+ claimedReplyMessageId: null,
550
+ submitting: false,
551
+ needsPresentation: true,
552
+ presentationTask: null,
553
+ };
554
+ this.#pendingInteractions.set(key, pending);
555
+ this.#interactionKeys.set(interactionId, key);
556
+ await this.#presentInteraction(pending);
557
+ }
558
+
559
+ async #handleInteractionResolved(resolution) {
560
+ if (resolution?.kind === 'approval') {
561
+ await this.#approvals.handleResolved(resolution);
562
+ return;
563
+ }
564
+ const interactionId = cleanText(resolution?.interactionId);
565
+ if (resolution?.kind !== 'question' || !interactionId) return;
566
+ const key = this.#interactionKeys.get(interactionId);
567
+ if (!key) return;
568
+ this.#clearPendingInteraction(key, interactionId);
569
+ }
570
+
571
+ #presentInteraction(pending) {
572
+ if (pending.presentationTask) return pending.presentationTask;
573
+ const question = pending.questions[pending.index];
574
+ if (!question) return Promise.resolve();
575
+ const task = (async () => {
576
+ await this.#bot.sendText(
577
+ pending.target,
578
+ harnessQuestionText(
579
+ question,
580
+ pending.index,
581
+ pending.questions.length,
582
+ { requiresMention: pending.requiresMention },
583
+ ),
584
+ );
585
+ pending.needsPresentation = false;
586
+ })();
587
+ pending.presentationTask = task;
588
+ task.then(
589
+ () => {
590
+ if (pending.presentationTask === task) pending.presentationTask = null;
591
+ },
592
+ () => {
593
+ if (pending.presentationTask === task) pending.presentationTask = null;
594
+ },
595
+ );
596
+ return task;
597
+ }
598
+
599
+ async #discardResolvedInteractionReply(message, messageId, {
600
+ alreadyRecorded = false,
601
+ } = {}) {
602
+ if (!alreadyRecorded) {
603
+ if (this.#state.hasSeen(messageId)) return;
604
+ await this.#state.markSeen(messageId);
605
+ this.#status.messagesReceived += 1;
606
+ this.#status.lastMessageAt = new Date().toISOString();
607
+ }
608
+ try {
609
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
610
+ } catch (error) {
611
+ this.#logger.error?.(
612
+ `[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,
613
+ error,
614
+ );
615
+ }
616
+ }
617
+
618
+ #takePendingInteraction(key, interactionId) {
619
+ const pending = this.#pendingInteractions.get(key);
620
+ if (!pending
621
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
622
+ this.#pendingInteractions.delete(key);
623
+ this.#interactionKeys.delete(pending.interactionId);
624
+ return pending;
625
+ }
626
+
627
+ #clearPendingInteraction(key, interactionId) {
628
+ return this.#takePendingInteraction(key, interactionId) !== null;
629
+ }
630
+
631
+ async #respondCancellation(interaction, message) {
632
+ try {
633
+ await interaction.respond({
634
+ ok: false,
635
+ error: { code: 'cancelled', message, details: {} },
636
+ }, { signal: AbortSignal.timeout(5_000) });
637
+ } catch (error) {
638
+ if (error?.code !== 'interaction-not-pending') throw error;
639
+ }
640
+ }
641
+
642
+ async #cancelPendingInteraction(key) {
643
+ const pending = this.#takePendingInteraction(key);
644
+ if (!pending || pending.kind !== 'question') return;
645
+ try {
646
+ await this.#respondCancellation(
647
+ pending.interaction,
648
+ `The ${this.#descriptor.label} interaction ended before the user answered.`,
649
+ );
650
+ } catch (error) {
651
+ this.#logger.warn?.(
652
+ `[dsh-im:${this.#descriptor.key}] failed to cancel a pending Harness interaction:`,
653
+ error,
654
+ );
193
655
  }
194
656
  }
195
657
  }
@@ -1,3 +1,11 @@
1
- import { HarnessClient } from '../weixin/harness-client.mjs';
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
2
 
3
- export class SlackHarnessClient extends HarnessClient {}
3
+ export class SlackHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'slack',
8
+ logPrefix: 'dsh-slack',
9
+ });
10
+ }
11
+ }
@@ -309,6 +309,7 @@ export class SlackRuntime {
309
309
  status: this.#status,
310
310
  logger: this.#logger,
311
311
  replyTimeoutMs: this.#replyTimeoutMs,
312
+ signal: controller.signal,
312
313
  });
313
314
  let timer;
314
315
  try {
@@ -389,7 +390,16 @@ export class SlackRuntime {
389
390
  if (this.#appId && packet.payload.api_app_id
390
391
  && packet.payload.api_app_id !== this.#appId) return;
391
392
  const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
392
- if (message) void this.#bridge?.accept(message);
393
+ const bridge = this.#bridge;
394
+ if (message && bridge) {
395
+ void bridge.accept(message).catch((error) => {
396
+ if (generation !== this.#generation || this.#stopped) return;
397
+ this.#logger.error?.(
398
+ `[dsh-im:slack] bot ${this.#config.botId} message handling failed:`,
399
+ error,
400
+ );
401
+ });
402
+ }
393
403
  });
394
404
 
395
405
  addSocketListener(socket, 'close', (event = {}) => {
@@ -1,3 +1,11 @@
1
- import { HarnessClient } from '../weixin/harness-client.mjs';
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
2
 
3
- export class TelegramHarnessClient extends HarnessClient {}
3
+ export class TelegramHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'telegram',
8
+ logPrefix: 'dsh-telegram',
9
+ });
10
+ }
11
+ }