@xmanrui/dsh-im 4.35.1 → 4.36.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 (65) 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 +30 -8
  6. package/lib/index.js +309 -309
  7. package/package.json +3 -3
  8. package/plugin-src/client/connection-error.js +2 -1
  9. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  10. package/plugin-src/host/channels/feishu/production.mjs +4 -2
  11. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  12. package/plugin-src/host/channels/qq/production.mjs +1 -1
  13. package/plugin-src/host/channels/shared/production.mjs +1 -1
  14. package/plugin-src/host/channels/slack/production.mjs +1 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  16. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  17. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  18. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  19. package/plugin-src/host/delivery-adapter.mjs +60 -12
  20. package/plugin-src/host/delivery-service.mjs +127 -1
  21. package/plugin-src/host/delivery-suggestions.mjs +2 -1
  22. package/plugin-src/host/harness-connection.mjs +3 -7
  23. package/plugin-src/host/index.mjs +5 -2
  24. package/plugin-src/host/modern-harness-api.mjs +139 -4
  25. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -0
  26. package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
  27. package/src/channels/dingtalk/dingtalk-runtime.mjs +10 -0
  28. package/src/channels/discord/discord-runtime.mjs +13 -0
  29. package/src/channels/feishu/bridge.mjs +30 -13
  30. package/src/channels/feishu/external-consumer.mjs +118 -0
  31. package/src/channels/feishu/feishu-runtime.mjs +72 -3
  32. package/src/channels/feishu/history-reader.mjs +92 -0
  33. package/src/channels/feishu/multi-bot-controller.mjs +130 -2
  34. package/src/channels/feishu/nearby-history.mjs +72 -0
  35. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  36. package/src/channels/matrix/matrix-controller.mjs +9 -0
  37. package/src/channels/matrix/matrix-runtime.mjs +14 -0
  38. package/src/channels/qq/qq-bridge.mjs +6 -0
  39. package/src/channels/qq/qq-controller.mjs +14 -0
  40. package/src/channels/qq/qq-runtime.mjs +10 -0
  41. package/src/channels/shared/connection-error.mjs +1 -0
  42. package/src/channels/shared/diagnostic-details.mjs +7 -1
  43. package/src/channels/shared/harness-approval.mjs +84 -7
  44. package/src/channels/shared/harness-client.mjs +51 -40
  45. package/src/channels/shared/i18n-en/diagnostics.mjs +3 -0
  46. package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
  47. package/src/channels/shared/message-failure.mjs +1 -1
  48. package/src/channels/shared/text-harness-bridge.mjs +6 -0
  49. package/src/channels/shared/token-bot-controller.mjs +16 -0
  50. package/src/channels/slack/manifest.mjs +1 -0
  51. package/src/channels/slack/slack-api.mjs +7 -0
  52. package/src/channels/slack/slack-controller.mjs +14 -0
  53. package/src/channels/slack/slack-runtime.mjs +12 -0
  54. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  55. package/src/channels/wecom/wecom-bridge.mjs +6 -0
  56. package/src/channels/wecom/wecom-controller.mjs +14 -0
  57. package/src/channels/wecom/wecom-runtime.mjs +10 -0
  58. package/src/channels/wecom-app/wecom-app-bridge.mjs +6 -0
  59. package/src/channels/wecom-app/wecom-app-controller.mjs +14 -0
  60. package/src/channels/wecom-app/wecom-app-runtime.mjs +10 -0
  61. package/src/channels/weixin/weixin-bridge.mjs +6 -0
  62. package/src/channels/weixin/weixin-controller.mjs +14 -0
  63. package/src/channels/weixin/weixin-runtime.mjs +10 -0
  64. package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
  65. package/src/channels/whatsapp/whatsapp-runtime.mjs +10 -0
