@xmanrui/dsh-im 4.12.0 → 4.14.0

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 (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +11 -2
  4. package/README.md +11 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +78 -2
  7. package/lib/index.js +273 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +57 -0
  11. package/plugin-src/client/i18n.js +5 -0
  12. package/plugin-src/client/styles.js +2 -1
  13. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/harness-command-executor.mjs +13 -6
  16. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  17. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  18. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  19. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  20. package/src/channels/dingtalk/state-store.mjs +8 -0
  21. package/src/channels/feishu/bridge.mjs +584 -6
  22. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  23. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  24. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  25. package/src/channels/feishu/state-store.mjs +11 -0
  26. package/src/channels/qq/qq-bridge.mjs +121 -1
  27. package/src/channels/qq/qq-menu.mjs +205 -0
  28. package/src/channels/qq/state-store.mjs +16 -0
  29. package/src/channels/shared/bot-workspace-store.mjs +17 -1
  30. package/src/channels/shared/control-command.mjs +8 -0
  31. package/src/channels/shared/conversation-state-store.mjs +8 -0
  32. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  33. package/src/channels/shared/deferred-delivery.mjs +161 -0
  34. package/src/channels/shared/deferred-state.mjs +53 -0
  35. package/src/channels/shared/harness-client.mjs +42 -3
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  37. package/src/channels/shared/i18n-en/feishu.mjs +8 -0
  38. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  39. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  40. package/src/channels/shared/semantic/artifact.mjs +3 -1
  41. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  42. package/src/channels/shared/workspace-session.mjs +16 -1
  43. package/src/channels/wecom/state-store.mjs +8 -0
  44. package/src/channels/wecom/wecom-bridge.mjs +19 -4
  45. package/src/channels/weixin/state-store.mjs +64 -1
  46. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  47. package/src/channels/weixin/weixin-runtime.mjs +33 -8
@@ -1,3 +1,4 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
1
2
  import { t } from './i18n.mjs';
2
3
  import {
3
4
  COMMAND_PERMISSION_DENIED_MESSAGE,
@@ -135,6 +136,7 @@ export class TextHarnessBridge {
135
136
  #bot;
136
137
  #harness;
137
138
  #state;
139
+ #deferred;
138
140
  #contextEnhancement;
139
141
  #accessPolicy;
140
142
  #status;
@@ -176,6 +178,9 @@ export class TextHarnessBridge {
176
178
  this.#logger = logger;
177
179
  this.#replyTimeoutMs = replyTimeoutMs;
178
180
  this.#signal = signal;
181
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
182
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
183
+ });
179
184
  this.#approvals = new HarnessApprovalQueue({
180
185
  label: descriptor.key,
181
186
  logger,
@@ -449,6 +454,13 @@ export class TextHarnessBridge {
449
454
  return current;
450
455
  }
451
456
 
457
+ async #deliverDeferredOutcome(entry, outcome) {
458
+ const text = deferredOutcomeText(outcome);
459
+ return typeof this.#bot.sendDelivery === 'function'
460
+ ? this.#bot.sendDelivery(entry.target, createTextDeliveryBlock(text, outcome.found ? 'markdown' : 'plain'))
461
+ : this.#bot.sendText(entry.target, text);
462
+ }
463
+
452
464
  async waitForIdle() {
453
465
  await Promise.allSettled([
454
466
  ...this.#queues.values(),
@@ -458,6 +470,7 @@ export class TextHarnessBridge {
458
470
  ...this.#approvalTasks,
459
471
  ...this.#commandTasks,
460
472
  ]);
473
+ await this.#deferred.whenIdle();
461
474
  }
462
475
 
463
476
  async #processFastCommand(message, messageId, key, runner) {
@@ -480,6 +493,7 @@ export class TextHarnessBridge {
480
493
  pendingInteraction: this.#pendingInteractions.has(key)
481
494
  || this.#approvals.hasPending(key),
482
495
  control: { owner: this, key },
496
+ deferredDelivery: this.#deferred,
483
497
  },
484
498
  );
485
499
  if (result?.stopped) {
@@ -696,6 +710,7 @@ export class TextHarnessBridge {
696
710
  contextEnhanced = content !== originalContent;
697
711
  }
698
712
  const { answer, artifacts = [] } = await askInWorkspaceSession({
713
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
699
714
  harness: this.#harness,
700
715
  state: this.#state,
701
716
  key: conversationKey,
@@ -15,6 +15,7 @@ function workspaceSession(harness, sessionId) {
15
15
  isRunning: (...args) => harness.isSessionRunning(sessionId, ...args),
16
16
  hasActiveTurn: (...args) => harness.hasActiveTurn(sessionId, ...args),
17
17
  stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
18
+ stopDeferredTurn: (...args) => harness.stopDeferredTurn?.(sessionId, ...args) ?? false,
18
19
  steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
19
20
  ask: (...args) => harness.ask(sessionId, ...args),
20
21
  };
@@ -51,6 +52,7 @@ export async function askInWorkspaceSession({
51
52
  createOptions,
52
53
  existsOptions,
53
54
  askOptions,
55
+ deferredDelivery,
54
56
  }) {
55
57
  const initialTitle = contextEnhanced
56
58
  ? initialSessionTitle({
@@ -97,7 +99,20 @@ export async function askInWorkspaceSession({
97
99
  artifacts.push(artifact);
98
100
  await originalOnArtifact?.(artifact);
99
101
  };
100
- const answer = await binding.session.ask(content ?? text, artifactOptions);
102
+ let answer;
103
+ try {
104
+ answer = await binding.session.ask(content ?? text, artifactOptions);
105
+ } catch (error) {
106
+ if (error?.code === 'harness-reply-timeout' && deferredDelivery) {
107
+ try {
108
+ const { coordinator, ...destination } = deferredDelivery();
109
+ await coordinator.trackTimeout(error, { ...destination, key, sessionId: binding.sessionId });
110
+ } catch (registrationError) {
111
+ console.warn('[dsh-im] unable to persist deferred delivery:', registrationError.message);
112
+ }
113
+ }
114
+ throw error;
115
+ }
101
116
  return {
102
117
  sessionId: binding.sessionId,
103
118
  answer,
@@ -1,3 +1,4 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
1
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
3
  import { dirname } from 'node:path';
3
4
 
@@ -14,6 +15,7 @@ function normalizeState(value) {
14
15
  return {
15
16
  version: 1,
16
17
  sessions,
18
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
17
19
  seenMessageIds: Array.isArray(value.seenMessageIds)
18
20
  ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
19
21
  : [],
@@ -24,6 +26,7 @@ export class WecomStateStore {
24
26
  #path;
25
27
  #state = structuredClone(EMPTY_STATE);
26
28
  #writeQueue = Promise.resolve();
29
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
27
30
 
28
31
  constructor(path) {
29
32
  this.#path = path;
@@ -40,6 +43,11 @@ export class WecomStateStore {
40
43
  return this;
41
44
  }
42
45
 
46
+ deferredEntries() { return this.#deferred.entries(); }
47
+ putDeferred(entry) { return this.#deferred.put(entry); }
48
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
49
+ removeDeferred(id) { return this.#deferred.remove(id); }
50
+
43
51
  sessionFor(key) {
44
52
  return this.#state.sessions[key] ?? null;
45
53
  }
@@ -1,3 +1,4 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
1
2
  import { generateReqId } from '@wecom/aibot-node-sdk';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import {
@@ -357,15 +358,17 @@ function thinkingProgressText(update) {
357
358
  }
358
359
 
359
360
  function streamContent(thinkingText, answerText = '', { finish = false } = {}) {
361
+ const answer = String(answerText ?? '').trim();
362
+ // Progress is transient: the final frame must contain only the delivered text.
363
+ if (finish) return answer;
364
+
360
365
  const thinking = String(thinkingText ?? '')
361
366
  .replace(/<\/?think>/gi, '')
362
367
  .trim();
363
- const answer = String(answerText ?? '').trim();
364
368
  if (!thinking) return answer;
365
- const thinkBlock = finish || answer
366
- ? `<think>${thinking}</think>`
369
+ return answer
370
+ ? `<think>${thinking}</think>\n${answer}`
367
371
  : `<think>${thinking}`;
368
- return answer ? `${thinkBlock}\n${answer}` : thinkBlock;
369
372
  }
370
373
 
371
374
  function artifactFailureText(fileName, error) {
@@ -541,6 +544,7 @@ export class WecomHarnessBridge {
541
544
  #client;
542
545
  #harness;
543
546
  #state;
547
+ #deferred;
544
548
  #contextEnhancement;
545
549
  #accessPolicy;
546
550
  #status;
@@ -594,6 +598,9 @@ export class WecomHarnessBridge {
594
598
  this.#generateReqId = generateStreamId;
595
599
  this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
596
600
  this.#signal = signal;
601
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
602
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
603
+ });
597
604
  this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
598
605
  }
599
606
 
@@ -1122,6 +1129,11 @@ export class WecomHarnessBridge {
1122
1129
  return task;
1123
1130
  }
1124
1131
 
1132
+ async #deliverDeferredOutcome(entry, outcome) {
1133
+ await this.#sendActive(entry.target.chatId, deferredOutcomeText(outcome));
1134
+ return true;
1135
+ }
1136
+
1125
1137
  async waitForIdle() {
1126
1138
  await Promise.allSettled([
1127
1139
  ...this.#queues.values(),
@@ -1131,6 +1143,7 @@ export class WecomHarnessBridge {
1131
1143
  ...this.#approvalTasks,
1132
1144
  ...this.#commandTasks,
1133
1145
  ]);
1146
+ await this.#deferred.whenIdle();
1134
1147
  }
1135
1148
 
1136
1149
  async #processFastCommand(frame, messageId, chatId, key, message, runner) {
@@ -1147,6 +1160,7 @@ export class WecomHarnessBridge {
1147
1160
  pendingInteraction: this.#pendingInteractions.has(key)
1148
1161
  || this.#approvals.hasPending(key),
1149
1162
  control: { owner: this, key },
1163
+ deferredDelivery: this.#deferred,
1150
1164
  });
1151
1165
  if (result?.stopped) {
1152
1166
  await Promise.allSettled([
@@ -1337,6 +1351,7 @@ export class WecomHarnessBridge {
1337
1351
  await this.#state.markSeen(messageId);
1338
1352
  promptRecorded = true;
1339
1353
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1354
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: { chatId } }),
1340
1355
  harness: this.#harness,
1341
1356
  state: this.#state,
1342
1357
  key,
@@ -1,3 +1,5 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
2
+ import { createHash } from 'node:crypto';
1
3
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
4
  import { dirname } from 'node:path';
3
5
 
@@ -15,6 +17,7 @@ export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
15
17
  export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
16
18
  export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
17
19
  export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
20
+ const CONTEXT_TOKEN_LIMIT = 1_000;
18
21
 
19
22
  function nonEmptyString(value) {
20
23
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -25,6 +28,26 @@ function timestamp(value) {
25
28
  return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
26
29
  }
27
30
 
31
+ function sequence(value) {
32
+ const text = typeof value === 'string' ? value : Number.isSafeInteger(value) ? String(value) : '';
33
+ return /^\d+$/.test(text) ? BigInt(text).toString() : null;
34
+ }
35
+
36
+ function normalizeContextTokens(value) {
37
+ if (!value || typeof value !== 'object' || !/^[a-f0-9]{64}$/.test(value.credentialHash ?? '')) return undefined;
38
+ const users = Object.create(null);
39
+ for (const [userId, entry] of Object.entries(value.users ?? {}).slice(-CONTEXT_TOKEN_LIMIT)) {
40
+ if (!nonEmptyString(userId) || !nonEmptyString(entry?.token)) continue;
41
+ users[userId] = {
42
+ token: entry.token,
43
+ seq: sequence(entry.seq),
44
+ messageTimeMs: timestamp(entry.messageTimeMs),
45
+ receivedAt: timestamp(entry.receivedAt),
46
+ };
47
+ }
48
+ return { credentialHash: value.credentialHash, users };
49
+ }
50
+
28
51
  function providerMessageIds(value) {
29
52
  if (!Array.isArray(value)) return [];
30
53
  return [...new Set(value
@@ -72,14 +95,17 @@ function normalizeState(value) {
72
95
  }
73
96
  }
74
97
  }
98
+ const contextTokens = normalizeContextTokens(value.contextTokens);
75
99
  return {
76
100
  version: 1,
77
101
  sessions,
102
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
78
103
  seenMessageIds: Array.isArray(value.seenMessageIds)
79
104
  ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
80
105
  : [],
81
106
  getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
82
107
  recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
108
+ ...(contextTokens ? { contextTokens } : {}),
83
109
  };
84
110
  }
85
111
 
@@ -87,6 +113,7 @@ export class WeixinStateStore {
87
113
  #path;
88
114
  #state = structuredClone(EMPTY_STATE);
89
115
  #writeQueue = Promise.resolve();
116
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
90
117
 
91
118
  constructor(path) {
92
119
  this.#path = path;
@@ -103,6 +130,41 @@ export class WeixinStateStore {
103
130
  return this;
104
131
  }
105
132
 
133
+ deferredEntries() { return this.#deferred.entries(); }
134
+ putDeferred(entry) { return this.#deferred.put(entry); }
135
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
136
+ removeDeferred(id) { return this.#deferred.remove(id); }
137
+
138
+ // Context belongs to this bot login, not to a Harness session or workspace.
139
+ async bindContextTokens(token) {
140
+ const credentialHash = createHash('sha256').update(token).digest('hex');
141
+ if (this.#state.contextTokens?.credentialHash === credentialHash) return;
142
+ this.#state.contextTokens = { credentialHash, users: Object.create(null) };
143
+ await this.#persist();
144
+ }
145
+
146
+ contextTokenFor(userId) {
147
+ return this.#state.contextTokens?.users?.[userId]?.token ?? undefined;
148
+ }
149
+
150
+ async rememberContextToken({ userId, contextToken, seq, messageTimeMs }) {
151
+ if (!nonEmptyString(userId) || !nonEmptyString(contextToken) || !this.#state.contextTokens) return;
152
+ const users = this.#state.contextTokens.users;
153
+ const previous = users[userId];
154
+ const next = { token: contextToken, seq: sequence(seq), messageTimeMs: timestamp(messageTimeMs), receivedAt: Date.now() };
155
+ // Late/duplicate batches must not replace a newer conversation capability.
156
+ if (previous) {
157
+ if (previous.seq !== null && next.seq !== null && BigInt(next.seq) <= BigInt(previous.seq)) return;
158
+ if (previous.messageTimeMs !== null && next.messageTimeMs !== null
159
+ && next.messageTimeMs < previous.messageTimeMs) return;
160
+ }
161
+ delete users[userId];
162
+ users[userId] = next;
163
+ const keys = Object.keys(users);
164
+ if (keys.length > CONTEXT_TOKEN_LIMIT) delete users[keys[0]];
165
+ await this.#persist();
166
+ }
167
+
106
168
  sessionFor(key) {
107
169
  return this.#state.sessions[key] ?? null;
108
170
  }
@@ -201,7 +263,8 @@ export class WeixinStateStore {
201
263
  }
202
264
 
203
265
  snapshot() {
204
- return structuredClone(this.#state);
266
+ const { contextTokens, ...state } = this.#state;
267
+ return structuredClone(state);
205
268
  }
206
269
 
207
270
  async remove() {
@@ -1,3 +1,4 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
1
2
  import {
2
3
  DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
3
4
  extractWeixinFiles,
@@ -163,7 +164,7 @@ function createWeixinSendDiagnostic({
163
164
  ].join(' ');
164
165
  }
165
166
 
166
- function weixinSendError(error, details) {
167
+ export function weixinSendError(error, details) {
167
168
  const diagnostic = createWeixinSendDiagnostic({ ...details, error });
168
169
  const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
169
170
  const code = safeDiagnosticToken(error?.code);
@@ -176,7 +177,7 @@ function weixinSendError(error, details) {
176
177
  return wrapped;
177
178
  }
178
179
 
179
- function weixinSendFailureOptions(error) {
180
+ export function weixinSendFailureOptions(error, { proactive = false } = {}) {
180
181
  let current = error;
181
182
  for (let depth = 0; current && depth < 5; depth += 1) {
182
183
  const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
@@ -184,7 +185,12 @@ function weixinSendFailureOptions(error) {
184
185
  return {
185
186
  reason: 'weixin-send-failed',
186
187
  userMessage: [
187
- t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
188
+ proactive
189
+ ? t('微信主动消息发送失败。长轮询在线不代表消息可以发送。')
190
+ : t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
191
+ ...(String(current.providerCode) === '-2' ? [
192
+ t('iLink 拒绝发送,可能涉及会话有效期、发送额度或消息内容。可让接收者发一条消息后重试;不要通过反复发送心跳尝试续期。'),
193
+ ] : []),
188
194
  t('微信发送诊断:{diagnostic}', { diagnostic }),
189
195
  ].join('\n'),
190
196
  };
@@ -289,6 +295,7 @@ export class WeixinHarnessBridge {
289
295
  #ownerUserId;
290
296
  #harness;
291
297
  #state;
298
+ #deferred;
292
299
  #contextEnhancement;
293
300
  #accessPolicy;
294
301
  #status;
@@ -351,6 +358,9 @@ export class WeixinHarnessBridge {
351
358
  this.#maxMessageChars = maxMessageChars;
352
359
  this.#typingKeepaliveMs = typingKeepaliveMs;
353
360
  this.#signal = signal;
361
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
362
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
363
+ });
354
364
  this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
355
365
  }
356
366
 
@@ -390,6 +400,12 @@ export class WeixinHarnessBridge {
390
400
  }
391
401
  const key = conversationKey(sender);
392
402
  const contextToken = nonEmptyString(message?.context_token) ?? undefined;
403
+ void this.#state.rememberContextToken?.({
404
+ userId: sender,
405
+ contextToken,
406
+ seq: message.seq,
407
+ messageTimeMs: message.create_time_ms ?? weixinMessageTimestampMs(messageId),
408
+ }).catch((error) => this.#logger.warn?.('[dsh-weixin] failed to persist conversation context:', error));
393
409
  const runId = nonEmptyString(message?.run_id) ?? undefined;
394
410
  const pending = this.#pendingInteractions.get(key);
395
411
  const batchCommand = isBatchInputCommand(commandText);
@@ -642,6 +658,11 @@ export class WeixinHarnessBridge {
642
658
  return task;
643
659
  }
644
660
 
661
+ async #deliverDeferredOutcome(entry, outcome) {
662
+ await this.#send(entry.target.toUserId, deferredOutcomeText(outcome), undefined, undefined, { stopTyping: false });
663
+ return true;
664
+ }
665
+
645
666
  async waitForIdle() {
646
667
  await Promise.allSettled([
647
668
  ...this.#queues.values(),
@@ -652,9 +673,11 @@ export class WeixinHarnessBridge {
652
673
  ...this.#commandTasks,
653
674
  this.#typingTail,
654
675
  ]);
676
+ await this.#deferred.whenIdle();
655
677
  }
656
678
 
657
679
  async close() {
680
+ this.#deferred.close();
658
681
  this.#typingClosed = true;
659
682
  await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
660
683
  }
@@ -682,6 +705,7 @@ export class WeixinHarnessBridge {
682
705
  pendingInteraction: this.#pendingInteractions.has(key)
683
706
  || this.#approvals.hasPending(key),
684
707
  control: { owner: this, key },
708
+ deferredDelivery: this.#deferred,
685
709
  });
686
710
  if (result?.stopped) {
687
711
  await Promise.allSettled([
@@ -794,6 +818,7 @@ export class WeixinHarnessBridge {
794
818
  await this.#state.markSeen(messageId);
795
819
  promptRecorded = true;
796
820
  ({ answer, artifacts = [] } = await askInWorkspaceSession({
821
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: { toUserId: sender } }),
797
822
  harness: this.#harness,
798
823
  state: this.#state,
799
824
  key,
@@ -1225,8 +1250,9 @@ export class WeixinHarnessBridge {
1225
1250
  ).catch(() => undefined);
1226
1251
  }
1227
1252
 
1228
- async #send(toUserId, text, contextToken, runId) {
1229
- await this.#stopTyping();
1253
+ async #send(toUserId, text, contextToken, runId, { stopTyping = true } = {}) {
1254
+ if (stopTyping) await this.#stopTyping();
1255
+ contextToken ??= this.#state.contextTokenFor?.(toUserId);
1230
1256
  const providerMessageIds = [];
1231
1257
  const chunks = splitWeixinText(text, this.#maxMessageChars);
1232
1258
  for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
@@ -1,6 +1,11 @@
1
1
  import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
2
- import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
2
+ import {
3
+ createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
4
+ } from './weixin-bridge.mjs';
3
5
  import { providerMessageIdsFor } from '../shared/semantic/delivery.mjs';
6
+ import {
7
+ channelDeliveryFailure, clearLastMessageFailure, setLastMessageFailure,
8
+ } from '../shared/message-failure.mjs';
4
9
  import {
5
10
  connectionTestTarget,
6
11
  connectionTestTargetUnavailable,
@@ -175,6 +180,7 @@ export class WeixinRuntime {
175
180
  throw harnessHealthError(error);
176
181
  }
177
182
  this.#status.harnessReachable = true;
183
+ await this.#state.bindContextTokens?.(this.#token);
178
184
  await this.#notifyStart();
179
185
  this.#abortController = new AbortController();
180
186
  const signal = this.#abortController.signal;
@@ -335,13 +341,32 @@ export class WeixinRuntime {
335
341
 
336
342
  async #sendTrackedText({ toUserId, text, signal }) {
337
343
  const sentAt = Date.now();
338
- const result = await this.#api.sendText({
339
- baseUrl: this.#config.baseUrl,
340
- token: this.#token,
341
- toUserId,
342
- text,
343
- signal,
344
- });
344
+ const contextToken = this.#state.contextTokenFor?.(toUserId);
345
+ let result;
346
+ try {
347
+ result = await this.#api.sendText({
348
+ baseUrl: this.#config.baseUrl,
349
+ token: this.#token,
350
+ toUserId,
351
+ text,
352
+ contextToken,
353
+ signal,
354
+ });
355
+ } catch (cause) {
356
+ if (signal?.aborted) throw cause;
357
+ const error = weixinSendError(cause, {
358
+ baseUrl: this.#config.baseUrl, text, chunk: text, chunkIndex: 0, chunkCount: 1,
359
+ maxMessageChars: this.#maxMessageChars, contextToken,
360
+ });
361
+ const failure = setLastMessageFailure(this.#status,
362
+ channelDeliveryFailure(error, { uncertain: cause?.code !== 'send-rejected' }),
363
+ weixinSendFailureOptions(error, { proactive: true }));
364
+ this.#logger.warn?.(`[dsh-weixin] outbound delivery failed [${failure.referenceId}]: ${failure.message}`);
365
+ throw error;
366
+ }
367
+ if (this.#status.lastMessageError?.reason === 'WEIXIN_SEND_FAILED') {
368
+ clearLastMessageFailure(this.#status);
369
+ }
345
370
  try {
346
371
  await this.#state.rememberOutboundMessage?.({
347
372
  toUserId,