@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,6 +1,14 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
+ import {
3
+ harnessAnswerForQuestion,
4
+ harnessQuestionText,
5
+ validHarnessQuestion,
6
+ } from '../shared/harness-question.mjs';
7
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
2
8
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
3
9
 
10
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
11
+
4
12
  const HELP_TEXT = [
5
13
  'QQ 机器人已连接 DeepSeek Harness。',
6
14
  '',
@@ -22,6 +30,17 @@ function safeText(message) {
22
30
  return typeof message?.content === 'string' ? message.content.trim() : '';
23
31
  }
24
32
 
33
+ function nonEmptyString(value) {
34
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
35
+ }
36
+
37
+ function canClaimInteractionReply(message, pending) {
38
+ return pending.questions[pending.index]
39
+ && nonEmptyString(message?.senderId) === pending.actor
40
+ && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
41
+ && nonEmptyString(safeText(message));
42
+ }
43
+
25
44
  export function createQqBridgeStatus() {
26
45
  return {
27
46
  messagesReceived: 0,
@@ -42,7 +61,13 @@ export class QqHarnessBridge {
42
61
  #status;
43
62
  #logger;
44
63
  #replyTimeoutMs;
64
+ #signal;
45
65
  #queues = new Map();
66
+ #pendingInteractions = new Map();
67
+ #interactionKeys = new Map();
68
+ #acceptedMessageIds = new Set();
69
+ #approvalTasks = new Set();
70
+ #approvals;
46
71
 
47
72
  constructor({
48
73
  bot,
@@ -52,6 +77,7 @@ export class QqHarnessBridge {
52
77
  status = createQqBridgeStatus(),
53
78
  logger = console,
54
79
  replyTimeoutMs = 600_000,
80
+ signal,
55
81
  }) {
56
82
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
57
83
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
@@ -63,6 +89,8 @@ export class QqHarnessBridge {
63
89
  this.#status = status;
64
90
  this.#logger = logger;
65
91
  this.#replyTimeoutMs = replyTimeoutMs;
92
+ this.#signal = signal;
93
+ this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
66
94
  }
67
95
 
68
96
  get status() {
@@ -70,12 +98,81 @@ export class QqHarnessBridge {
70
98
  }
71
99
 
72
100
  accept(message) {
101
+ if (this.#signal?.aborted) return Promise.resolve();
102
+ const messageId = nonEmptyString(message?.messageId);
103
+ const sender = nonEmptyString(message?.senderId);
104
+ if (!messageId || !sender || message?.senderIsBot === true
105
+ || !['c2c', 'group'].includes(message?.kind)
106
+ || this.#state.hasSeen(messageId)
107
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
73
108
  const key = conversationKey(message);
109
+ this.#acceptedMessageIds.add(messageId);
110
+ const pending = this.#pendingInteractions.get(key);
111
+ const approval = this.#approvals.claimReply({
112
+ key,
113
+ actor: sender,
114
+ messageId,
115
+ text: safeText(message),
116
+ addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
117
+ hasPendingQuestion: Boolean(pending),
118
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
119
+ ? pending.queue
120
+ : null,
121
+ isQuestionPending: () => this.#pendingInteractions.has(key),
122
+ send: (text) => this.#bot.sendText(message.replyTarget, text),
123
+ });
124
+ if (approval) {
125
+ let task;
126
+ task = approval.process(async () => {
127
+ if (this.#state.hasSeen(messageId)) return false;
128
+ await this.#state.markSeen(messageId);
129
+ this.#status.messagesReceived += 1;
130
+ this.#status.lastMessageAt = new Date().toISOString();
131
+ return true;
132
+ })
133
+ .finally(() => {
134
+ this.#acceptedMessageIds.delete(messageId);
135
+ this.#approvalTasks.delete(task);
136
+ });
137
+ this.#approvalTasks.add(task);
138
+ return task;
139
+ }
140
+ if (pending && sender !== pending.actor) {
141
+ return this.#enqueueMessage(message, messageId, key);
142
+ }
143
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
144
+ return this.#enqueueMessage(message, messageId, key);
145
+ }
146
+ if (pending) {
147
+ if (canClaimInteractionReply(message, pending)) {
148
+ pending.claimedReplyMessageId = messageId;
149
+ }
150
+ const previous = pending.queue ?? Promise.resolve();
151
+ const current = previous
152
+ .catch(() => undefined)
153
+ .then(() => this.#processInteractionReply(message, messageId, key, pending))
154
+ .catch((error) => this.#handleInteractionFailure(message, messageId, error))
155
+ .finally(() => {
156
+ this.#acceptedMessageIds.delete(messageId);
157
+ if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
158
+ if (pending.queue === current) pending.queue = null;
159
+ });
160
+ pending.queue = current;
161
+ return current;
162
+ }
163
+ return this.#enqueueMessage(message, messageId, key);
164
+ }
165
+
166
+ #enqueueMessage(message, messageId, key, {
167
+ releaseMessageId = true,
168
+ alreadyRecorded = false,
169
+ } = {}) {
74
170
  const previous = this.#queues.get(key) ?? Promise.resolve();
75
171
  const current = previous
76
172
  .catch(() => undefined)
77
- .then(() => this.#process(message))
173
+ .then(() => this.#process(message, key, { alreadyRecorded }))
78
174
  .finally(() => {
175
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
79
176
  if (this.#queues.get(key) === current) this.#queues.delete(key);
80
177
  });
81
178
  this.#queues.set(key, current);
@@ -83,17 +180,26 @@ export class QqHarnessBridge {
83
180
  }
84
181
 
85
182
  async waitForIdle() {
86
- await Promise.allSettled([...this.#queues.values()]);
183
+ await Promise.allSettled([
184
+ ...this.#queues.values(),
185
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
186
+ pending.queue ? [pending.queue] : []
187
+ )),
188
+ ...this.#approvalTasks,
189
+ ]);
87
190
  }
88
191
 
89
- async #process(message) {
90
- const messageId = typeof message?.messageId === 'string' ? message.messageId : '';
91
- const sender = typeof message?.senderId === 'string' ? message.senderId : '';
192
+ async #process(message, key, { alreadyRecorded = false } = {}) {
193
+ if (this.#signal?.aborted) return;
194
+ const messageId = nonEmptyString(message?.messageId);
195
+ const sender = nonEmptyString(message?.senderId);
92
196
  if (!messageId || !sender || message.senderIsBot === true) return;
93
- if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return;
94
-
95
- this.#status.messagesReceived += 1;
96
- this.#status.lastMessageAt = new Date().toISOString();
197
+ if (!['c2c', 'group'].includes(message.kind)) return;
198
+ if (!alreadyRecorded) {
199
+ if (this.#state.hasSeen(messageId)) return;
200
+ this.#status.messagesReceived += 1;
201
+ this.#status.lastMessageAt = new Date().toISOString();
202
+ }
97
203
  if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
98
204
  this.#status.messagesRejected += 1;
99
205
  this.#status.lastRejectedAt = new Date().toISOString();
@@ -116,12 +222,11 @@ export class QqHarnessBridge {
116
222
  return;
117
223
  }
118
224
  if (command === '/status') {
119
- await this.#harness.ensureRunning();
225
+ await this.#harness.ensureRunning({ signal: this.#signal });
120
226
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
121
227
  await this.#state.markSeen(messageId);
122
228
  return;
123
229
  }
124
- const key = conversationKey(message);
125
230
  if (command === '/new') {
126
231
  await this.#state.clearSession(key);
127
232
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
@@ -146,23 +251,41 @@ export class QqHarnessBridge {
146
251
  this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
147
252
  }
148
253
  }
149
- const { answer } = await askInWorkspaceSession({
150
- harness: this.#harness,
151
- state: this.#state,
152
- key,
153
- text,
154
- askOptions: {
155
- timeoutMs: this.#replyTimeoutMs,
156
- onUpdate: stream ? async (update) => {
157
- const progress = update.type === 'text'
158
- ? update.text
159
- : update.type === 'tool'
160
- ? `正在使用${update.name}…`
161
- : update.text;
162
- if (progress) await stream.update(progress);
163
- } : undefined,
164
- },
165
- });
254
+ let answer;
255
+ try {
256
+ ({ answer } = await askInWorkspaceSession({
257
+ harness: this.#harness,
258
+ state: this.#state,
259
+ key,
260
+ text,
261
+ createOptions: { signal: this.#signal },
262
+ existsOptions: { signal: this.#signal },
263
+ askOptions: {
264
+ timeoutMs: this.#replyTimeoutMs,
265
+ signal: this.#signal,
266
+ onUpdate: stream ? async (update) => {
267
+ const progress = update.type === 'text'
268
+ ? update.text
269
+ : update.type === 'tool'
270
+ ? `正在使用${update.name}…`
271
+ : update.text;
272
+ if (progress) await stream.update(progress);
273
+ } : undefined,
274
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
275
+ key,
276
+ actor: sender,
277
+ target,
278
+ requiresMention: message.kind === 'group',
279
+ }),
280
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
281
+ },
282
+ }));
283
+ } finally {
284
+ await Promise.allSettled([
285
+ this.#cancelPendingInteraction(key),
286
+ this.#approvals.closeRoute(key),
287
+ ]);
288
+ }
166
289
  if (stream) {
167
290
  try {
168
291
  await stream.update(answer);
@@ -179,6 +302,7 @@ export class QqHarnessBridge {
179
302
  this.#status.lastReplyAt = new Date().toISOString();
180
303
  this.#status.lastError = null;
181
304
  } catch (error) {
305
+ if (this.#signal?.aborted) return;
182
306
  this.#status.lastError = error?.message ?? String(error);
183
307
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
184
308
  try {
@@ -189,4 +313,280 @@ export class QqHarnessBridge {
189
313
  }
190
314
  }
191
315
  }
316
+
317
+ async #processInteractionReply(message, messageId, key, expected) {
318
+ this.#signal?.throwIfAborted();
319
+ const current = this.#pendingInteractions.get(key);
320
+ const claimed = expected.claimedReplyMessageId === messageId;
321
+ if (!current || current !== expected || current.submitting) {
322
+ if (claimed && (!current || current !== expected)) {
323
+ return this.#discardResolvedInteractionReply(message, messageId);
324
+ }
325
+ return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
326
+ }
327
+ if (this.#state.hasSeen(messageId)) return;
328
+ await this.#state.markSeen(messageId);
329
+ this.#status.messagesReceived += 1;
330
+ this.#status.lastMessageAt = new Date().toISOString();
331
+
332
+ if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
333
+ const text = nonEmptyString(safeText(message));
334
+ if (!text) {
335
+ await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
336
+ return;
337
+ }
338
+
339
+ const pending = this.#pendingInteractions.get(key);
340
+ if (!pending || pending !== expected || pending.submitting) {
341
+ if (claimed && (!pending || pending !== expected)) {
342
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
343
+ return;
344
+ }
345
+ return this.#enqueueMessage(message, messageId, key, {
346
+ releaseMessageId: false,
347
+ alreadyRecorded: true,
348
+ });
349
+ }
350
+ pending.target = message.replyTarget;
351
+ if (pending.needsPresentation) {
352
+ try {
353
+ await this.#presentInteraction(pending);
354
+ } catch {
355
+ this.#status.lastError = 'QQ 交互问题发送失败。';
356
+ this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
357
+ pending.interaction.reconnect?.();
358
+ return;
359
+ }
360
+ const presentedPending = this.#pendingInteractions.get(key);
361
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
362
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
363
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT)
364
+ .catch(() => undefined);
365
+ return;
366
+ }
367
+ return this.#enqueueMessage(message, messageId, key, {
368
+ releaseMessageId: false,
369
+ alreadyRecorded: true,
370
+ });
371
+ }
372
+ }
373
+
374
+ const question = pending.questions[pending.index];
375
+ if (!question) return;
376
+ pending.answers.push(harnessAnswerForQuestion(question, text));
377
+ pending.index += 1;
378
+ if (pending.index < pending.questions.length) {
379
+ if (pending.claimedReplyMessageId === messageId) {
380
+ pending.claimedReplyMessageId = null;
381
+ }
382
+ pending.needsPresentation = true;
383
+ try {
384
+ await this.#presentInteraction(pending);
385
+ } catch {
386
+ this.#status.lastError = 'QQ 交互问题发送失败。';
387
+ this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
388
+ pending.interaction.reconnect?.();
389
+ }
390
+ return;
391
+ }
392
+
393
+ pending.submitting = true;
394
+ try {
395
+ await pending.interaction.respond({
396
+ ok: true,
397
+ value: {
398
+ sessionId: pending.sessionId,
399
+ answer: { answers: pending.answers },
400
+ },
401
+ });
402
+ this.#clearPendingInteraction(key, pending.interactionId);
403
+ this.#status.lastError = null;
404
+ } catch (error) {
405
+ if (this.#signal?.aborted) return;
406
+ if (error?.code === 'interaction-not-pending') {
407
+ this.#clearPendingInteraction(key, pending.interactionId);
408
+ await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
409
+ return;
410
+ }
411
+ if (this.#pendingInteractions.get(key) !== pending) return;
412
+ pending.submitting = false;
413
+ pending.answers.pop();
414
+ pending.index -= 1;
415
+ this.#status.lastError = '回答提交失败。';
416
+ this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
417
+ await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。')
418
+ .catch(() => undefined);
419
+ }
420
+ }
421
+
422
+ async #handleInteraction(interaction, {
423
+ key,
424
+ actor,
425
+ target,
426
+ requiresMention,
427
+ }) {
428
+ if (interaction?.kind === 'approval') {
429
+ return this.#approvals.handleRequested(interaction, {
430
+ key,
431
+ actor,
432
+ requiresMention,
433
+ send: (text) => this.#bot.sendText(target, text),
434
+ });
435
+ }
436
+ if (interaction?.kind !== 'question') return;
437
+ const questions = interaction?.payload?.questions;
438
+ const interactionId = typeof interaction?.interactionId === 'string'
439
+ ? interaction.interactionId
440
+ : interaction?.rpcId;
441
+ if (typeof interaction?.rpcId !== 'string'
442
+ || typeof interactionId !== 'string'
443
+ || typeof interaction.sessionId !== 'string'
444
+ || !Array.isArray(questions)
445
+ || questions.length === 0
446
+ || questions.some((question) => !validHarnessQuestion(question))) {
447
+ this.#logger.warn?.('[dsh-im:qq] ignored an invalid Harness question interaction');
448
+ return;
449
+ }
450
+
451
+ if (interaction.recovered === true) {
452
+ await interaction.respond({
453
+ ok: false,
454
+ error: {
455
+ code: 'cancelled',
456
+ message: 'QQ safely cancelled an interaction left by an earlier client.',
457
+ details: {},
458
+ },
459
+ });
460
+ await this.#bot.sendText(
461
+ target,
462
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
463
+ ).catch(() => undefined);
464
+ return;
465
+ }
466
+
467
+ const existing = this.#pendingInteractions.get(key);
468
+ if (existing?.interactionId === interactionId) {
469
+ existing.interaction = interaction;
470
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
471
+ return;
472
+ }
473
+ if (this.#interactionKeys.has(interactionId)) return;
474
+ if (existing) {
475
+ await interaction.respond({
476
+ ok: false,
477
+ error: {
478
+ code: 'cancelled',
479
+ message: 'QQ is already handling another user interaction.',
480
+ details: {},
481
+ },
482
+ });
483
+ return;
484
+ }
485
+
486
+ const pending = {
487
+ kind: 'question',
488
+ interactionId,
489
+ sessionId: interaction.sessionId,
490
+ interaction,
491
+ actor,
492
+ requiresMention,
493
+ questions,
494
+ answers: [],
495
+ index: 0,
496
+ target,
497
+ queue: null,
498
+ claimedReplyMessageId: null,
499
+ presentationPromise: null,
500
+ submitting: false,
501
+ needsPresentation: true,
502
+ };
503
+ this.#pendingInteractions.set(key, pending);
504
+ this.#interactionKeys.set(interactionId, key);
505
+ await this.#presentInteraction(pending);
506
+ }
507
+
508
+ async #handleInteractionResolved(resolution) {
509
+ if (resolution?.kind === 'approval') {
510
+ await this.#approvals.handleResolved(resolution);
511
+ return;
512
+ }
513
+ const interactionId = resolution?.interactionId;
514
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
515
+ const key = this.#interactionKeys.get(interactionId);
516
+ if (!key) return;
517
+ this.#clearPendingInteraction(key, interactionId);
518
+ }
519
+
520
+ #presentInteraction(pending) {
521
+ if (!pending.needsPresentation) return Promise.resolve();
522
+ if (pending.presentationPromise) return pending.presentationPromise;
523
+ const question = pending.questions[pending.index];
524
+ if (!question) return Promise.resolve();
525
+ const presentation = this.#bot.sendText(
526
+ pending.target,
527
+ harnessQuestionText(
528
+ question,
529
+ pending.index,
530
+ pending.questions.length,
531
+ { requiresMention: pending.requiresMention },
532
+ ),
533
+ ).then(() => {
534
+ pending.needsPresentation = false;
535
+ }).finally(() => {
536
+ if (pending.presentationPromise === presentation) pending.presentationPromise = null;
537
+ });
538
+ pending.presentationPromise = presentation;
539
+ return presentation;
540
+ }
541
+
542
+ async #discardResolvedInteractionReply(message, messageId) {
543
+ if (this.#state.hasSeen(messageId)) return;
544
+ await this.#state.markSeen(messageId);
545
+ this.#status.messagesReceived += 1;
546
+ this.#status.lastMessageAt = new Date().toISOString();
547
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
548
+ }
549
+
550
+ #takePendingInteraction(key, interactionId) {
551
+ const pending = this.#pendingInteractions.get(key);
552
+ if (!pending
553
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
554
+ this.#pendingInteractions.delete(key);
555
+ this.#interactionKeys.delete(pending.interactionId);
556
+ return pending;
557
+ }
558
+
559
+ #clearPendingInteraction(key, interactionId) {
560
+ return this.#takePendingInteraction(key, interactionId) !== null;
561
+ }
562
+
563
+ async #cancelPendingInteraction(key) {
564
+ const pending = this.#takePendingInteraction(key);
565
+ if (!pending || pending.kind !== 'question') return;
566
+ try {
567
+ await pending.interaction.respond({
568
+ ok: false,
569
+ error: {
570
+ code: 'cancelled',
571
+ message: 'The QQ interaction ended before the user answered.',
572
+ details: {},
573
+ },
574
+ }, { signal: AbortSignal.timeout(5_000) });
575
+ } catch (error) {
576
+ if (error?.code !== 'interaction-not-pending') {
577
+ this.#logger.warn?.('[dsh-im:qq] failed to cancel a pending Harness interaction');
578
+ }
579
+ }
580
+ }
581
+
582
+ async #handleInteractionFailure(message, messageId, error) {
583
+ if (this.#signal?.aborted) return;
584
+ this.#status.lastError = error?.message ?? String(error);
585
+ this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
586
+ if (!this.#state.hasSeen(messageId)) {
587
+ await this.#state.markSeen(messageId).catch(() => undefined);
588
+ }
589
+ await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
590
+ .catch(() => undefined);
591
+ }
192
592
  }
@@ -101,6 +101,8 @@ export class QqRuntime {
101
101
  if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
102
102
  throw new TypeError('QQ bot factory returned an invalid client');
103
103
  }
104
+ const controller = new AbortController();
105
+ this.#abortController = controller;
104
106
  this.#bot = bot;
105
107
  this.#bridge = new QqHarnessBridge({
106
108
  bot,
@@ -110,6 +112,7 @@ export class QqRuntime {
110
112
  status: this.#status,
111
113
  logger: this.#logger,
112
114
  replyTimeoutMs: this.#replyTimeoutMs,
115
+ signal: controller.signal,
113
116
  });
114
117
  bot.use?.(this.#typingMiddleware({
115
118
  keepAlive: true,
@@ -117,8 +120,6 @@ export class QqRuntime {
117
120
  || ctx?.message?.senderId === this.#config.ownerUserOpenid,
118
121
  }));
119
122
 
120
- const controller = new AbortController();
121
- this.#abortController = controller;
122
123
  let readyResolve;
123
124
  let readyReject;
124
125
  const ready = new Promise((resolve, reject) => {
@@ -141,7 +142,17 @@ export class QqRuntime {
141
142
  this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
142
143
  }
143
144
  };
144
- const onMessage = (_ctx, message) => this.#bridge?.accept(message);
145
+ const onMessage = (_ctx, message) => {
146
+ const task = this.#bridge?.accept(message);
147
+ if (!task) return;
148
+ void task.catch((error) => {
149
+ if (controller.signal.aborted) return;
150
+ this.#logger.error?.(
151
+ `[dsh-im:qq] bot ${this.#config.botId} message handling failed:`,
152
+ error,
153
+ );
154
+ });
155
+ };
145
156
  bot.on('ready', onReady);
146
157
  bot.on('resumed', onReady);
147
158
  bot.on('error', onError);