@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.
- 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 +30 -8
- package/lib/index.js +309 -309
- package/package.json +3 -3
- package/plugin-src/client/connection-error.js +2 -1
- 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 +6 -0
- package/src/channels/qq/qq-controller.mjs +14 -0
- package/src/channels/qq/qq-runtime.mjs +10 -0
- package/src/channels/shared/connection-error.mjs +1 -0
- package/src/channels/shared/diagnostic-details.mjs +7 -1
- package/src/channels/shared/harness-approval.mjs +84 -7
- package/src/channels/shared/harness-client.mjs +51 -40
- package/src/channels/shared/i18n-en/diagnostics.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
- package/src/channels/shared/message-failure.mjs +1 -1
- 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
|
@@ -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())
|
|
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)
|
|
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
|
-
|
|
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
|
*
|