@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
@@ -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
  *
@@ -0,0 +1,92 @@
1
+ import { readNearbyHistory } from './nearby-history.mjs';
2
+ import { normalizeExternalText } from './external-consumer.mjs';
3
+
4
+ function refuse(code) { throw Object.assign(new Error(code), { code }); }
5
+ function identifier(value) {
6
+ return typeof value === 'string' && value.length > 0 && value.length <= 512;
7
+ }
8
+
9
+ function check(response) {
10
+ if ([230027, 99991672, 99991679].includes(response?.code)) refuse('history-permission-denied');
11
+ if (response?.code) refuse('history-unavailable');
12
+ }
13
+
14
+ async function checkedSdkRead(operation, signal) {
15
+ let response;
16
+ try { response = await operation(); }
17
+ catch (error) {
18
+ signal?.throwIfAborted();
19
+ if (error?.name === 'AbortError') throw error;
20
+ if (error?.code === 'ABORT_ERR' || error?.code === 'ERR_CANCELED') refuse('cancelled');
21
+ // The SDK rejects HTTP 403 before returning the provider's JSON payload.
22
+ // Never expose its raw Axios error (which can contain credential headers).
23
+ check(error?.response?.data);
24
+ refuse('history-unavailable');
25
+ }
26
+ signal?.throwIfAborted();
27
+ check(response);
28
+ return response;
29
+ }
30
+
31
+ // Application-defined checked read: destination is derived from an authenticated source.
32
+ // Lark has Chat/time-range listing and Thread listing, not a native around-message API.
33
+ export async function readExternalHistory(client, identity, route, query, signal) {
34
+ if (!route || !identifier(route.messageId) || !identifier(route.conversationId)
35
+ || !identifier(route.actorId) || ['threadId', 'rootId', 'parentId'].some(key => route[key] !== undefined && !identifier(route[key])) || !['group', 'nearby', 'thread'].includes(query?.scope)
36
+ || !Number.isInteger(query.limit) || query.limit < 1 || query.limit > 20
37
+ || (query.cursor !== undefined && (typeof query.cursor !== 'string' || !query.cursor || query.cursor.length > 4096)))
38
+ refuse('bad-request');
39
+ if (query.scope === 'nearby' && [query.beforeCount ?? 10, query.afterCount ?? 5]
40
+ .some(value => !Number.isInteger(value) || value < 0 || value > 20)) refuse('bad-request');
41
+ signal?.throwIfAborted();
42
+ const reference = await checkedSdkRead(() => client.im.v1.message.get({ path: { message_id: route.messageId }, params: { with_sender_name: true } }, { signal }), signal);
43
+ signal?.throwIfAborted();
44
+ const source = (Array.isArray(reference?.data?.items) ? reference.data.items : []).find(item => item?.message_id === route.messageId);
45
+ if (!source || source.deleted || source.chat_id !== route.conversationId
46
+ || source.sender?.sender_type !== 'user' || source.sender?.id_type !== 'open_id'
47
+ || source.sender?.id !== route.actorId || (source.thread_id || undefined) !== route.threadId
48
+ || (source.root_id || undefined) !== route.rootId || (source.parent_id || undefined) !== route.parentId)
49
+ refuse('stale-route');
50
+ if (query.scope === 'thread' && !route.threadId) refuse('thread-unavailable');
51
+ signal?.throwIfAborted();
52
+ const seconds = Math.floor(Number(source.create_time) / 1000);
53
+ if (!Number.isFinite(seconds) || seconds <= 0) refuse('stale-route');
54
+ const readPage = async params => {
55
+ const response = await checkedSdkRead(() => client.im.v1.message.list({ params }, { signal }), signal);
56
+ signal?.throwIfAborted();
57
+ const items = response?.data?.items;
58
+ if (!Array.isArray(items) || items.length > query.limit || typeof response.data.has_more !== 'boolean')
59
+ refuse('history-unavailable');
60
+ const events = [];
61
+ let omitted = 0;
62
+ for (const item of items) {
63
+ if (!item || item.chat_id !== route.conversationId
64
+ || (query.scope === 'thread' && item.thread_id !== route.threadId)) refuse('untrusted-source');
65
+ if (item.deleted || item.msg_type !== 'text' || item.sender?.sender_type !== 'user'
66
+ || item.sender?.id_type !== 'open_id') { ++omitted; continue; }
67
+ if (item.mentions !== undefined && (!Array.isArray(item.mentions) || item.mentions.length > 100)) { ++omitted; continue; }
68
+ let event;
69
+ try { event = normalizeExternalText({ event_id: `history:${item.message_id}`,
70
+ sender: { sender_type: 'user', sender_id: { open_id: item.sender.id }, sender_name: item.sender.sender_name },
71
+ message: { ...item, chat_type: 'group', message_type: item.msg_type, content: item.body?.content,
72
+ mentions: (item.mentions ?? []).map(mention => ({ ...mention, id: { open_id: mention?.id } })) },
73
+ }, identity); } catch (error) {
74
+ if (error?.code !== 'invalid-inbound') throw error;
75
+ ++omitted; continue;
76
+ }
77
+ if (!event) { ++omitted; continue; }
78
+ events.push(event);
79
+ }
80
+ const nextCursor = response.data.has_more ? response.data.page_token : undefined;
81
+ if (response.data.has_more && (typeof nextCursor !== 'string' || !nextCursor || nextCursor.length > 4096))
82
+ refuse('history-unavailable');
83
+ return {events, omitted, hasMore:response.data.has_more, ...(nextCursor ? {nextCursor} : {})};
84
+ };
85
+ if (query.scope === 'nearby') return readNearbyHistory(readPage, route, query, Number(source.create_time));
86
+ if (query.beforeCount !== undefined || query.afterCount !== undefined) refuse('bad-request');
87
+ const page = await readPage({container_id_type:query.scope === 'thread' ? 'thread' : 'chat',
88
+ container_id:query.scope === 'thread' ? route.threadId : route.conversationId,
89
+ sort_type:'ByCreateTimeDesc', page_size:query.limit, with_sender_name:true,
90
+ ...(query.cursor === undefined ? {} : {page_token:query.cursor})});
91
+ return {version:1, scope:query.scope, ...page, coverage:'provider-visible-human-text'};
92
+ }