@@ -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: {
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { isAbsolute } from 'node:path';
5
+ import { performance } from 'node:perf_hooks';
5
6
 
6
7
  import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
7
8
  import { sameWorkspacePath } from './default-workspace.mjs';
@@ -504,33 +505,9 @@ function consumeInteractionOwnership(ownership, entries) {
504
505
  ownership.completed = true;
505
506
  continue;
506
507
  }
507
- let toolCall = null;
508
- if (event.type === 'tool/call'
509
- && ownership.active
510
- && event.data?.turn === ownership.turn
511
- && typeof event.data?.callId === 'string'
512
- && event.data.callId) {
513
- toolCall = {
514
- callId: event.data.callId,
515
- name: event.data?.name,
516
- arguments: event.data?.arguments,
517
- };
518
- } else if (event.type === 'tool/code-dispatch-start'
519
- && ownership.active
520
- && typeof event.data?.subCallId === 'string'
521
- && event.data.subCallId) {
522
- let argumentsText;
523
- try {
524
- argumentsText = JSON.stringify(event.data?.arguments);
525
- } catch {
526
- argumentsText = undefined;
527
- }
528
- toolCall = {
529
- callId: event.data.subCallId,
530
- name: event.data?.name,
531
- arguments: argumentsText,
532
- };
533
- }
508
+ const toolCall = ownership.active
509
+ && (event.type !== 'tool/call' || event.data?.turn === ownership.turn)
510
+ ? harnessToolCallFromEvent(event) : null;
534
511
  if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
535
512
  }
536
513
  }
@@ -540,17 +517,36 @@ function consumeInteractionOwnership(ownership, entries) {
540
517
  * this Session. The modern Harness adapter uses the same ownership registry
541
518
  * as HarnessClient so it never steals a browser-owned question or approval.
542
519
  */
543
- export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
520
+ export function activeHarnessInteractionOwner(scope, sessionId, entries = []) {
544
521
  if (!scope || !['object', 'function', 'string'].includes(typeof scope)
545
- || typeof sessionId !== 'string' || !sessionId) return false;
522
+ || typeof sessionId !== 'string' || !sessionId) return null;
546
523
  const owners = interactionRegistry(scope).ownerships.get(sessionId);
547
- if (!owners || owners.size === 0) return false;
524
+ if (!owners || owners.size === 0) return null;
548
525
  for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
549
- return [...owners].some((ownership) => (
526
+ return [...owners].filter((ownership) => (
550
527
  ownership.active
551
528
  && !ownership.completed
552
529
  && typeof ownership.reconnect === 'function'
553
- ));
530
+ )).sort((left, right) => left.order - right.order)[0] ?? null;
531
+ }
532
+
533
+ /** Whether a live IM consumer owns this turn. */
534
+ export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
535
+ return activeHarnessInteractionOwner(scope, sessionId, entries) !== null;
536
+ }
537
+
538
+ /** Read the operation attached to a normal or PTC tool-call event. */
539
+ export function harnessToolCallFromEvent(event) {
540
+ const data = event?.data;
541
+ if (event?.type === 'tool/call' && typeof data?.callId === 'string') {
542
+ return { callId: data.callId, name: data.name, arguments: data.arguments };
543
+ }
544
+ if (event?.type === 'tool/code-dispatch-start' && typeof data?.subCallId === 'string') {
545
+ let argumentsText;
546
+ try { argumentsText = JSON.stringify(data.arguments); } catch { /* Unserializable details cannot be approved. */ }
547
+ return { callId: data.subCallId, name: data.name, arguments: argumentsText };
548
+ }
549
+ return null;
554
550
  }
555
551
 
