@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,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
- import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
3
+ import { activeHarnessInteractionOwner, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
4
4
  import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
5
5
 
6
6
  const modernApis = new WeakMap();
@@ -196,6 +196,7 @@ class MuxSubscription {
196
196
 
197
197
  class ModernHarnessApi {
198
198
  #gateway;
199
+ #deliveryService;
199
200
  #scope;
200
201
  #mux = new Set();
201
202
  #pendingQuestions = new Map();
@@ -286,6 +287,10 @@ class ModernHarnessApi {
286
287
  }
287
288
  }
288
289
 
290
+ setDeliveryService(service) {
291
+ if (service) this.#deliveryService = service;
292
+ }
293
+
289
294
  async #invoke(namespace, method, args, signal) {
290
295
  return this.#gateway.invoke(remoteRequest(namespace, method, args, signal));
291
296
  }
@@ -467,7 +472,9 @@ class ModernHarnessApi {
467
472
  subscription = new MuxSubscription(signal, () => this.#mux.delete(subscription));
468
473
  this.#mux.add(subscription);
469
474
  for (const pending of this.#pendingQuestions.values()) subscription.push(this.#questionFrame(pending));
470
- for (const pending of this.#pendingApprovals.values()) subscription.push(this.#approvalFrame(pending));
475
+ for (const pending of this.#pendingApprovals.values()) {
476
+ if (pending.broadcast !== false && pending.imAvailable !== false) subscription.push(this.#approvalFrame(pending));
477
+ }
471
478
  return subscription;
472
479
  }
473
480
 
@@ -630,6 +637,7 @@ class ModernHarnessApi {
630
637
  type: 'approval/requested',
631
638
  sessionId: pending.sessionId,
632
639
  approvalId: pending.approvalId,
640
+ ...(pending.competitive ? { competitive: true } : {}),
633
641
  toolName: pending.toolName,
634
642
  ...(pending.callId === undefined ? {} : { callId: pending.callId }),
635
643
  ...(pending.reason === undefined ? {} : { reason: pending.reason }),
@@ -638,6 +646,123 @@ class ModernHarnessApi {
638
646
  }
639
647
 
640
648
  #requestApproval(request, next) {
649
+ const session = request?.agent?.session;
650
+ const sessionId = session?.id ?? request?.agent?.id;
651
+ const events = sessionEvents(session);
652
+ if (typeof sessionId !== 'string' || !events) return next();
653
+ const owner = activeHarnessInteractionOwner(this.#scope, sessionId, events);
654
+ if (owner && !owner.competitiveApprovals) return this.#requestExclusiveApproval(request, next);
655
+ if (!owner && typeof this.#deliveryService?.presentSessionSyncApproval !== 'function') return next();
656
+ const turnSignal = request.signal;
657
+ if (turnSignal?.aborted) return Promise.resolve('cancelled');
658
+ const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
659
+ const decided = new Set();
660
+ let approvalId;
661
+ for (let index = events.length - 1; index >= 0; index -= 1) {
662
+ const event = events[index];
663
+ if (event.type === 'approval/decided') decided.add(event.data?.id);
664
+ if (event.type !== 'approval/asked') continue;
665
+ const id = event.data?.id;
666
+ if (!id || decided.has(id) || claimed.has(id)
667
+ || (request.callId ?? null) !== (event.data?.callId ?? null)) continue;
668
+ approvalId = id;
669
+ break;
670
+ }
671
+ if (!approvalId) return next();
672
+ let toolCall = owner?.toolCalls.get(request.callId);
673
+ if (!toolCall) {
674
+ for (let index = events.length - 1; index >= 0; index -= 1) {
675
+ const event = events[index];
676
+ if (event.type === 'turn/start') break;
677
+ const call = harnessToolCallFromEvent(event);
678
+ if (call?.callId === request.callId && call.name === request.toolName) {
679
+ toolCall = call;
680
+ break;
681
+ }
682
+ }
683
+ }
684
+ // This controller retires only this approval; the caller keeps its turn signal.
685
+ const lifetime = new AbortController();
686
+ request.signal = lifetime.signal;
687
+ let nativeAvailable = true;
688
+ let resolveResult;
689
+ const result = new Promise((resolve) => { resolveResult = resolve; });
690
+ const pending = {
691
+ rpcId: randomUUID(), sessionId, approvalId,
692
+ toolName: request.toolName, callId: request.callId, reason: request.reason,
693
+ competitive: true, broadcast: Boolean(owner), imAvailable: true,
694
+ settle: (outcome) => {
695
+ if (!this.#pendingApprovals.delete(pending.rpcId)) return false;
696
+ turnSignal?.removeEventListener('abort', onAbort);
697
+ if (pending.broadcast) this.#broadcast({
698
+ type: 'approval/resolved', sessionId, approvalId, outcome,
699
+ });
700
+ // Reserve the result before aborting the losing native presentation.
701
+ resolveResult(outcome);
702
+ lifetime.abort(new Error('approval settled'));
703
+ if (request.signal === lifetime.signal) request.signal = turnSignal;
704
+ return true;
705
+ },
706
+ withdraw: () => {
707
+ if (!this.#pendingApprovals.has(pending.rpcId) || !pending.imAvailable) return false;
708
+ pending.imAvailable = false;
709
+ if (pending.broadcast) this.#broadcast({
710
+ type: 'approval/resolved', sessionId, approvalId, outcome: 'unavailable',
711
+ });
712
+ if (!nativeAvailable) pending.settle('unavailable');
713
+ return true;
714
+ },
715
+ };
716
+ const onAbort = () => pending.settle('cancelled');
717
+ this.#pendingApprovals.set(pending.rpcId, pending);
718
+ turnSignal?.addEventListener('abort', onAbort, { once: true });
719
+ if (turnSignal?.aborted) onAbort();
720
+ // Start both providers without waiting for target lookup or platform delivery.
721
+ Promise.resolve().then(() => {
722
+ if (lifetime.signal.aborted) return 'cancelled';
723
+ return next();
724
+ }).then((outcome) => {
725
+ if (outcome === 'allowed-once' || outcome === 'rejected') pending.settle(outcome);
726
+ else {
727
+ nativeAvailable = false;
728
+ if (!pending.imAvailable) pending.settle('unavailable');
729
+ }
730
+ }, (error) => {
731
+ nativeAvailable = false;
732
+ if (!lifetime.signal.aborted) console.warn('[dsh-im] native approval provider unavailable:', error?.code ?? error?.name);
733
+ if (!pending.imAvailable) pending.settle('unavailable');
734
+ });
735
+ if (!lifetime.signal.aborted && owner) {
736
+ this.#broadcast(this.#approvalFrame(pending).payload, pending.rpcId);
737
+ } else if (!lifetime.signal.aborted) {
738
+ const respond = async (response) => {
739
+ const receipt = this.#respond({ rpcId: pending.rpcId, result: response });
740
+ if (!receipt.accepted) throw new HarnessInteractionError(
741
+ `interaction-${receipt.reason}`, 'This approval is no longer answerable',
742
+ );
743
+ return receipt;
744
+ };
745
+ const interaction = {
746
+ kind: 'approval', interactionId: approvalId, rpcId: pending.rpcId, sessionId,
747
+ payload: this.#approvalFrame(pending).payload, toolCall, respond,
748
+ withdraw: async () => pending.withdraw(),
749
+ };
750
+ Promise.resolve().then(() => {
751
+ if (lifetime.signal.aborted) return false;
752
+ return this.#deliveryService.presentSessionSyncApproval(sessionId, interaction, {
753
+ signal: lifetime.signal, completion: result,
754
+ });
755
+ }).then((available) => {
756
+ if (!available) pending.withdraw();
757
+ }, (error) => {
758
+ if (!lifetime.signal.aborted) console.warn('[dsh-im] synced approval delivery unavailable:', error?.code ?? error?.name);
759
+ pending.withdraw();
760
+ });
761
+ }
762
+ return result;
763
+ }
764
+
765
+ #requestExclusiveApproval(request, next) {
641
766
  const owner = this.#claimableAgent(request?.agent);
642
767
  if (!owner) return next();
643
768
  if (request.signal?.aborted) return Promise.resolve('cancelled');
@@ -687,6 +812,12 @@ class ModernHarnessApi {
687
812
  #respond(message) {
688
813
  const approval = this.#pendingApprovals.get(message?.rpcId);
689
814
  if (approval) {
815
+ if (approval.imAvailable === false) return { accepted: false, reason: 'not-pending' };
816
+ if (approval.competitive && message?.result?.ok === false
817
+ && message?.result?.error?.code === 'unavailable') {
818
+ return approval.withdraw()
819
+ ? { accepted: true } : { accepted: false, reason: 'not-pending' };
820
+ }
690
821
  const value = message?.result?.value;
691
822
  if (message?.result?.ok !== true
692
823
  || !value || typeof value !== 'object'
@@ -738,18 +869,22 @@ class ModernHarnessApi {
738
869
  }
739
870
 
740
871
  /** Create one modern compatibility API per Cordis Host root. */
741
- export function modernHarnessApi(ctx) {
872
+ export function modernHarnessApi(ctx, { deliveryService } = {}) {
742
873
  const scope = ctx?.root ?? ctx;
743
874
  if (!scope || !['object', 'function'].includes(typeof scope)) {
744
875
  throw new TypeError('dsh-im requires a Cordis Host context');
745
876
  }
746
877
  const cached = modernApis.get(scope);
747
- if (cached) return cached;
878
+ if (cached) {
879
+ cached.setDeliveryService(deliveryService);
880
+ return cached;
881
+ }
748
882
  const gateway = ctx?.typertGateway;
749
883
  if (!gateway || typeof gateway.invoke !== 'function' || typeof gateway.stream !== 'function') {
750
884
  throw new TypeError('dsh-im requires the modern Host Typert gateway');
751
885
  }
752
886
  const api = new ModernHarnessApi(ctx, gateway, scope);
887
+ api.setDeliveryService(deliveryService);
753
888
  modernApis.set(scope, api);
754
889
  if (typeof ctx.effect === 'function') {
755
890
  ctx.effect(() => () => {
@@ -1647,6 +1647,12 @@ export class DingtalkHarnessBridge {
1647
1647
  }
1648
1648
  }
1649
1649
 
1650
+ async presentSessionSyncApproval(interaction, context, options = {}) {
1651
+ return this.#approvals.handleSessionSyncRequested(interaction, context, {
1652
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
1653
+ });
1654
+ }
1655
+
1650
1656
  async #handleInteraction(interaction, {
1651
1657
  key,
1652
1658
  actor,
@@ -442,6 +442,20 @@ export class DingtalkController {
442
442
  });
443
443
  }
444
444
 
445
+ async presentSessionSyncApproval(botId, target, interaction, options = {}) {
446
+ const config = this.#configStore.get(botId);
447
+ if (!config) throw new Error('Unknown DingTalk bot');
448
+ return this.#withBotTransition(botId, async () => {
449
+ const runtime = this.#runtimes.get(botId);
450
+ if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
451
+ const error = new Error(t('钉钉消息连接当前离线'));
452
+ error.code = 'bot-not-connected';
453
+ throw error;
454
+ }
455
+ return runtime.presentSessionSyncApproval(target, interaction, options);
456
+ });
457
+ }
458
+
445
459
  /** Removes one bot, its secret, runtime, and local conversation state. */
446
460
  async deleteBot(botId) {
447
461
  const warnings = [];
@@ -432,6 +432,16 @@ export class DingtalkRuntime {
432
432
  });
433
433
  }
434
434
 
435
+ async presentSessionSyncApproval(target, interaction, options = {}) {
436
+ if (!this.#status.ready || !this.#bridge) return false;
437
+ const actor = typeof target?.route?.userId === 'string' ? target.route.userId.trim() : '';
438
+ if (!actor) return false;
439
+ return this.#bridge.presentSessionSyncApproval(interaction, {
440
+ key: options.key, actor, validate: options.validate,
441
+ send: (text) => this.sendProactiveText(target, text),
442
+ }, options);
443
+ }
444
+
435
445
  async sendProactiveText(target, text, { signal } = {}) {
436
446
  const userId = typeof target?.route?.userId === 'string'
437
447
  ? target.route.userId.trim() : '';
@@ -605,6 +605,19 @@ export class DiscordRuntime {
605
605
  return this.#bridge.sendConnectionTest(text);
606
606
  }
607
607
 
608
+ async presentSessionSyncApproval(target, interaction, options = {}) {
609
+ if (!this.#status.ready || !this.#bridge) return false;
610
+ const channelId = target?.route?.channelId;
611
+ const channel = await this.#api.getChannel({ channelId, signal: options.signal });
612
+ const actor = channel?.type === 1 && channel.recipients?.length === 1
613
+ ? channel.recipients[0]?.id : '';
614
+ if (!actor) return false;
615
+ return this.#bridge.presentSessionSyncApproval(interaction, {
616
+ key: options.key, actor, validate: options.validate,
617
+ send: (text) => this.sendProactiveText(target, text),
618
+ }, options);
619
+ }
620
+
608
621
  async sendProactiveText(target, text, options = {}) {
609
622
  if (!this.#status.ready || !this.#bridge) {
610
623
  const error = new Error('Discord bot is not connected');
@@ -3161,6 +3161,7 @@ export class FeishuHarnessBridge {
3161
3161
  }
3162
3162
  const sentId = nonEmptyString(response?.data?.message_id);
3163
3163
  if (!sentId) throw new Error('Feishu card send returned no message_id');
3164
+ this.#rememberCardRoute(sentId, response?.data?.chat_id ?? chatId, options);
3164
3165
  return sentId;
3165
3166
  }
3166
3167
 
@@ -6043,20 +6044,16 @@ export class FeishuHarnessBridge {
6043
6044
  }
6044
6045
  }
6045
6046
 
6046
- async #handleInteraction(interaction, {
6047
- key,
6048
- actor,
6049
- chatId,
6050
- requiresMention,
6051
- replyToMessageId,
6052
- }) {
6047
+ #approvalContext({ key, actor, chatId, requiresMention, replyToMessageId, receiveIdType,
6048
+ validate, send = (text) => this.#send(chatId, text, { replyTo: replyToMessageId }) }) {
6053
6049
  let approvalCardMessageId = null;
6054
6050
  let approvalCardData = null;
6055
- if (await this.#approvals.handleRequested(interaction, {
6051
+ return {
6056
6052
  key,
6057
6053
  actor,
6058
6054
  requiresMention,
6059
- send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
6055
+ send,
6056
+ validate,
6060
6057
  // Approvals render as interactive cards with approve/reject buttons by
6061
6058
  // default. Set the bridge `interactionCards` option (or
6062
6059
  // DSH_IM_INTERACTION_CARDS=0) to keep the plain-text reply flow.
@@ -6073,27 +6070,47 @@ export class FeishuHarnessBridge {
6073
6070
  approvalCardMessageId = await this.#sendCard(
6074
6071
  chatId,
6075
6072
  approvalCard(approvalCardData),
6076
- { key, replyTo: replyToMessageId },
6073
+ { key, replyTo: replyToMessageId, receiveIdType },
6077
6074
  ).catch(async () => {
6078
6075
  // Fall back to the plain-text approval if the card cannot be
6079
6076
  // sent. If the text send also fails, let the error propagate so
6080
6077
  // the pending approval is not marked as presented and the
6081
6078
  // existing retry/reconnect logic can run.
6082
- await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
6079
+ await send(pending.text);
6083
6080
  return null;
6084
6081
  });
6085
6082
  },
6086
6083
  onResolved: async (resolvedText) => {
6087
6084
  if (!approvalCardMessageId) return;
6088
- await this.#patchCardMessage(
6085
+ const patched = await this.#patchCardMessage(
6089
6086
  chatId,
6090
6087
  approvalCardMessageId,
6091
6088
  approvalCard({ ...approvalCardData, resolvedText }),
6092
6089
  );
6090
+ if (!patched) throw new Error('Approval card update failed');
6093
6091
  },
6094
6092
  }
