@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
@@ -1,6 +1,13 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
+ import {
3
+ harnessAnswerForQuestion,
4
+ harnessQuestionText,
5
+ validHarnessQuestion,
6
+ } from '../shared/harness-question.mjs';
2
7
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
3
8
 
9
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
10
+
4
11
  const HELP_TEXT = [
5
12
  'QQ 机器人已连接 DeepSeek Harness。',
6
13
  '',
@@ -8,6 +15,8 @@ const HELP_TEXT = [
8
15
  '/new 开启一个全新会话',
9
16
  '/workspace 工作区绝对路径 切换工作区',
10
17
  '/workspacelist 列出工作区绝对路径',
18
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
19
+ '/session Session ID 将当前聊天绑定到指定会话',
11
20
  '/status 检查连接状态',
12
21
  '/help 显示本帮助',
13
22
  ].join('\n');
@@ -20,6 +29,17 @@ function safeText(message) {
20
29
  return typeof message?.content === 'string' ? message.content.trim() : '';
21
30
  }
22
31
 
32
+ function nonEmptyString(value) {
33
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
34
+ }
35
+
36
+ function canClaimInteractionReply(message, pending) {
37
+ return pending.questions[pending.index]
38
+ && nonEmptyString(message?.senderId) === pending.actor
39
+ && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
40
+ && nonEmptyString(safeText(message));
41
+ }
42
+
23
43
  export function createQqBridgeStatus() {
24
44
  return {
25
45
  messagesReceived: 0,
@@ -40,7 +60,11 @@ export class QqHarnessBridge {
40
60
  #status;
41
61
  #logger;
42
62
  #replyTimeoutMs;
63
+ #signal;
43
64
  #queues = new Map();
65
+ #pendingInteractions = new Map();
66
+ #interactionKeys = new Map();
67
+ #acceptedMessageIds = new Set();
44
68
 
45
69
  constructor({
46
70
  bot,
@@ -50,6 +74,7 @@ export class QqHarnessBridge {
50
74
  status = createQqBridgeStatus(),
51
75
  logger = console,
52
76
  replyTimeoutMs = 600_000,
77
+ signal,
53
78
  }) {
54
79
  if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
55
80
  if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
@@ -61,6 +86,7 @@ export class QqHarnessBridge {
61
86
  this.#status = status;
62
87
  this.#logger = logger;
63
88
  this.#replyTimeoutMs = replyTimeoutMs;
89
+ this.#signal = signal;
64
90
  }
65
91
 
66
92
  get status() {
@@ -68,12 +94,52 @@ export class QqHarnessBridge {
68
94
  }
69
95
 
70
96
  accept(message) {
97
+ if (this.#signal?.aborted) return Promise.resolve();
98
+ const messageId = nonEmptyString(message?.messageId);
99
+ const sender = nonEmptyString(message?.senderId);
100
+ if (!messageId || !sender || message?.senderIsBot === true
101
+ || !['c2c', 'group'].includes(message?.kind)
102
+ || this.#state.hasSeen(messageId)
103
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
71
104
  const key = conversationKey(message);
105
+ this.#acceptedMessageIds.add(messageId);
106
+ const pending = this.#pendingInteractions.get(key);
107
+ if (pending && sender !== pending.actor) {
108
+ return this.#enqueueMessage(message, messageId, key);
109
+ }
110
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
111
+ return this.#enqueueMessage(message, messageId, key);
112
+ }
113
+ if (pending) {
114
+ if (canClaimInteractionReply(message, pending)) {
115
+ pending.claimedReplyMessageId = messageId;
116
+ }
117
+ const previous = pending.queue ?? Promise.resolve();
118
+ const current = previous
119
+ .catch(() => undefined)
120
+ .then(() => this.#processInteractionReply(message, messageId, key, pending))
121
+ .catch((error) => this.#handleInteractionFailure(message, messageId, error))
122
+ .finally(() => {
123
+ this.#acceptedMessageIds.delete(messageId);
124
+ if (pending.claimedReplyMessageId === messageId) pending.claimedReplyMessageId = null;
125
+ if (pending.queue === current) pending.queue = null;
126
+ });
127
+ pending.queue = current;
128
+ return current;
129
+ }
130
+ return this.#enqueueMessage(message, messageId, key);
131
+ }
132
+
133
+ #enqueueMessage(message, messageId, key, {
134
+ releaseMessageId = true,
135
+ alreadyRecorded = false,
136
+ } = {}) {
72
137
  const previous = this.#queues.get(key) ?? Promise.resolve();
73
138
  const current = previous
74
139
  .catch(() => undefined)
75
- .then(() => this.#process(message))
140
+ .then(() => this.#process(message, key, { alreadyRecorded }))
76
141
  .finally(() => {
142
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
77
143
  if (this.#queues.get(key) === current) this.#queues.delete(key);
78
144
  });
79
145
  this.#queues.set(key, current);
@@ -81,17 +147,25 @@ export class QqHarnessBridge {
81
147
  }
82
148
 
83
149
  async waitForIdle() {
84
- await Promise.allSettled([...this.#queues.values()]);
150
+ await Promise.allSettled([
151
+ ...this.#queues.values(),
152
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
153
+ pending.queue ? [pending.queue] : []
154
+ )),
155
+ ]);
85
156
  }
86
157
 
87
- async #process(message) {
88
- const messageId = typeof message?.messageId === 'string' ? message.messageId : '';
89
- const sender = typeof message?.senderId === 'string' ? message.senderId : '';
158
+ async #process(message, key, { alreadyRecorded = false } = {}) {
159
+ if (this.#signal?.aborted) return;
160
+ const messageId = nonEmptyString(message?.messageId);
161
+ const sender = nonEmptyString(message?.senderId);
90
162
  if (!messageId || !sender || message.senderIsBot === true) return;
91
- if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return;
92
-
93
- this.#status.messagesReceived += 1;
94
- this.#status.lastMessageAt = new Date().toISOString();
163
+ if (!['c2c', 'group'].includes(message.kind)) return;
164
+ if (!alreadyRecorded) {
165
+ if (this.#state.hasSeen(messageId)) return;
166
+ this.#status.messagesReceived += 1;
167
+ this.#status.lastMessageAt = new Date().toISOString();
168
+ }
95
169
  if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
96
170
  this.#status.messagesRejected += 1;
97
171
  this.#status.lastRejectedAt = new Date().toISOString();
@@ -114,19 +188,18 @@ export class QqHarnessBridge {
114
188
  return;
115
189
  }
116
190
  if (command === '/status') {
117
- await this.#harness.ensureRunning();
191
+ await this.#harness.ensureRunning({ signal: this.#signal });
118
192
  await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。');
119
193
  await this.#state.markSeen(messageId);
120
194
  return;
121
195
  }
122
- const key = conversationKey(message);
123
196
  if (command === '/new') {
124
197
  await this.#state.clearSession(key);
125
198
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
126
199
  await this.#state.markSeen(messageId);
127
200
  return;
128
201
  }
129
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
202
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
130
203
  if (workspaceCommand) {
131
204
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
132
205
  await this.#bot.sendText(target, reply);
@@ -144,23 +217,38 @@ export class QqHarnessBridge {
144
217
  this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
145
218
  }
146
219
  }
147
- const { answer } = await askInWorkspaceSession({
148
- harness: this.#harness,
149
- state: this.#state,
150
- key,
151
- text,
152
- askOptions: {
153
- timeoutMs: this.#replyTimeoutMs,
154
- onUpdate: stream ? async (update) => {
155
- const progress = update.type === 'text'
156
- ? update.text
157
- : update.type === 'tool'
158
- ? `正在使用${update.name}…`
159
- : update.text;
160
- if (progress) await stream.update(progress);
161
- } : undefined,
162
- },
163
- });
220
+ let answer;
221
+ try {
222
+ ({ answer } = await askInWorkspaceSession({
223
+ harness: this.#harness,
224
+ state: this.#state,
225
+ key,
226
+ text,
227
+ createOptions: { signal: this.#signal },
228
+ existsOptions: { signal: this.#signal },
229
+ askOptions: {
230
+ timeoutMs: this.#replyTimeoutMs,
231
+ signal: this.#signal,
232
+ onUpdate: stream ? async (update) => {
233
+ const progress = update.type === 'text'
234
+ ? update.text
235
+ : update.type === 'tool'
236
+ ? `正在使用${update.name}…`
237
+ : update.text;
238
+ if (progress) await stream.update(progress);
239
+ } : undefined,
240
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
241
+ key,
242
+ actor: sender,
243
+ target,
244
+ requiresMention: message.kind === 'group',
245
+ }),
246
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
247
+ },
248
+ }));
249
+ } finally {
250
+ await this.#cancelPendingInteraction(key);
251
+ }
164
252
  if (stream) {
165
253
  try {
166
254
  await stream.update(answer);
@@ -177,6 +265,7 @@ export class QqHarnessBridge {
177
265
  this.#status.lastReplyAt = new Date().toISOString();
178
266
  this.#status.lastError = null;
179
267
  } catch (error) {
268
+ if (this.#signal?.aborted) return;
180
269
  this.#status.lastError = error?.message ?? String(error);
181
270
  this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
182
271
  try {
@@ -187,4 +276,269 @@ export class QqHarnessBridge {
187
276
  }
188
277
  }
189
278
  }
279
+
280
+ async #processInteractionReply(message, messageId, key, expected) {
281
+ this.#signal?.throwIfAborted();
282
+ const current = this.#pendingInteractions.get(key);
283
+ const claimed = expected.claimedReplyMessageId === messageId;
284
+ if (!current || current !== expected || current.submitting) {
285
+ if (claimed && (!current || current !== expected)) {
286
+ return this.#discardResolvedInteractionReply(message, messageId);
287
+ }
288
+ return this.#enqueueMessage(message, messageId, key, { releaseMessageId: false });
289
+ }
290
+ if (this.#state.hasSeen(messageId)) return;
291
+ await this.#state.markSeen(messageId);
292
+ this.#status.messagesReceived += 1;
293
+ this.#status.lastMessageAt = new Date().toISOString();
294
+
295
+ if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
296
+ const text = nonEmptyString(safeText(message));
297
+ if (!text) {
298
+ await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。');
299
+ return;
300
+ }
301
+
302
+ const pending = this.#pendingInteractions.get(key);
303
+ if (!pending || pending !== expected || pending.submitting) {
304
+ if (claimed && (!pending || pending !== expected)) {
305
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT);
306
+ return;
307
+ }
308
+ return this.#enqueueMessage(message, messageId, key, {
309
+ releaseMessageId: false,
310
+ alreadyRecorded: true,
311
+ });
312
+ }
313
+ pending.target = message.replyTarget;
314
+ if (pending.needsPresentation) {
315
+ try {
316
+ await this.#presentInteraction(pending);
317
+ } catch {
318
+ this.#status.lastError = 'QQ 交互问题发送失败。';
319
+ this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
320
+ pending.interaction.reconnect?.();
321
+ return;
322
+ }
323
+ const presentedPending = this.#pendingInteractions.get(key);
324
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
325
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
326
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT)
327
+ .catch(() => undefined);
328
+ return;
329
+ }
330
+ return this.#enqueueMessage(message, messageId, key, {
331
+ releaseMessageId: false,
332
+ alreadyRecorded: true,
333
+ });
334
+ }
335
+ }
336
+
337
+ const question = pending.questions[pending.index];
338
+ if (!question) return;
339
+ pending.answers.push(harnessAnswerForQuestion(question, text));
340
+ pending.index += 1;
341
+ if (pending.index < pending.questions.length) {
342
+ if (pending.claimedReplyMessageId === messageId) {
343
+ pending.claimedReplyMessageId = null;
344
+ }
345
+ pending.needsPresentation = true;
346
+ try {
347
+ await this.#presentInteraction(pending);
348
+ } catch {
349
+ this.#status.lastError = 'QQ 交互问题发送失败。';
350
+ this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
351
+ pending.interaction.reconnect?.();
352
+ }
353
+ return;
354
+ }
355
+
356
+ pending.submitting = true;
357
+ try {
358
+ await pending.interaction.respond({
359
+ ok: true,
360
+ value: {
361
+ sessionId: pending.sessionId,
362
+ answer: { answers: pending.answers },
363
+ },
364
+ });
365
+ this.#clearPendingInteraction(key, pending.interactionId);
366
+ this.#status.lastError = null;
367
+ } catch (error) {
368
+ if (this.#signal?.aborted) return;
369
+ if (error?.code === 'interaction-not-pending') {
370
+ this.#clearPendingInteraction(key, pending.interactionId);
371
+ await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
372
+ return;
373
+ }
374
+ if (this.#pendingInteractions.get(key) !== pending) return;
375
+ pending.submitting = false;
376
+ pending.answers.pop();
377
+ pending.index -= 1;
378
+ this.#status.lastError = '回答提交失败。';
379
+ this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
380
+ await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。')
381
+ .catch(() => undefined);
382
+ }
383
+ }
384
+
385
+ async #handleInteraction(interaction, {
386
+ key,
387
+ actor,
388
+ target,
389
+ requiresMention,
390
+ }) {
391
+ // Approval remains fail-closed until #5 adds an authenticated policy.
392
+ if (interaction?.kind !== 'question') return;
393
+ const questions = interaction?.payload?.questions;
394
+ const interactionId = typeof interaction?.interactionId === 'string'
395
+ ? interaction.interactionId
396
+ : interaction?.rpcId;
397
+ if (typeof interaction?.rpcId !== 'string'
398
+ || typeof interactionId !== 'string'
399
+ || typeof interaction.sessionId !== 'string'
400
+ || !Array.isArray(questions)
401
+ || questions.length === 0
402
+ || questions.some((question) => !validHarnessQuestion(question))) {
403
+ this.#logger.warn?.('[dsh-im:qq] ignored an invalid Harness question interaction');
404
+ return;
405
+ }
406
+
407
+ if (interaction.recovered === true) {
408
+ await interaction.respond({
409
+ ok: false,
410
+ error: {
411
+ code: 'cancelled',
412
+ message: 'QQ safely cancelled an interaction left by an earlier client.',
413
+ details: {},
414
+ },
415
+ });
416
+ await this.#bot.sendText(
417
+ target,
418
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
419
+ ).catch(() => undefined);
420
+ return;
421
+ }
422
+
423
+ const existing = this.#pendingInteractions.get(key);
424
+ if (existing?.interactionId === interactionId) {
425
+ existing.interaction = interaction;
426
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
427
+ return;
428
+ }
429
+ if (this.#interactionKeys.has(interactionId)) return;
430
+ if (existing) {
431
+ await interaction.respond({
432
+ ok: false,
433
+ error: {
434
+ code: 'cancelled',
435
+ message: 'QQ is already handling another user interaction.',
436
+ details: {},
437
+ },
438
+ });
439
+ return;
440
+ }
441
+
442
+ const pending = {
443
+ kind: 'question',
444
+ interactionId,
445
+ sessionId: interaction.sessionId,
446
+ interaction,
447
+ actor,
448
+ requiresMention,
449
+ questions,
450
+ answers: [],
451
+ index: 0,
452
+ target,
453
+ queue: null,
454
+ claimedReplyMessageId: null,
455
+ presentationPromise: null,
456
+ submitting: false,
457
+ needsPresentation: true,
458
+ };
459
+ this.#pendingInteractions.set(key, pending);
460
+ this.#interactionKeys.set(interactionId, key);
461
+ await this.#presentInteraction(pending);
462
+ }
463
+
464
+ #handleInteractionResolved(resolution) {
465
+ const interactionId = resolution?.interactionId;
466
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
467
+ const key = this.#interactionKeys.get(interactionId);
468
+ if (!key) return;
469
+ this.#clearPendingInteraction(key, interactionId);
470
+ }
471
+
472
+ #presentInteraction(pending) {
473
+ if (!pending.needsPresentation) return Promise.resolve();
474
+ if (pending.presentationPromise) return pending.presentationPromise;
475
+ const question = pending.questions[pending.index];
476
+ if (!question) return Promise.resolve();
477
+ const presentation = this.#bot.sendText(
478
+ pending.target,
479
+ harnessQuestionText(
480
+ question,
481
+ pending.index,
482
+ pending.questions.length,
483
+ { requiresMention: pending.requiresMention },
484
+ ),
485
+ ).then(() => {
486
+ pending.needsPresentation = false;
487
+ }).finally(() => {
488
+ if (pending.presentationPromise === presentation) pending.presentationPromise = null;
489
+ });
490
+ pending.presentationPromise = presentation;
491
+ return presentation;
492
+ }
493
+
494
+ async #discardResolvedInteractionReply(message, messageId) {
495
+ if (this.#state.hasSeen(messageId)) return;
496
+ await this.#state.markSeen(messageId);
497
+ this.#status.messagesReceived += 1;
498
+ this.#status.lastMessageAt = new Date().toISOString();
499
+ await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
500
+ }
501
+
502
+ #takePendingInteraction(key, interactionId) {
503
+ const pending = this.#pendingInteractions.get(key);
504
+ if (!pending
505
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
506
+ this.#pendingInteractions.delete(key);
507
+ this.#interactionKeys.delete(pending.interactionId);
508
+ return pending;
509
+ }
510
+
511
+ #clearPendingInteraction(key, interactionId) {
512
+ return this.#takePendingInteraction(key, interactionId) !== null;
513
+ }
514
+
515
+ async #cancelPendingInteraction(key) {
516
+ const pending = this.#takePendingInteraction(key);
517
+ if (!pending || pending.kind !== 'question') return;
518
+ try {
519
+ await pending.interaction.respond({
520
+ ok: false,
521
+ error: {
522
+ code: 'cancelled',
523
+ message: 'The QQ interaction ended before the user answered.',
524
+ details: {},
525
+ },
526
+ }, { signal: AbortSignal.timeout(5_000) });
527
+ } catch (error) {
528
+ if (error?.code !== 'interaction-not-pending') {
529
+ this.#logger.warn?.('[dsh-im:qq] failed to cancel a pending Harness interaction');
530
+ }
531
+ }
532
+ }
533
+
534
+ async #handleInteractionFailure(message, messageId, error) {
535
+ if (this.#signal?.aborted) return;
536
+ this.#status.lastError = error?.message ?? String(error);
537
+ this.#logger.error?.('[dsh-im:qq] failed to process an interaction reply:', error);
538
+ if (!this.#state.hasSeen(messageId)) {
539
+ await this.#state.markSeen(messageId).catch(() => undefined);
540
+ }
541
+ await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。')
542
+ .catch(() => undefined);
543
+ }
190
544
  }
@@ -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);