@xmanrui/dsh-im 4.35.0 → 4.36.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 (64) hide show
  1. package/PROACTIVE_DELIVERY.en.md +50 -0
  2. package/PROACTIVE_DELIVERY.md +22 -0
  3. package/README.en.md +3 -1
  4. package/README.md +3 -1
  5. package/lib/client.js +4 -3
  6. package/lib/index.js +311 -310
  7. package/package.json +3 -3
  8. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  9. package/plugin-src/host/channels/feishu/production.mjs +4 -2
  10. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  11. package/plugin-src/host/channels/qq/production.mjs +1 -1
  12. package/plugin-src/host/channels/shared/production.mjs +1 -1
  13. package/plugin-src/host/channels/slack/production.mjs +1 -1
  14. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  15. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  18. package/plugin-src/host/delivery-adapter.mjs +60 -12
  19. package/plugin-src/host/delivery-service.mjs +127 -1
  20. package/plugin-src/host/delivery-suggestions.mjs +2 -1
  21. package/plugin-src/host/harness-connection.mjs +3 -7
  22. package/plugin-src/host/index.mjs +5 -2
  23. package/plugin-src/host/modern-harness-api.mjs +139 -4
  24. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -0
  25. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  26. package/src/channels/dingtalk/dingtalk-runtime.mjs +10 -0
  27. package/src/channels/discord/discord-runtime.mjs +13 -0
  28. package/src/channels/feishu/bridge.mjs +30 -13
  29. package/src/channels/feishu/external-consumer.mjs +118 -0
  30. package/src/channels/feishu/feishu-runtime.mjs +72 -3
  31. package/src/channels/feishu/history-reader.mjs +92 -0
  32. package/src/channels/feishu/multi-bot-controller.mjs +130 -2
  33. package/src/channels/feishu/nearby-history.mjs +72 -0
  34. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  35. package/src/channels/matrix/matrix-controller.mjs +9 -0
  36. package/src/channels/matrix/matrix-runtime.mjs +14 -0
  37. package/src/channels/qq/qq-bridge.mjs +8 -0
  38. package/src/channels/qq/qq-controller.mjs +14 -0
  39. package/src/channels/qq/qq-runtime.mjs +10 -0
  40. package/src/channels/shared/deferred-delivery-coordinator.mjs +2 -1
  41. package/src/channels/shared/deferred-delivery.mjs +8 -5
  42. package/src/channels/shared/harness-approval.mjs +84 -7
  43. package/src/channels/shared/harness-client.mjs +152 -42
  44. package/src/channels/shared/i18n-en/shared-a.mjs +6 -2
  45. package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
  46. package/src/channels/shared/message-failure.mjs +6 -2
  47. package/src/channels/shared/text-harness-bridge.mjs +6 -0
  48. package/src/channels/shared/token-bot-controller.mjs +16 -0
  49. package/src/channels/slack/manifest.mjs +1 -0
  50. package/src/channels/slack/slack-api.mjs +7 -0
  51. package/src/channels/slack/slack-controller.mjs +14 -0
  52. package/src/channels/slack/slack-runtime.mjs +12 -0
  53. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  54. package/src/channels/wecom/wecom-bridge.mjs +6 -0
  55. package/src/channels/wecom/wecom-controller.mjs +14 -0
  56. package/src/channels/wecom/wecom-runtime.mjs +10 -0
  57. package/src/channels/wecom-app/wecom-app-bridge.mjs +6 -0
  58. package/src/channels/wecom-app/wecom-app-controller.mjs +14 -0
  59. package/src/channels/wecom-app/wecom-app-runtime.mjs +10 -0
  60. package/src/channels/weixin/weixin-bridge.mjs +6 -0
  61. package/src/channels/weixin/weixin-controller.mjs +14 -0
  62. package/src/channels/weixin/weixin-runtime.mjs +10 -0
  63. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  64. package/src/channels/whatsapp/whatsapp-runtime.mjs +10 -0
@@ -1,3 +1,4 @@
1
+ import { ExclusiveInboundConsumers, normalizeExternalText } from './external-consumer.mjs';
1
2
  import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { connectionTestMessage } from '../shared/connection-test.mjs';