6095
6093
  : {}),
6096
- })) return;
6094
+ };
6095
+
6096
+ }
6097
+
6098
+ async presentSessionSyncApproval(interaction, context, options = {}) {
6099
+ return this.#approvals.handleSessionSyncRequested(interaction, this.#approvalContext(context), {
6100
+ ...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
6101
+ });
6102
+ }
6103
+
6104
+ async #handleInteraction(interaction, {
6105
+ key,
6106
+ actor,
6107
+ chatId,
6108
+ requiresMention,
6109
+ replyToMessageId,
6110
+ }) {
6111
+ if (await this.#approvals.handleRequested(interaction, this.#approvalContext({
6112
+ key, actor, chatId, requiresMention, replyToMessageId,
6113
+ }))) return;
6097
6114
 
6098
6115
  // Approval requests return above; the existing question state machine stays unchanged.
6099
6116
  if (interaction?.kind !== 'question') return;
@@ -0,0 +1,118 @@
1
+ function refusal(code) {
2
+ const error = new Error(code);
3
+ error.code = code;
4
+ return error;
5
+ }
6
+
7
+ function identifier(value) {
8
+ return typeof value === 'string' && value.length > 0 && value.length <= 512 ? value : undefined;
9
+ }
10
+
11
+ export function normalizeExternalText(event, { botId, appId, botOpenId, fingerprint }) {
12
+ const message = event?.message;
13
+ const actorId = identifier(event?.sender?.sender_id?.open_id);
14
+ const messageId = identifier(message?.message_id);
15
+ const conversationId = identifier(message?.chat_id);
16
+ const eventId = identifier(event?.event_id ?? event?.header?.event_id);
17
+ const eventApp = event?.app_id ?? event?.header?.app_id;
18
+ if (eventApp !== undefined && eventApp !== appId) throw refusal('account-changed');
19
+ if (event?.sender?.sender_type === 'app' || event?.sender?.sender_type === 'bot') return null;
20
+ if (event?.sender?.sender_type !== 'user'
21
+ || !['p2p', 'group'].includes(message?.chat_type) || message?.message_type !== 'text') return null;
22
+ if (message.chat_type === 'group' && !identifier(botOpenId)) throw refusal('account-unverified');
23
+ if (message.mentions !== undefined && (!Array.isArray(message.mentions) || message.mentions.length > 100))
24
+ throw refusal('invalid-inbound');
25
+ const mentions = Object.freeze((message.mentions ?? []).map(mention => {
26
+ const id = identifier(mention?.id?.open_id ?? mention?.open_id);
27
+ const key = identifier(mention?.key);
28
+ if (!id || !key) throw refusal('invalid-inbound');
29
+ return Object.freeze({ id, key, ...(identifier(mention.name) ? { name: mention.name } : {}) });
30
+ }));
31
+ if (!actorId || !messageId || !conversationId || !eventId
32
+ || !/^[a-f0-9]{64}$/.test(fingerprint ?? '')) throw refusal('invalid-inbound');
33
+ let text;
34
+ try { text = JSON.parse(message.content)?.text; } catch { throw refusal('invalid-inbound'); }
35
+ if (typeof text !== 'string' || !text.trim() || text.length > 16000) throw refusal('invalid-inbound');
36
+ const milliseconds = Number(message.create_time);
37
+ const timestamp = new Date(milliseconds);
38
+ if (!Number.isFinite(milliseconds) || milliseconds <= 0 || !Number.isFinite(timestamp.getTime()))
39
+ throw refusal('invalid-inbound');
40
+ const route = Object.freeze({
41
+ messageId, conversationId, actorId,
42
+ ...(identifier(message.thread_id) ? { threadId: message.thread_id } : {}),
43
+ ...(identifier(message.root_id) ? { rootId: message.root_id } : {}),
44
+ ...(identifier(message.parent_id) ? { parentId: message.parent_id } : {}),
45
+ });
46
+ return Object.freeze({
47
+ version: 1, channel: 'feishu', botId, fingerprint, eventId, messageId,
48
+ actor: Object.freeze({ kind: 'user', id: actorId,
49
+ ...(identifier(event.sender.sender_name) ? { name: event.sender.sender_name } : {}) }),
50
+ conversation: Object.freeze({ kind: message.chat_type === 'group' ? 'group' : 'dm', id: conversationId }),
51
+ mentions, mentionedAccount: mentions.some(mention => mention.id === botOpenId),
52
+ at: timestamp.toISOString(), text, reply: route,
53
+ replay: Object.freeze({ kind: 'provider-redelivery', resumeCursor: false, gapPossible: true }),
54
+ });
55
+ }
56
+
57
+ export class ExclusiveInboundConsumers {
58
+ #entries = new Map();
59
+
60
+ register(botId, { fingerprint, onEvent, signal }) {
61
+ if (this.#entries.has(botId)) throw refusal('consumer-conflict');
62
+ if (!/^[a-f0-9]{64}$/.test(fingerprint ?? '') || typeof onEvent !== 'function')
63
+ throw refusal('bad-request');
64
+ signal?.throwIfAborted();
65
+ const controller = new AbortController();
66
+ const entry = { fingerprint, onEvent, controller, dispose: undefined };
67
+ const dispose = () => {
68
+ if (this.#entries.get(botId) === entry) this.#entries.delete(botId);
69
+ controller.abort(refusal('consumer-unavailable'));
70
+ signal?.removeEventListener('abort', dispose);
71
+ };
72
+ entry.dispose = dispose;
73
+ this.#entries.set(botId, entry);
74
+ signal?.addEventListener('abort', dispose, { once: true });
75
+ return dispose;
76
+ }
77
+
78
+ async accept(botId, evidence, signal) {
79
+ const entry = this.#entries.get(botId);
80
+ if (!entry) throw refusal('consumer-unavailable');
81
+ if (entry.fingerprint !== evidence.fingerprint) throw refusal('account-changed');
82
+ const deliverySignal = signal
83
+ ? AbortSignal.any([signal, entry.controller.signal]) : entry.controller.signal;
84
+ deliverySignal.throwIfAborted();
85
+ let abort;
86
+ const interrupted = new Promise((_, reject) => {
87
+ abort = () => reject(deliverySignal.reason);
88
+ deliverySignal.addEventListener('abort', abort, { once: true });
89
+ if (deliverySignal.aborted) abort();
90
+ });
91
+ let result;
92
+ try {
93
+ result = await Promise.race([entry.onEvent(evidence, { signal: deliverySignal }), interrupted]);
94
+ } finally {
95
+ deliverySignal.removeEventListener('abort', abort);
96
+ }
97
+ deliverySignal.throwIfAborted();
98
+ if (this.#entries.get(botId) !== entry) throw refusal('consumer-unavailable');
99
+ if (result?.accepted !== true) throw refusal('ingress-not-accepted');
100
+ return { accepted: true };
101
+ }
102
+
103
+ signalFor(botId, fingerprint) {
104
+ const entry = this.#entries.get(botId);
105
+ if (!entry) throw refusal('consumer-unavailable');
106
+ if (entry.fingerprint !== fingerprint) throw refusal('account-changed');
107
+ return entry.controller.signal;
108
+ }
109
+
110
+ remove(botId) {
111
+ const entry = this.#entries.get(botId);
112
+ entry?.dispose();
113
+ }
114
+
115
+ close() {
116
+ for (const botId of this.#entries.keys()) this.remove(botId);
117
+ }
118
+ }
@@ -1,4 +1,5 @@
1
1
  import { createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
2
+ import { readExternalHistory } from './history-reader.mjs';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { FeishuHarnessBridge } from './bridge.mjs';
4
5
  import { cardActionProbeCard } from './feishu-cards.mjs';
@@ -134,6 +135,8 @@ export class FeishuRuntime {
134
135
  #repair;
135
136
  #client = null;
136
137
  #bridge = null;
138
+ #consumerMode;
139
+ #acceptExternal;
137
140
  #wsClient = null;
138
141
  #starting = null;
139
142
  #stopping = null;
@@ -157,6 +160,8 @@ export class FeishuRuntime {
157
160
 
158
161
  constructor({
159
162
  lark,
163
+ consumerMode = 'standalone',
164
+ acceptExternal,
160
165
  botId,
161
166
  appId,
162
167
  appSecret,
@@ -197,6 +202,8 @@ export class FeishuRuntime {
197
202
  throw new TypeError('FeishuRuntime requestTimeoutMs must be a positive number');
198
203
  }
199
204
 
205
+ this.#consumerMode = consumerMode;
206
+ this.#acceptExternal = acceptExternal;
200
207
  this.#lark = lark;
201
208
  this.#botId = nonEmptyString(botId);
202
209
  this.#appId = appId;
@@ -224,7 +231,7 @@ export class FeishuRuntime {
224
231
  this.#replyTimeoutMs = replyTimeoutMs;
225
232
  this.#connectTimeoutMs = connectTimeoutMs;
226
233
  this.#requestTimeoutMs = requestTimeoutMs;
227
- this.#slashCommands = Boolean(slashCommands);
234
+ this.#slashCommands = consumerMode !== 'external-consumer' && Boolean(slashCommands);
228
235
  this.#slashPanel = normalizeSlashPanelConfig(slashPanel);
229
236
  this.#wsAgent = wsAgent;
230
237
  this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'feishu', logger });
@@ -348,7 +355,7 @@ export class FeishuRuntime {
348
355
  client,
349
356
  initialText: t('已连接 DeepSeek Harness,正在思考…'),
350
357
  });
351
- const bridge = new FeishuHarnessBridge({
358
+ const bridge = this.#consumerMode === 'external-consumer' ? null : new FeishuHarnessBridge({
352
359
  client,
353
360
  channel,
354
361
  harness: this.#harness,
@@ -381,6 +388,12 @@ export class FeishuRuntime {
381
388
 
382
389
  const dispatcher = new this.#lark.EventDispatcher({}).register({
383
390
  'im.message.receive_v1': (event) => {
391
+ if (this.#consumerMode === 'external-consumer') {
392
+ assertCurrentStart();
393
+ if (typeof this.#acceptExternal !== 'function')
394
+ throw Object.assign(new Error('consumer-unavailable'), { code: 'consumer-unavailable' });
395
+ return this.#acceptExternal(event, { signal });
396
+ }
384
397
  if (isCurrentStart()) void bridge.accept(event);
385
398
  },
386
399
  'im.message.reaction.created_v1': () => undefined,
@@ -389,7 +402,7 @@ export class FeishuRuntime {
389
402
  // subscribes card.action.trigger; the number-reply fallback covers
390
403
  // apps that do not).
391
404
  'card.action.trigger': (event) => {
392
- if (!isCurrentStart()) return;
405
+ if (!isCurrentStart() || this.#consumerMode === 'external-consumer') return;
393
406
  this.#status.cardActionsReceived += 1;
394
407
  this.#status.lastCardActionAt = new Date().toISOString();
395
408
  if (!this.#consumeCardActionProbe(event)) void bridge.onCardAction(event);
@@ -675,6 +688,17 @@ export class FeishuRuntime {
675
688
  });
676
689
  }
677
690
 
691
+ async presentSessionSyncApproval(target, interaction, options = {}) {
692
+ if (!this.#status.ready || !this.#bridge) return false;
693
+ const actor = typeof target?.route?.openId === 'string' ? target.route.openId.trim() : '';
694
+ if (!actor) return false;
695
+ return this.#bridge.presentSessionSyncApproval(interaction, {
696
+ key: options.key, actor, validate: options.validate,
697
+ chatId: actor, receiveIdType: 'open_id',
698
+ send: (text) => this.sendProactiveText(target, text),
699
+ }, options);
700
+ }
701
+
678
702
  async sendProactiveText(target, text, { signal, format = 'plain', receipt = false } = {}) {
679
703
  if (!this.#status.ready || !this.#client) {
680
704
  const error = new Error('飞书机器人尚未连接');
@@ -734,6 +758,51 @@ export class FeishuRuntime {
734
758
  return { sent: true, receipt: { version: 1, messageId, conversationId } };
735
759
  }
736
760
 
761
+ async historyChecked(identity, route, query, { signal } = {}) {
762
+ const client = this.#client;
763
+ if (!client || this.#consumerMode !== 'external-consumer')
764
+ throw Object.assign(new Error('bot-not-connected'), { code: 'bot-not-connected' });
765
+ const result = await readExternalHistory(client, identity, route, query, signal);
766
+ signal?.throwIfAborted();
767
+ if (this.#client !== client)
768
+ throw Object.assign(new Error('bot-not-connected'), { code: 'bot-not-connected' });
769
+ return result;
770
+ }
771
+
772
+ async replyChecked(route, text, { signal } = {}) {
773
+ if (!route || typeof route.messageId !== 'string' || typeof route.conversationId !== 'string'
774
+ || typeof route.actorId !== 'string' || typeof text !== 'string' || !text.trim() || text.length > 4000)
775
+ throw Object.assign(new Error('bad-request'), { code: 'bad-request' });
776
+ const client = this.#client;
777
+ if (!client || this.#consumerMode !== 'external-consumer')
778
+ throw Object.assign(new Error('bot-not-connected'), { code: 'bot-not-connected' });
779
+ signal?.throwIfAborted();
780
+ const current = await client.im.v1.message.get({ path: { message_id: route.messageId } });
781
+ const source = current?.data?.items?.find(item => item.message_id === route.messageId);
782
+ if (current?.code || !source || source.deleted === true || source.chat_id !== route.conversationId
783
+ || source.sender?.sender_type !== 'user' || source.sender?.id_type !== 'open_id'
784
+ || source.sender?.id !== route.actorId || (source.thread_id || undefined) !== route.threadId
785
+ || (source.root_id || undefined) !== route.rootId || (source.parent_id || undefined) !== route.parentId)
786
+ throw Object.assign(new Error('stale-route'), { code: 'stale-route' });
787
+ signal?.throwIfAborted();
788
+ if (this.#client !== client) throw Object.assign(new Error('bot-not-connected'), { code: 'bot-not-connected' });
789
+ let response;
790
+ try {
791
+ response = await client.im.v1.message.reply({
792
+ path: { message_id: route.messageId },
793
+ data: { msg_type: 'text', content: JSON.stringify({ text }), reply_in_thread: Boolean(route.threadId) },
794
+ });
795
+ } catch (cause) {
796
+ // The request may have reached Lark. Do not turn an ambiguous result into
797
+ // a retryable pre-send failure or send again on the group mainline.
798
+ throw Object.assign(new Error('reply-result-unknown', { cause }), { code: 'reply-result-unknown' });
799
+ }
800
+ const messageId = response?.data?.message_id;
801
+ if (response?.code || typeof messageId !== 'string' || !messageId)
802
+ throw Object.assign(new Error('reply-result-unknown'), { code: 'reply-result-unknown' });
803
+ return { sent: true, messageId };
804
+ }
805
+
737
806
  /**
738
807
  * Queue one panel sync for the current config.
739
808
  *