556
552
  export class HarnessReplyTracker {
@@ -934,6 +930,7 @@ export class HarnessClient {
934
930
  #managedProcess = null;
935
931
  #interactionRegistry;
936
932
  #interactionOwnerships;
933
+ #competitiveApprovals;
937
934
  #interactionClaims;
938
935
  #controlOwnerships;
939
936
 
@@ -941,6 +938,7 @@ export class HarnessClient {
941
938
  baseUrl,
942
939
  apiProxy,
943
940
  interactionScope = apiProxy,
941
+ competitiveApprovals = false,
944
942
  workspace,
945
943
  ungroupedWorkspace,
946
944
  agentPreset,
@@ -991,6 +989,7 @@ export class HarnessClient {
991
989
  || !['object', 'function'].includes(typeof interactionScope))) {
992
990
  throw new TypeError('interactionScope must identify the current Host');
993
991
  }
992
+ this.#competitiveApprovals = competitiveApprovals === true;
994
993
  this.#workspace = workspace;
995
994
  // Only the reserved IM directory bypasses grouping, even after /ws switches.
996
995
  this.#ungroupedWorkspace = ungroupedWorkspace;
@@ -1015,6 +1014,12 @@ export class HarnessClient {
1015
1014
  }
1016
1015
 
1017
1016
  async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
1017
+ const startedAt = performance.now();
1018
+ const withDiagnostics = error => Object.assign(error, {
1019
+ durationMs: Math.round(performance.now() - startedAt),
1020
+ timeoutMs,
1021
+ transport: this.#apiProxy ? 'host-api' : 'http',
1022
+ });
1018
1023
  const rpcId = options.rpcId ?? `${this.#rpcIdPrefix}-${randomUUID()}`;
1019
1024
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1020
1025
  const signal = options.signal
@@ -1053,24 +1058,24 @@ export class HarnessClient {
1053
1058
  // Preserve an explicit caller cancellation; it is control flow, not a
1054
1059
  // Harness availability diagnosis.
1055
1060
  if (options.signal?.aborted) throw error;
1056
- if (error instanceof HarnessTransportError) throw error;
1057
- throw new HarnessTransportError(
1061
+ if (error instanceof HarnessTransportError) throw withDiagnostics(error);
1062
+ throw withDiagnostics(new HarnessTransportError(
1058
1063
  timeoutSignal.aborted ? 'harness-timeout' : 'harness-connect-failed',
1059
1064
  method,
1060
1065
  { cause: error },
1061
- );
1066
+ ));
1062
1067
  }
1063
1068
  if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
1064
- throw new HarnessTransportError('harness-response-invalid', method, {
1069
+ throw withDiagnostics(new HarnessTransportError('harness-response-invalid', method, {
1065
1070
  cause: new Error(`Harness returned an invalid response for ${method}`),
1066
- });
1071
+ }));
1067
1072
  }
1068
1073
  if (!body.result || typeof body.result !== 'object' || typeof body.result.ok !== 'boolean') {
1069
- throw new HarnessTransportError('harness-response-invalid', method, {
1074
+ throw withDiagnostics(new HarnessTransportError('harness-response-invalid', method, {
1070
1075
  cause: new Error(`Harness returned an invalid result for ${method}`),
1071
- });
1076
+ }));
1072
1077
  }
1073
- if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
1078
+ if (!body.result?.ok) throw withDiagnostics(new HarnessRpcError(method, body.result?.error));
1074
1079
  return body.result.value;
1075
1080
  }
1076
1081
 
@@ -1783,6 +1788,7 @@ export class HarnessClient {
1783
1788
  lastSeq: baselineSeq,
1784
1789
  reconnect: null,
1785
1790
  order: -1,
1791
+ competitiveApprovals: this.#competitiveApprovals,
1786
1792
  toolCalls: new Map(),
1787
1793
  control,
1788
1794
  }
