@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.
- package/PROACTIVE_DELIVERY.en.md +50 -0
- package/PROACTIVE_DELIVERY.md +22 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +4 -3
- package/lib/index.js +311 -310
- package/package.json +3 -3
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +4 -2
- package/plugin-src/host/channels/matrix/production.mjs +1 -1
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/delivery-adapter.mjs +60 -12
- package/plugin-src/host/delivery-service.mjs +127 -1
- package/plugin-src/host/delivery-suggestions.mjs +2 -1
- package/plugin-src/host/harness-connection.mjs +3 -7
- package/plugin-src/host/index.mjs +5 -2
- package/plugin-src/host/modern-harness-api.mjs +139 -4
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +14 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +10 -0
- package/src/channels/discord/discord-runtime.mjs +13 -0
- package/src/channels/feishu/bridge.mjs +30 -13
- package/src/channels/feishu/external-consumer.mjs +118 -0
- package/src/channels/feishu/feishu-runtime.mjs +72 -3
- package/src/channels/feishu/history-reader.mjs +92 -0
- package/src/channels/feishu/multi-bot-controller.mjs +130 -2
- package/src/channels/feishu/nearby-history.mjs +72 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/matrix/matrix-controller.mjs +9 -0
- package/src/channels/matrix/matrix-runtime.mjs +14 -0
- package/src/channels/qq/qq-bridge.mjs +8 -0
- package/src/channels/qq/qq-controller.mjs +14 -0
- package/src/channels/qq/qq-runtime.mjs +10 -0
- package/src/channels/shared/deferred-delivery-coordinator.mjs +2 -1
- package/src/channels/shared/deferred-delivery.mjs +8 -5
- package/src/channels/shared/harness-approval.mjs +84 -7
- package/src/channels/shared/harness-client.mjs +152 -42
- package/src/channels/shared/i18n-en/shared-a.mjs +6 -2
- package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
- package/src/channels/shared/message-failure.mjs +6 -2
- package/src/channels/shared/text-harness-bridge.mjs +6 -0
- package/src/channels/shared/token-bot-controller.mjs +16 -0
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +7 -0
- package/src/channels/slack/slack-controller.mjs +14 -0
- package/src/channels/slack/slack-runtime.mjs +12 -0
- package/src/channels/telegram/telegram-runtime.mjs +10 -0
- package/src/channels/wecom/wecom-bridge.mjs +6 -0
- package/src/channels/wecom/wecom-controller.mjs +14 -0
- package/src/channels/wecom/wecom-runtime.mjs +10 -0
- package/src/channels/wecom-app/wecom-app-bridge.mjs +6 -0
- package/src/channels/wecom-app/wecom-app-controller.mjs +14 -0
- package/src/channels/wecom-app/wecom-app-runtime.mjs +10 -0
- package/src/channels/weixin/weixin-bridge.mjs +6 -0
- package/src/channels/weixin/weixin-controller.mjs +14 -0
- package/src/channels/weixin/weixin-runtime.mjs +10 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +14 -0
- 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
|
-
|
|
6047
|
-
|
|
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
|
-
|
|
6051
|
+
return {
|
|
6056
6052
|
key,
|
|
6057
6053
|
actor,
|
|
6058
6054
|
requiresMention,
|
|
6059
|
-
send
|
|
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
|
|
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
|
-
}
|
|
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
|
+
}
|