@@ -93,6 +94,7 @@ function secretRefFor(botId) {
93
94
  function configuredBotFingerprint(config) {
94
95
  return JSON.stringify({
95
96
  id: config.id,
97
+ consumerMode: config.consumerMode ?? 'standalone',
96
98
  appId: config.appId,
97
99
  secretRef: config.secretRef,
98
100
  ownerOpenIds: config.ownerOpenIds,
@@ -113,6 +115,14 @@ function configuredBotFingerprint(config) {
113
115
  });
114
116
  }
115
117
 
118
+ // Account/ownership fences must not include settings that are hot-updated on
119
+ // the same runtime (step push, voice, slash panel or group presentation).
120
+ function externalAccountOwnership(config) {
121
+ return JSON.stringify({ id: config.id, consumerMode: config.consumerMode ?? 'standalone',
122
+ appId: config.appId, secretRef: config.secretRef, ownerOpenIds: config.ownerOpenIds,
123
+ domain: config.domain, botOpenId: config.botOpenId, deletionPending: config.deletionPending === true });
124
+ }
125
+
116
126
  function optionalNonEmptyString(value) {
117
127
  return typeof value === 'string' && value.trim() ? value.trim() : null;
118
128
  }
@@ -133,6 +143,7 @@ export class MultiBotDshFeishuController {
133
143
  #createBotId;
134
144
  #createRegistrationId;
135
145
  #runtimes = new Map();
146
+ #inboundConsumers = new ExclusiveInboundConsumers();
136
147
  #botErrors = new Map();
137
148
  #registrations = new Map();
138
149
  #activeAppUpdates = new Map();
@@ -594,7 +605,82 @@ export class MultiBotDshFeishuController {
594
605
  this.#assertOpen('capability-unavailable');
595
606
  return { version: 1, botId, channel: 'feishu', account,
596
607
  connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
597
- capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
608
+ capabilities: ['proactive-text-checked', 'proactive-receipt-checked', 'exclusive-text-consumer', 'reply-text-checked', 'history-text-checked', 'thread-history-text-checked'] };
609
+ });
610
+ }
611
+
612
+ async consumeInbound(botId, { expectedFingerprint, onEvent, signal } = {}) {
613
+ this.#assertOpen();
614
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
615
+ this.#assertOpen();
616
+ signal?.throwIfAborted();
617
+ const config = this.#requireBot(botId);
618
+ const account = await this.#deliveryAccount(config);
619
+ this.#assertOpen('capability-unavailable');
620
+ signal?.throwIfAborted();
621
+ if (account.fingerprint !== expectedFingerprint)
622
+ throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
623
+ const dispose = this.#inboundConsumers.register(botId, { fingerprint: expectedFingerprint, onEvent, signal });
624
+ try {
625
+ const saved = await this.#configStore.saveBot({ ...config, consumerMode: 'external-consumer' });
626
+ this.#assertOpen('capability-unavailable');
627
+ signal?.throwIfAborted();
628
+ const resolved = await this.#credentials.resolve(saved.secretRef);
629
+ this.#assertOpen('capability-unavailable');
630
+ signal?.throwIfAborted();
631
+ if (!resolved?.value) throw Object.assign(new Error('account-unverified'), { code: 'account-unverified' });
632
+ await this.#startRuntime(saved, resolved.value);
633
+ signal?.throwIfAborted();
634
+ this.#touch();
635
+ return dispose;
636
+ } catch (error) {
637
+ dispose();
638
+ throw error;
639
+ }
640
+ }));
641
+ }
642
+
643
+ async historyChecked(botId, route, query, { expectedFingerprint, signal } = {}) {
644
+ this.#assertOpen('capability-unavailable');
645
+ return this.#withBotTransition(botId, async () => {
646
+ this.#assertOpen('capability-unavailable');
647
+ signal?.throwIfAborted();
648
+ const config = this.#requireBot(botId);
649
+ const account = await this.#deliveryAccount(config);
650
+ this.#assertOpen('capability-unavailable');
651
+ signal?.throwIfAborted();
652
+ if (account.fingerprint !== expectedFingerprint)
653
+ throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
654
+ const runtime = this.#runtimes.get(botId);
655
+ if (config.consumerMode !== 'external-consumer' || !isConnected(connectionStatus(runtime))
656
+ || typeof runtime.historyChecked !== 'function')
657
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
658
+ const leaseSignal = this.#inboundConsumers.signalFor(botId, expectedFingerprint);
659
+ const readSignal = signal ? AbortSignal.any([signal, leaseSignal]) : leaseSignal;
660
+ const result = await runtime.historyChecked({ botId, appId: config.appId,
661
+ botOpenId: config.botOpenId, fingerprint: account.fingerprint }, route, query, { signal: readSignal });
662
+ this.#assertOpen('capability-unavailable');
663
+ readSignal.throwIfAborted();
664
+ return result;
665
+ });
666
+ }
667
+
668
+ async replyChecked(botId, route, text, { expectedFingerprint, signal } = {}) {
669
+ this.#assertOpen();
670
+ return this.#withBotTransition(botId, async () => {
671
+ this.#assertOpen();
672
+ const config = this.#requireBot(botId);
673
+ const account = await this.#deliveryAccount(config);
674
+ this.#assertOpen('capability-unavailable');
675
+ signal?.throwIfAborted();
676
+ if (account.fingerprint !== expectedFingerprint)
677
+ throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
678
+ signal?.throwIfAborted();
679
+ const runtime = this.#runtimes.get(botId);
680
+ if (config.consumerMode !== 'external-consumer' || !isConnected(connectionStatus(runtime))
681
+ || typeof runtime.replyChecked !== 'function')
682
+ throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
683
+ return runtime.replyChecked(route, text, { signal });
598
684
  });