@@ -2130,6 +2136,11 @@ export class HarnessClient {
2130
2136
  ? this.#interactionClaims.get(claimKey)?.recovered === true
2131
2137
  : false,
2132
2138
  ...(toolCall ? { toolCall } : {}),
2139
+ ...(kind === 'approval' && payload.competitive === true ? {
2140
+ withdraw: () => this.respondInteraction(envelope.rpcId, {
2141
+ ok: false, error: { code: 'unavailable', message: 'IM cannot answer this approval', details: {} },
2142
+ }, { signal: AbortSignal.timeout(5_000) }),
2143
+ } : {}),
2133
2144
  reconnect: close,
2134
2145
  respond: (result, options = {}) => this.respondInteraction(
2135
2146
  envelope.rpcId,
@@ -47,6 +47,9 @@ export default Object.freeze({
47
47
  "暂未识别具体原因,请复制诊断信息,并查看相同参考号的 DSH 日志。": "The underlying cause is unknown. Copy the diagnostics and find the matching reference in the DSH logs.",
48
48
  "多个原因": "Multiple causes",
49
49
  "服务域名": "Service host",
50
+ "RPC 方法": "RPC method",
51
+ "调用方式": "Transport",
52
+ "宿主 API(进程内)": "Host API (in-process)",
50
53
  "请求耗时(毫秒)": "Request duration (ms)",
51
54
  "超时阈值(毫秒)": "Timeout (ms)",
52
55
  "Node 版本": "Node version",
@@ -70,6 +70,9 @@ export default {
70
70
  '已批准,仅对本次操作有效。':
71
71
  'Approved — valid for this operation only.',
72
72
  '已拒绝此次操作。': 'This operation was rejected.',
73
+ '机器人端无法处理这次审批,请到 Web 查看。': 'The bot cannot handle this approval. Please check the Web interface.',
74
+ '未找到可处理端,本次操作未获批准。': 'No approval interface is available. This operation was not approved.',
75
+ '有操作在等待审批:{tool},请到 Web 处理。': 'An operation is awaiting approval: {tool}. Please handle it in the Web interface.',
73
76
  '无法完整展示这次操作,已安全拒绝此次审批。':
74
77
  'The operation could not be displayed in full, so this approval was safely rejected.',
75
78
  '审批提交失败,请重新回复「批准」或「拒绝」。':
@@ -218,7 +218,7 @@ export function classifyMessageFailure(error, {
218
218
  : businessCode;
219
219
  if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
220
220
  return Object.freeze({
221
- ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
221
+ ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode || details.method ? { details } : {}),
222
222
  code,
223
223
  reason: safeReason ?? safeFailureReason(error?.code) ?? code,
224
224
  message: typeof userMessage === 'string' && userMessage.trim()
@@ -1381,6 +1381,12 @@ export class TextHarnessBridge {
1381
1381
  });
1382
1382
  }
1383
1383
 
1384
+ async presentSessionSyncApproval(interaction, context, options = {}) {
1385
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
1386
+ ...options, runtimeSignal: this.#signal ?? options.runtimeSignal, accessPolicy: this.#accessPolicy,
1387
+ });
1388
+ }
1389
+
1384
1390
  async #handleInteraction(interaction, {
1385
1391
  key,
1386
1392
  actor,
@@ -247,6 +247,22 @@ export class TokenBotController {
247
247
  });
248
248
  }
249
249
 
250
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
251
+ const config = this.#configStore.get(botId);
252
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
253
+ return this.#withBotTransition(botId, async () => {
254
+ const runtime = this.#runtimes.get(botId);
255
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
256
+ const error = new Error(t('{label}机器人尚未连接', {
257
+ label: this.#descriptor.label,
258
+ }));
259
+ error.code = 'bot-not-connected';
260
+ throw error;
261
+ }
262
+ return runtime.presentSessionSyncApproval(target, interaction, options);
263
+ });
264
+ }
265
+
250
266
  async deleteBot(botId) {
251
267
  const warnings = [];
252
268
  const config = this.#configStore.get(botId);
@@ -21,6 +21,7 @@ oauth_config:
21
21
  - files:read
22
22
  - files:write
23
23
  - groups:history
24
+ - im:read
24
25
  - im:history
25
26
  - mpim:history
26
27
  - reactions:write
@@ -234,6 +234,13 @@ export class SlackApi {
234
234
  });
235
235
  }
236
236
 
237
+ async conversationInfo({ channelId, signal } = {}) {
238
+ const value = await this.#request('conversations.info', {
239
+ tokenKind: 'bot', signal, body: { channel: slackId(channelId, 'channel id') },
240
+ });
241
+ return value.channel;
242
+ }
243
+
237
244
  async fileInfo({ fileId, signal } = {}) {
238
245
  const value = await this.#request('files.info', {
239
246
  tokenKind: 'bot',
@@ -207,6 +207,20 @@ export class SlackController {
207
207
  });
208
208
  }
209
209
 
210
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
211
+ const config = this.#configStore.get(botId);
212
+ if (!config) throw new Error('Unknown Slack bot');
213
+ return this.#withBotTransition(botId, async () => {
214
+ const runtime = this.#runtimes.get(botId);
215
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
216
+ const error = new Error(t('Slack机器人尚未连接'));
217
+ error.code = 'bot-not-connected';
218
+ throw error;
219
+ }
220
+ return runtime.presentSessionSyncApproval(target, interaction, options);
221
+ });
222
+ }
223
+
210
224
  async deleteBot(botId) {
211
225
  const warnings = [];
212
226
  const config = this.#configStore.get(botId);
@@ -473,6 +473,18 @@ export class SlackRuntime {
473
473
  return this.#bridge.sendConnectionTest(text);
474
474
  }
475
475
 
476
+ async presentSessionSyncApproval(target, interaction, options = {}) {
477
+ if (!this.#status.ready || !this.#bridge) return false;
478
+ const channelId = target?.route?.channelId;
479
+ const channel = await this.#api.conversationInfo({ channelId, signal: options.signal });
480
+ const actor = channel?.is_im === true && typeof channel.user === 'string' ? channel.user : '';
481
+ if (!actor) return false;
482
+ return this.#bridge.presentSessionSyncApproval(interaction, {
483
+ key: options.key, actor, validate: options.validate,
484
+ send: (text) => this.sendProactiveText(target, text),
485
+ }, options);
486
+ }
487
+
476
488
  async sendProactiveText(target, text, options = {}) {
477
489
  if (!this.#status.ready || !this.#bridge) {
478
490
  const error = new Error('Slack bot is not connected');
@@ -1171,6 +1171,16 @@ export class TelegramRuntime {
1171
1171
  return this.#bridge.sendConnectionTest(text);
1172
1172
  }
1173
1173
 
1174
+ async presentSessionSyncApproval(target, interaction, options = {}) {
1175
+ if (!this.#status.ready || !this.#bridge) return false;
1176
+ const actor = typeof target?.route?.chatId === 'string' ? target.route.chatId.trim() : '';
1177
+ if (!actor) return false;
1178
+ return this.#bridge.presentSessionSyncApproval(interaction, {
1179
+ key: options.key, actor, validate: options.validate,
1180
+ send: (text) => this.sendProactiveText(target, text),
1181
+ }, options);
1182
+ }
1183
+
1174
1184
  async sendProactiveText(target, text, options = {}) {
1175
1185
  if (!this.#status.ready || !this.#bridge) {
1176
1186
  const error = new Error('Telegram bot is not connected');
@@ -1668,6 +1668,12 @@ export class WecomHarnessBridge {
1668
1668
  }
1669
1669
  }
1670
1670
 
1671
+ async presentSessionSyncApproval(interaction, context, options = {}) {
1672
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
1673
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
1674
+ });
1675
+ }
1676
+
1671
1677
  async #handleInteraction(interaction, {
1672
1678
  key,
1673
1679
  actor,
@@ -281,6 +281,20 @@ export class WecomController {
281
281
  });
282
282
  }
283
283
 
284
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
285
+ const config = this.#configStore.get(botId);
286
+ if (!config) throw new Error('Unknown Enterprise WeChat bot');
287
+ return this.#withBotTransition(botId, async () => {
288
+ const runtime = this.#runtimes.get(botId);
289
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
290
+ const error = new Error(t('企业微信连接当前离线'));
291
+ error.code = 'bot-not-connected';
292
+ throw error;
293
+ }
294
+ return runtime.presentSessionSyncApproval(target, interaction, options);
295
+ });
296
+ }
297
+
284
298
  async deleteBot(botId) {
285
299
  const warnings = [];
286
300
  const config = this.#configStore.get(botId);
@@ -230,6 +230,16 @@ export class WecomRuntime {
230
230
  });
231
231
  }
232
232
 
233
+ async presentSessionSyncApproval(target, interaction, options = {}) {
234
+ if (!this.#status.ready || !this.#bridge) return false;
235
+ const actor = typeof target?.route?.chatId === 'string' ? target.route.chatId.trim() : '';
236
+ if (!actor) return false;
237
+ return this.#bridge.presentSessionSyncApproval(interaction, {
238
+ key: options.key, actor, validate: options.validate,
239
+ send: (text) => this.sendProactiveText(target, text),
240
+ }, options);
241
+ }
242
+
233
243
  async sendProactiveText(target, text, { signal } = {}) {
234
244
  const chatId = typeof target?.route?.chatId === 'string'
235
245
  ? target.route.chatId.trim() : '';
@@ -855,6 +855,12 @@ export class WecomAppBridge {
855
855
  }
856
856
  }