599
685
  }
600
686
 
@@ -628,6 +714,18 @@ export class MultiBotDshFeishuController {
628
714
  });
629
715
  }
630
716
 
717
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
718
+ this.#assertOpen();
719
+ return this.#withBotTransition(botId, async () => {
720
+ this.#assertOpen();
721
+ this.#requireBot(botId);
722
+ const runtime = this.#runtimes.get(botId);
723
+ if (!isConnected(connectionStatus(runtime))
724
+ || typeof runtime.presentSessionSyncApproval !== 'function') return false;
725
+ return runtime.presentSessionSyncApproval(target, interaction, options);
726
+ });
727
+ }
728
+
631
729
  async disconnectBot(botId) {
632
730
  this.#assertOpen();
633
731
  // An operational pause only: credentials/config remain durable, so the
@@ -761,6 +859,7 @@ export class MultiBotDshFeishuController {
761
859
  async close() {
762
860
  if (this.#closed) return;
763
861
  this.#closed = true;
862
+ this.#inboundConsumers.close();
764
863
  const appUpdateProcessing = [];
765
864
  for (const record of this.#registrations.values()) {
766
865
  const state = record.manager.status().state;
@@ -1352,11 +1451,39 @@ export class MultiBotDshFeishuController {
1352
1451
 
1353
1452
  async #startRuntime(config, appSecret) {
1354
1453
  await this.#stopRuntime(config.id);
1355
- const runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
1454
+ let runtime;
1455
+ runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
1356
1456
  botId: config.id,
1357
1457
  config,
1358
1458
  appSecret,
1359
1459
  repair: this.#runtimeRepairCapability(config.id),
1460
+ acceptExternal: async (event, { signal } = {}) => {
1461
+ const assertCurrent = () => {
1462
+ this.#assertOpen('capability-unavailable');
1463
+ signal?.throwIfAborted();
1464
+ if (this.#runtimes.get(config.id) !== runtime)
1465
+ throw Object.assign(new Error('consumer-unavailable'), { code: 'consumer-unavailable' });
1466
+ const current = this.#requireBot(config.id);
1467
+ if (current.consumerMode !== 'external-consumer'
1468
+ || externalAccountOwnership(current) !== externalAccountOwnership(config))
1469
+ throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
1470
+ return current;
1471
+ };
1472
+ const evidence = await this.#withBotTransition(config.id, async () => {
1473
+ const current = assertCurrent();
1474
+ const account = await this.#deliveryAccount(current);
1475
+ assertCurrent();
1476
+ return normalizeExternalText(event, { botId: current.id, appId: current.appId,
1477
+ botOpenId: current.botOpenId, fingerprint: account.fingerprint });
1478
+ });
1479
+ // Never hold the account queue across application work: onEvent may
1480
+ // await a checked read/reply, and stop must be able to abort its signal.
1481
+ assertCurrent();
1482
+ if (evidence === null) return { accepted: true, ignored: true };
1483
+ const result = await this.#inboundConsumers.accept(config.id, evidence, signal);
1484
+ assertCurrent();
1485
+ return result;
1486
+ },
1360
1487
  }));
1361
1488
  this.#runtimes.set(config.id, runtime);
1362
1489
  try {
@@ -1398,6 +1525,7 @@ export class MultiBotDshFeishuController {
1398
1525
  async #deleteBot(botId) {
1399
1526
  let config = this.#configStore.getBot(botId);
1400
1527
  if (!config) return;
1528
+ this.#inboundConsumers.remove(botId);
1401
1529
  if (!config.deletionPending) {
1402
1530
  config = await this.#configStore.saveBot({ ...config, deletionPending: true });
1403
1531
  }
@@ -0,0 +1,72 @@
1
+ function refuse(code) { throw Object.assign(new Error(code), { code }); }
2
+ const count = value => Number.isInteger(value) && value >= 0 && value <= 20;
3
+
4
+ export async function readNearbyHistory(readPage, route, query, anchorTime) {
5
+ const before = query.beforeCount ?? 10;
6
+ const after = query.afterCount ?? 5;
7
+ if (!count(before) || !count(after)) refuse('bad-request');
8
+ const start = Math.max(0, anchorTime - 300000);
9
+ const end = anchorTime + 300000;
10
+ const window = { start: Math.floor(start / 1000), end: Math.floor(end / 1000) + 1 };
11
+ let state = { phase: 'window', before: 0, after: 0 };
12
+ if (query.cursor !== undefined) {
13
+ try {
14
+ const decoded = JSON.parse(Buffer.from(query.cursor, 'base64url').toString());
15
+ if (decoded.version !== 1 || decoded.anchor !== route.messageId || decoded.at !== anchorTime
16
+ || decoded.minBefore !== before || decoded.minAfter !== after || decoded.limit !== query.limit
17
+ || !['window', 'before', 'after'].includes(decoded.phase)
18
+ || !count(decoded.before) || !count(decoded.after)
19
+ || (decoded.page !== undefined && (typeof decoded.page !== 'string' || !decoded.page || decoded.page.length > 3000)))
20
+ refuse('bad-request');
21
+ state = decoded;
22
+ } catch { refuse('bad-request'); }
23
+ }
24
+ const params = { container_id_type: 'chat', container_id: route.conversationId,
25
+ page_size: query.limit, with_sender_name: true,
26
+ sort_type: state.phase === 'before' ? 'ByCreateTimeDesc' : 'ByCreateTimeAsc',
27
+ ...(state.page === undefined ? {} : { page_token: state.page }),
28
+ ...(state.phase === 'window' ? { start_time: String(window.start), end_time: String(window.end) }
29
+ : state.phase === 'before' ? { end_time: String(window.start + 1) }
30
+ : { start_time: String(Math.floor(end / 1000)) }),
31
+ };
32
+ // Lark defaults an omitted end_time to now and rejects a future start_time.
33
+ // A fresh anchor has no existing messages beyond its future window boundary.
34
+ const page = state.phase === 'after' && end >= Date.now()
35
+ ? { events: [], omitted: 0, hasMore: false }
36
+ : await readPage(params);
37
+ const events = [];
38
+ for (const event of page.events) {
39
+ const at = Date.parse(event.at);
40
+ if (!Number.isFinite(at)) refuse('history-unavailable');
41
+ if (state.phase === 'window') {
42
+ if (at < start || at > end) continue;
43
+ events.push(event);
44
+ if (event.messageId !== route.messageId && at < anchorTime) state.before = Math.min(20, state.before + 1);
45
+ if (event.messageId !== route.messageId && at > anchorTime) state.after = Math.min(20, state.after + 1);
46
+ } else if (state.phase === 'before' && at < start && state.before < before) {
47
+ events.push(event); state.before++;
48
+ } else if (state.phase === 'after' && at > end && state.after < after) {
49
+ events.push(event); state.after++;
50
+ }
51
+ }
52
+ const needsPage = page.hasMore && (state.phase === 'window'
53
+ || (state.phase === 'before' ? state.before < before : state.after < after));
54
+ if (needsPage) {
55
+ if (page.nextCursor === state.page) refuse('history-unavailable');
56
+ state.page = page.nextCursor;
57
+ } else {
58
+ delete state.page;
59
+ if (state.phase === 'window' && state.before < before) state.phase = 'before';
60
+ else if (state.phase !== 'after' && state.after < after) state.phase = 'after';
61
+ else state.phase = 'done';
62
+ }
63
+ let nextCursor;
64
+ if (state.phase !== 'done') {
65
+ nextCursor = Buffer.from(JSON.stringify({version:1, anchor:route.messageId, at:anchorTime,
66
+ minBefore:before, minAfter:after, limit:query.limit, phase:state.phase,
67
+ before:state.before, after:state.after, ...(state.page ? {page:state.page} : {})})).toString('base64url');
68
+ if (nextCursor.length > 4096) refuse('history-unavailable');
69
+ }
70
+ return {version:1, scope:'nearby', events, omitted:page.omitted, hasMore:!!nextCursor,
71
+ ...(nextCursor ? {nextCursor} : {}), window, coverage:'provider-visible-human-text'};
72
+ }
@@ -40,6 +40,7 @@ function normalizeBot(value, { legacy = false } = {}) {
40
40
  const domain = value.domain === 'lark' ? 'lark' : 'feishu';
41
41
  return Object.freeze({
42
42
  id,
43
+ consumerMode: value.consumerMode === 'external-consumer' ? 'external-consumer' : 'standalone',
43
44
  appId,
44
45
  secretRef,
45
46
  ownerOpenIds: Object.freeze(ownerOpenIds),
@@ -224,6 +224,15 @@ export class MatrixController {
224
224
  return { sent: true };
225
225
  }
226
226
 
227
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
228
+ this.#requireId(botId);
229
+ const runtime = this.#runtimes.get(botId);
230
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
231
+ throw new Error('Matrix bot is not connected for proactive delivery');
232
+ }
233
+ return runtime.presentSessionSyncApproval(target, interaction, options);
234
+ }
235
+
227
236
  async deleteBot(botId) {
228
237
  this.#requireId(botId);
229
238
  const removed = await this.#withBotTransition(botId, async () => {
@@ -173,6 +173,7 @@ export class MatrixRuntime {
173
173
  #replyTimeoutMs;
174
174
  #api = null;
175
175
  #bridge = null;
176
+ #approvalController = new AbortController();
176
177
  #botUserId = null;
177
178
  #deviceId = null;
178
179
  #crypto = null;
@@ -289,6 +290,7 @@ export class MatrixRuntime {
289
290
  async start() {
290
291
  if (this.#started && this.#status.ready) return;
291
292
  this.#stopped = false;
293
+ this.#approvalController = new AbortController();
292
294
  this.#started = true;
293
295
  const generation = ++this.#generation;
294
296
  const homeserver = this.#config.homeserver;
@@ -361,6 +363,7 @@ export class MatrixRuntime {
361
363
  async stop() {
362
364
  if (this.#stopped) return;
363
365
  this.#stopped = true;
366
+ this.#approvalController.abort();
364
367
  this.#started = false;
365
368
  this.#generation += 1;
366
369
  if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
@@ -389,6 +392,17 @@ export class MatrixRuntime {
389
392
  return await bridge.sendConnectionTest(text);
390
393
  }
391
394
 
395
+ async presentSessionSyncApproval(target, interaction, options = {}) {
396
+ if (!this.#status.ready || !this.#bridge) return false;
397
+ const actor = typeof target?.route?.userId === 'string' ? target.route.userId.trim() : '';
398
+ if (!actor) return false;
399
+ return this.#bridge.presentSessionSyncApproval(interaction, {
400
+ key: options.key.startsWith('dm:') ? `direct:${options.key}` : options.key,
401
+ actor, validate: options.validate,
402
+ send: (text) => this.sendProactiveText(target, text),
403
+ }, { ...options, runtimeSignal: this.#approvalController.signal });
404
+ }
405
+
392
406
  async sendProactiveText(target, text, options = {}) {
393
407
  const api = this.#api;
394
408
  if (!api) throw new Error('Matrix bot is not connected');
@@ -488,6 +488,7 @@ export class QqHarnessBridge {
488
488
  this.#replyTimeoutMs = replyTimeoutMs;
489
489
  this.#signal = signal;
490
490
  this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
491
+ finalAnswerOnly: true,
491
492
  deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
492
493
  });
493
494
  this.#fetchImpl = fetchImpl;
@@ -1085,6 +1086,7 @@ export class QqHarnessBridge {
1085
1086
  signal: this.#signal,
1086
1087
  control: { owner: this, key },
1087
1088
  progressMode: 'all',
1089
+ finalAnswerOnly: true,
1088
1090
  onUpdate: (update) => {
1089
1091
  if (update.error) {
1090
1092
  const name = (nonEmptyString(update.toolName) ?? t('工具'))
@@ -1335,6 +1337,12 @@ export class QqHarnessBridge {
1335
1337
  }
1336
1338
  }
1337
1339
 
1340
+ async presentSessionSyncApproval(interaction, context, options = {}) {
1341
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
1342
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
1343
+ });
1344
+ }
1345
+
1338
1346
  async #handleInteraction(interaction, {
1339
1347
  key,
1340
1348
  actor,
@@ -297,6 +297,20 @@ export class QqController {
297
297
  });
298
298
  }
299
299
 
300
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
301
+ const config = this.#configStore.get(botId);
302
+ if (!config) throw new Error('Unknown QQ bot');
303
+ return this.#withBotTransition(botId, async () => {
304
+ const runtime = this.#runtimes.get(botId);
305
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
306
+ const error = new Error(t('QQ机器人尚未连接'));
307
+ error.code = 'bot-not-connected';
308
+ throw error;
309
+ }
310
+ return runtime.presentSessionSyncApproval(target, interaction, options);
311
+ });
312
+ }
313
+
300
314
  async deleteBot(botId) {
301
315
  const warnings = [];
302
316
  const config = this.#configStore.get(botId);
@@ -104,6 +104,16 @@ export class QqRuntime {
104
104
  return { sent: true };
105
105
  }
106
106
 
107
+ async presentSessionSyncApproval(target, interaction, options = {}) {
108
+ if (!this.#status.ready || !this.#bridge) return false;
109
+ const actor = typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '';
110
+ if (!actor) return false;
111
+ return this.#bridge.presentSessionSyncApproval(interaction, {
112
+ key: options.key, actor, validate: options.validate,
113
+ send: (text) => this.sendProactiveText(target, text),
114
+ }, options);
115
+ }
116
+
107
117
  async sendProactiveText(target, text, { signal } = {}) {
108
118
  const nativeId = target?.kind === 'user'
109
119
  ? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
@@ -24,6 +24,7 @@ function orderedEvents(history) {
24
24
  export function createDeferredDeliveryCoordinator({
25
25
  harness, state, deliver, signal, logger = console,
26
26
  retryDelayMs = 1_000, pollDelayMs = 30_000, watch = true,
27
+ finalAnswerOnly = false,
27
28
  } = {}) {
28
29
  const enabled = ['deferredEntries', 'putDeferred', 'patchDeferred', 'removeDeferred']
29
30
  .every((name) => typeof state?.[name] === 'function');
@@ -100,7 +101,7 @@ export function createDeferredDeliveryCoordinator({
100
101
  }
101
102
  const outcome = targetTurn === null
102
103
  ? { found: false, endSeq: -1, turn: null }
103
- : extractCompletedTurnAnswer(ordered, { turn: targetTurn });
104
+ : extractCompletedTurnAnswer(ordered, { turn: targetTurn, finalAnswerOnly });
104
105
  const hasStart = ordered.some((event) => event.type === 'turn/start' && event.data?.turn === targetTurn);
105
106
  if (!history.hasMore || hasStart) return { ...outcome, turn: targetTurn };
106
107
  const oldest = batch[0]?.seq;
@@ -1,4 +1,4 @@
1
- import { AssistantTextAccumulator } from './harness-client.mjs';
1
+ import { AssistantTextAccumulator, accumulateFinalAssistantText } from './harness-client.mjs';
2
2
 
3
3
  export const MAX_DEFERRED_PER_KEY = 4;
4
4
 
@@ -35,12 +35,13 @@ function textFromContent(content) {
35
35
  *
36
36
  * Reuses HarnessReplyTracker text accumulation: chunk deltas accumulate per
37
37
  * (step, part index); a canonical assistant/message replaces its step; steps
38
- * join with a blank line. `turn` omitted → prefer the latest completed answer.
38
+ * join with a blank line. finalAnswerOnly discards text before tool calls.
39
+ * `turn` omitted → prefer the latest completed answer.
39
40
  * Returns `{ found, turn, text, reason, endSeq }`; `found: false` when the
40
41
  * turn ended without a completed final answer (stopped/failed/empty/
41
42
  * interrupted) — `reason`/`endSeq` still identify the confirmed terminal.
42
43
  */
43
- export function extractCompletedTurnAnswer(entries, { turn } = {}) {
44
+ export function extractCompletedTurnAnswer(entries, { turn, finalAnswerOnly = false } = {}) {
44
45
  const events = orderedEvents(entries);
45
46
  const wantTurn = Number.isSafeInteger(turn) ? turn : null;
46
47
  function turnAnswer(turnEnd) {
@@ -48,12 +49,14 @@ export function extractCompletedTurnAnswer(entries, { turn } = {}) {
48
49
  let interrupted = false;
49
50
  for (const event of events) {
50
51
  if (event.seq > turnEnd.seq || event.data?.turn !== turnEnd.data.turn) continue;
51
- if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
52
+ if (finalAnswerOnly === true) {
53
+ accumulateFinalAssistantText(accumulator, event);
54
+ } else if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
52
55
  accumulator.appendDelta(event.data.step, event.data.chunk.index, event.data.chunk.text);
53
56
  } else if (event.type === 'assistant/message') {
54
57
  accumulator.setCanonical(event.data.step, textFromContent(event.data.message?.content));
55
- interrupted = event.data.interrupted === true;
56
58
  }
59
+ if (event.type === 'assistant/message') interrupted = event.data.interrupted === true;
57
60
  }
58
61
  return { text: accumulator.text, interrupted };
59
62
  }
@@ -1,4 +1,5 @@
1
1
  import { t } from './i18n.mjs';
2
+ import { evaluateInboundAccess } from './inbound-access.mjs';
2
3
 
3
4
  const APPROVAL_REPLIES = new Map([
4
5
  ['批准', 'allowed-once'],
@@ -9,6 +10,12 @@ const APPROVAL_REPLIES = new Map([
9
10
  ['no', 'rejected'],
10
11
  ]);
11
12
 
13
+ export const COMPETITIVE_APPROVAL_CHANNELS = Object.freeze([
14
+ 'feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
15
+ 'telegram', 'slack', 'discord', 'whatsapp', 'matrix',
16
+ ]);
17
+
18
+ const APPROVAL_UNAVAILABLE_TEXT = '机器人端无法处理这次审批,请到 Web 查看。';
12
19
  const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
13
20
  const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
14
21
  const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
@@ -95,6 +102,7 @@ function approvalResult(pending, outcome) {
95
102
  }
96
103
 
97
104
  function approvalOutcomeText(outcome) {
105
+ if (outcome === 'unavailable') return t(APPROVAL_UNAVAILABLE_TEXT);
98
106
  if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
99
107
  if (outcome === 'rejected') return t('已拒绝此次操作。');
100
108
  return t(APPROVAL_RESOLVED_TEXT);
@@ -210,6 +218,51 @@ export class HarnessApprovalQueue {
210
218
  };
211
219
  }
212
220
 
221
+ /** Present a bound private-chat approval without creating an IM prompt. */
222
+ async handleSessionSyncRequested(interaction, context, { signal, completion, runtimeSignal, accessPolicy } = {}) {
223
+ if (signal?.aborted || runtimeSignal?.aborted) return false;
224
+ const validateRoute = context.validate;
225
+ context = { ...context, validate: async () => {
226
+ await validateRoute?.();
227
+ if (!context.actor || !evaluateInboundAccess(accessPolicy, {
228
+ conversationType: 'direct', senderIds: [context.actor], text: 'yes',
229
+ }).allowed) throw new Error('Approval recipient is no longer allowed');
230
+ } };
231
+ await context.validate();
232
+ if (signal?.aborted || runtimeSignal?.aborted) return false;
233
+ let resolved = false;
234
+ const finish = (outcome) => this.handleResolved({
235
+ kind: 'approval', interactionId: interaction.interactionId, outcome,
236
+ });
237
+ const onStop = () => {
238
+ queueMicrotask(async () => {
239
+ if (resolved) return;
240
+ try {
241
+ await interaction.withdraw?.();
242
+ await finish('unavailable');
243
+ } catch (error) {
244
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to withdraw a stopped approval:`, error);
245
+ }
246
+ });
247
+ };
248
+ runtimeSignal?.addEventListener('abort', onStop, { once: true });
249
+ Promise.resolve(completion).then(async (outcome) => {
250
+ resolved = true;
251
+ runtimeSignal?.removeEventListener('abort', onStop);
252
+ await finish(outcome);
253
+ }).catch((error) => this.#logger.warn?.(`[dsh-im:${this.#label}] failed to retire a synced approval:`, error));
254
+ try {
255
+ await this.handleRequested(interaction, context);
256
+ if (runtimeSignal?.aborted) onStop();
257
+ return this.#byId.has(interaction.interactionId);
258
+ } catch (error) {
259
+ runtimeSignal?.removeEventListener('abort', onStop);
260
+ await interaction.withdraw?.();
261
+ await finish('unavailable');
262
+ throw error;
263
+ }
264
+ }
265
+
213
266
  async handleRequested(interaction, context) {
214
267
  if (interaction?.kind !== 'approval') return false;
215
268
  const payload = interaction.payload;
@@ -257,7 +310,7 @@ export class HarnessApprovalQueue {
257
310
  if (!text) {
258
311
  const rejected = await this.#rejectInteraction(interaction, payload);
259
312
  await send(rejected
260
- ? t('无法完整展示这次操作,已安全拒绝此次审批。')
313
+ ? (interaction.withdraw ? t(APPROVAL_UNAVAILABLE_TEXT) : t('无法完整展示这次操作,已安全拒绝此次审批。'))
261
314
  : t(APPROVAL_RESOLVED_TEXT));
262
315
  return true;
263
316
  }
@@ -272,6 +325,7 @@ export class HarnessApprovalQueue {
272
325
  requiresMention: context.requiresMention === true,
273
326
  send,
274
327
  render,
328
+ validate: context.validate,
275
329
  onResolved: typeof context.onResolved === 'function' ? context.onResolved : null,
276
330
  text,
277
331
  presented: false,
@@ -342,13 +396,14 @@ export class HarnessApprovalQueue {
342
396
  for (const pending of pendingItems) this.#remove(pending);
343
397
  await Promise.all(pendingItems.map(async (pending) => {
344
398
  try {
345
- await pending.interaction.respond(
399
+ if (pending.interaction.withdraw) await pending.interaction.withdraw();
400
+ else await pending.interaction.respond(
346
401
  approvalResult(pending, 'rejected'),
347
402
  { signal: AbortSignal.timeout(5_000) },
348
403
  );
349
- pending.closedOutcome = 'rejected';
404
+ pending.closedOutcome = pending.interaction.withdraw ? 'unavailable' : 'rejected';
350
405
  if (pending.presented || pending.deliveryCompleted) {
351
- await this.#notifyResolved(pending, 'rejected');
406
+ await this.#notifyResolved(pending, pending.closedOutcome);
352
407
  }
353
408
  } catch (error) {
354
409
  if (error?.code === 'interaction-not-pending') {
@@ -372,9 +427,12 @@ export class HarnessApprovalQueue {
372
427
  if (pending.presentationTask) return pending.presentationTask;
373
428
  // A channel-provided renderer shows the approval as an interactive card
374
429
  // (e.g. approve/reject buttons); otherwise fall back to plain text.
375
- const task = pending.render
376
- ? Promise.resolve().then(() => pending.render(pending, pending.send))
377
- : Promise.resolve().then(() => pending.send(pending.text));
430
+ const task = Promise.resolve().then(async () => {
431
+ await pending.validate?.();
432
+ if (pending.inactive || pending.resolving) return;
433
+ if (pending.render) await pending.render(pending, pending.send);
434
+ else await pending.send(pending.text);
435
+ });
378
436
  pending.presentationTask = task;
379
437
  try {
380
438
  await task;
@@ -384,6 +442,12 @@ export class HarnessApprovalQueue {
384
442
  } else if (pending.closedOutcome) {
385
443
  await this.#notifyResolved(pending, pending.closedOutcome);
386
444
  }
445
+ } catch (error) {
446
+ if (!pending.interaction.withdraw) throw error;
447
+ pending.presentationTask = null;
448
+ await pending.interaction.withdraw();
449
+ await this.handleResolved({ kind: 'approval', interactionId: pending.approvalId, outcome: 'unavailable' });
450
+ this.#logger.warn?.(`[dsh-im:${this.#label}] approval presentation unavailable:`, error);
387
451
  } finally {
388
452
  if (pending.presentationTask === task) pending.presentationTask = null;
389
453
  }
@@ -392,6 +456,15 @@ export class HarnessApprovalQueue {
392
456
  async #submit(pending, outcome) {
393
457
  pending.submitting = true;
394
458
  try {
459
+ if (pending.validate) {
460
+ try { await pending.validate(); }
461
+ catch (error) {
462
+ await pending.interaction.withdraw?.();
463
+ throw Object.assign(new Error('Approval route is no longer available', { cause: error }), {
464
+ code: 'interaction-not-pending',
465
+ });
466
+ }
467
+ }
395
468
  await pending.interaction.respond(approvalResult(pending, outcome));
396
469
  } catch (error) {
397
470
  if (error?.code === 'interaction-not-pending') {
@@ -491,6 +564,10 @@ export class HarnessApprovalQueue {
491
564
 
492
565
  async #rejectInteraction(interaction, payload) {
493
566
  try {
567
+ if (interaction.withdraw) {
568
+ await interaction.withdraw();
569
+ return true;
570
+ }
494
571
  await interaction.respond({
495
572
  ok: true,
496
573
  value: {