857
857
 
858
+ async presentSessionSyncApproval(interaction, context, options = {}) {
859
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
860
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
861
+ });
862
+ }
863
+
858
864
  async #handleInteraction(interaction, { key, actor }) {
859
865
  if (interaction?.kind === 'approval') {
860
866
  return this.#approvals.handleRequested(interaction, {
@@ -290,6 +290,20 @@ export class WecomAppController {
290
290
  });
291
291
  }
292
292
 
293
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
294
+ const config = this.#configStore.get(botId);
295
+ if (!config) throw new Error('Unknown Enterprise WeChat app bot');
296
+ return this.#withBotTransition(botId, async () => {
297
+ const runtime = this.#runtimes.get(botId);
298
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
299
+ const error = new Error(t('企业微信应用当前离线'));
300
+ error.code = 'bot-not-connected';
301
+ throw error;
302
+ }
303
+ return runtime.presentSessionSyncApproval(target, interaction, options);
304
+ });
305
+ }
306
+
293
307
  async deleteBot(botId) {
294
308
  const warnings = [];
295
309
  const config = this.#configStore.get(botId);
@@ -193,6 +193,16 @@ export class WecomAppRuntime {
193
193
  });
194
194
  }
195
195
 
196
+ async presentSessionSyncApproval(target, interaction, options = {}) {
197
+ if (!this.#started || !this.#bridge) return false;
198
+ const actor = typeof target?.route?.chatId === 'string' ? target.route.chatId.trim() : '';
199
+ if (!actor) return false;
200
+ return this.#bridge.presentSessionSyncApproval(interaction, {
201
+ key: options.key, actor, validate: options.validate,
202
+ send: (text) => this.sendProactiveText(target, text),
203
+ }, options);
204
+ }
205
+
196
206
  async sendProactiveText(target, text, { signal } = {}) {
197
207
  const chatId = typeof target?.route?.chatId === 'string' ? target.route.chatId.trim() : '';
198
208
  if ((target?.kind !== 'user') || !chatId) {
@@ -1102,6 +1102,12 @@ export class WeixinHarnessBridge {
1102
1102
  }
1103
1103
  }
1104
1104
 
1105
+ async presentSessionSyncApproval(interaction, context, options = {}) {
1106
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
1107
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
1108
+ });
1109
+ }
1110
+
1105
1111
  async #handleInteraction(interaction, {
1106
1112
  key,
1107
1113
  actor,
@@ -289,6 +289,20 @@ export class WeixinController {
289
289
  });
290
290
  }
291
291
 
292
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
293
+ const config = this.#configStore.get(botId);
294
+ if (!config) throw new Error('Unknown Weixin account');
295
+ return this.#withBotTransition(botId, async () => {
296
+ const runtime = this.#runtimes.get(botId);
297
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
298
+ const error = new Error(t('微信连接当前离线'));
299
+ error.code = 'bot-not-connected';
300
+ throw error;
301
+ }
302
+ return runtime.presentSessionSyncApproval(target, interaction, options);
303
+ });
304
+ }
305
+
292
306
  async deleteBot(botId) {
293
307
  const config = this.#configStore.get(botId);
294
308
  if (!config) throw weixinStageError('workspace-bot-not-found', undefined, 'account.remove');
@@ -392,6 +392,16 @@ export class WeixinRuntime {
392
392
  return result;
393
393
  }
394
394
 
395
+ async presentSessionSyncApproval(target, interaction, options = {}) {
396
+ if (!this.#status.ready || !this.#bridge) return false;
397
+ const actor = typeof target?.route?.toUserId === 'string' ? target.route.toUserId.trim() : '';
398
+ if (!actor) return false;
399
+ return this.#bridge.presentSessionSyncApproval(interaction, {
400
+ key: options.key, actor, validate: options.validate,
401
+ send: (text) => this.sendProactiveText(target, text),
402
+ }, options);
403
+ }
404
+
395
405
  async sendProactiveText(target, text, { signal } = {}) {
396
406
  const toUserId = typeof target?.route?.toUserId === 'string'
397
407
  ? target.route.toUserId.trim() : '';