@xmanrui/dsh-im 4.35.1 → 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 +309 -309
- 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 +6 -0
- package/src/channels/qq/qq-controller.mjs +14 -0
- package/src/channels/qq/qq-runtime.mjs +10 -0
- package/src/channels/shared/harness-approval.mjs +84 -7
- package/src/channels/shared/harness-client.mjs +36 -32
- package/src/channels/shared/i18n-en/shared-c.mjs +3 -0
- 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,3 +1,4 @@
|
|
|
1
|
+
import { ExclusiveInboundConsumers, normalizeExternalText } from './external-consumer.mjs';
|
|
1
2
|
import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
2
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
4
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
@@ -93,6 +94,7 @@ function secretRefFor(botId) {
|
|
|
93
94
|
function configuredBotFingerprint(config) {
|
|
94
95
|
return JSON.stringify({
|
|
95
96
|
id: config.id,
|
|
97
|
+
consumerMode: config.consumerMode ?? 'standalone',
|
|
96
98
|
appId: config.appId,
|
|
97
99
|
secretRef: config.secretRef,
|
|
98
100
|
ownerOpenIds: config.ownerOpenIds,
|
|
@@ -113,6 +115,14 @@ function configuredBotFingerprint(config) {
|
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
// Account/ownership fences must not include settings that are hot-updated on
|
|
119
|
+
// the same runtime (step push, voice, slash panel or group presentation).
|
|
120
|
+
function externalAccountOwnership(config) {
|
|
121
|
+
return JSON.stringify({ id: config.id, consumerMode: config.consumerMode ?? 'standalone',
|
|
122
|
+
appId: config.appId, secretRef: config.secretRef, ownerOpenIds: config.ownerOpenIds,
|
|
123
|
+
domain: config.domain, botOpenId: config.botOpenId, deletionPending: config.deletionPending === true });
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
function optionalNonEmptyString(value) {
|
|
117
127
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
118
128
|
}
|
|
@@ -133,6 +143,7 @@ export class MultiBotDshFeishuController {
|
|
|
133
143
|
#createBotId;
|
|
134
144
|
#createRegistrationId;
|
|
135
145
|
#runtimes = new Map();
|
|
146
|
+
#inboundConsumers = new ExclusiveInboundConsumers();
|
|
136
147
|
#botErrors = new Map();
|
|
137
148
|
#registrations = new Map();
|
|
138
149
|
#activeAppUpdates = new Map();
|
|
@@ -594,7 +605,82 @@ export class MultiBotDshFeishuController {
|
|
|
594
605
|
this.#assertOpen('capability-unavailable');
|
|
595
606
|
return { version: 1, botId, channel: 'feishu', account,
|
|
596
607
|
connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
|
|
597
|
-
capabilities: ['proactive-text-checked', 'proactive-receipt-checked'] };
|
|
608
|
+
capabilities: ['proactive-text-checked', 'proactive-receipt-checked', 'exclusive-text-consumer', 'reply-text-checked', 'history-text-checked', 'thread-history-text-checked'] };
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async consumeInbound(botId, { expectedFingerprint, onEvent, signal } = {}) {
|
|
613
|
+
this.#assertOpen();
|
|
614
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
615
|
+
this.#assertOpen();
|
|
616
|
+
signal?.throwIfAborted();
|
|
617
|
+
const config = this.#requireBot(botId);
|
|
618
|
+
const account = await this.#deliveryAccount(config);
|
|
619
|
+
this.#assertOpen('capability-unavailable');
|
|
620
|
+
signal?.throwIfAborted();
|
|
621
|
+
if (account.fingerprint !== expectedFingerprint)
|
|
622
|
+
throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
|
|
623
|
+
const dispose = this.#inboundConsumers.register(botId, { fingerprint: expectedFingerprint, onEvent, signal });
|
|
624
|
+
try {
|
|
625
|
+
const saved = await this.#configStore.saveBot({ ...config, consumerMode: 'external-consumer' });
|
|
626
|
+
this.#assertOpen('capability-unavailable');
|
|
627
|
+
signal?.throwIfAborted();
|
|
628
|
+
const resolved = await this.#credentials.resolve(saved.secretRef);
|
|
629
|
+
this.#assertOpen('capability-unavailable');
|
|
630
|
+
signal?.throwIfAborted();
|
|
631
|
+
if (!resolved?.value) throw Object.assign(new Error('account-unverified'), { code: 'account-unverified' });
|
|
632
|
+
await this.#startRuntime(saved, resolved.value);
|
|
633
|
+
signal?.throwIfAborted();
|
|
634
|
+
this.#touch();
|
|
635
|
+
return dispose;
|
|
636
|
+
} catch (error) {
|
|
637
|
+
dispose();
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
}));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async historyChecked(botId, route, query, { expectedFingerprint, signal } = {}) {
|
|
644
|
+
this.#assertOpen('capability-unavailable');
|
|
645
|
+
return this.#withBotTransition(botId, async () => {
|
|
646
|
+
this.#assertOpen('capability-unavailable');
|
|
647
|
+
signal?.throwIfAborted();
|
|
648
|
+
const config = this.#requireBot(botId);
|
|
649
|
+
const account = await this.#deliveryAccount(config);
|
|
650
|
+
this.#assertOpen('capability-unavailable');
|
|
651
|
+
signal?.throwIfAborted();
|
|
652
|
+
if (account.fingerprint !== expectedFingerprint)
|
|
653
|
+
throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
|
|
654
|
+
const runtime = this.#runtimes.get(botId);
|
|
655
|
+
if (config.consumerMode !== 'external-consumer' || !isConnected(connectionStatus(runtime))
|
|
656
|
+
|| typeof runtime.historyChecked !== 'function')
|
|
657
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
658
|
+
const leaseSignal = this.#inboundConsumers.signalFor(botId, expectedFingerprint);
|
|
659
|
+
const readSignal = signal ? AbortSignal.any([signal, leaseSignal]) : leaseSignal;
|
|
660
|
+
const result = await runtime.historyChecked({ botId, appId: config.appId,
|
|
661
|
+
botOpenId: config.botOpenId, fingerprint: account.fingerprint }, route, query, { signal: readSignal });
|
|
662
|
+
this.#assertOpen('capability-unavailable');
|
|
663
|
+
readSignal.throwIfAborted();
|
|
664
|
+
return result;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async replyChecked(botId, route, text, { expectedFingerprint, signal } = {}) {
|
|
669
|
+
this.#assertOpen();
|
|
670
|
+
return this.#withBotTransition(botId, async () => {
|
|
671
|
+
this.#assertOpen();
|
|
672
|
+
const config = this.#requireBot(botId);
|
|
673
|
+
const account = await this.#deliveryAccount(config);
|
|
674
|
+
this.#assertOpen('capability-unavailable');
|
|
675
|
+
signal?.throwIfAborted();
|
|
676
|
+
if (account.fingerprint !== expectedFingerprint)
|
|
677
|
+
throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
|
|
678
|
+
signal?.throwIfAborted();
|
|
679
|
+
const runtime = this.#runtimes.get(botId);
|
|
680
|
+
if (config.consumerMode !== 'external-consumer' || !isConnected(connectionStatus(runtime))
|
|
681
|
+
|| typeof runtime.replyChecked !== 'function')
|
|
682
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
683
|
+
return runtime.replyChecked(route, text, { signal });
|
|
598
684
|
});
|
|
599
685
|
}
|
|
600
686
|
|
|
@@ -628,6 +714,18 @@ export class MultiBotDshFeishuController {
|
|
|
628
714
|
});
|
|
629
715
|
}
|
|
630
716
|
|
|
717
|
+
async presentSessionSyncApproval(botId, target, interaction, options = {}) {
|
|
718
|
+
this.#assertOpen();
|
|
719
|
+
return this.#withBotTransition(botId, async () => {
|
|
720
|
+
this.#assertOpen();
|
|
721
|
+
this.#requireBot(botId);
|
|
722
|
+
const runtime = this.#runtimes.get(botId);
|
|
723
|
+
if (!isConnected(connectionStatus(runtime))
|
|
724
|
+
|| typeof runtime.presentSessionSyncApproval !== 'function') return false;
|
|
725
|
+
return runtime.presentSessionSyncApproval(target, interaction, options);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
631
729
|
async disconnectBot(botId) {
|
|
632
730
|
this.#assertOpen();
|
|
633
731
|
// An operational pause only: credentials/config remain durable, so the
|
|
@@ -761,6 +859,7 @@ export class MultiBotDshFeishuController {
|
|
|
761
859
|
async close() {
|
|
762
860
|
if (this.#closed) return;
|
|
763
861
|
this.#closed = true;
|
|
862
|
+
this.#inboundConsumers.close();
|
|
764
863
|
const appUpdateProcessing = [];
|
|
765
864
|
for (const record of this.#registrations.values()) {
|
|
766
865
|
const state = record.manager.status().state;
|
|
@@ -1352,11 +1451,39 @@ export class MultiBotDshFeishuController {
|
|
|
1352
1451
|
|
|
1353
1452
|
async #startRuntime(config, appSecret) {
|
|
1354
1453
|
await this.#stopRuntime(config.id);
|
|
1355
|
-
|
|
1454
|
+
let runtime;
|
|
1455
|
+
runtime = await atConnectionStage('runtime.prepare', () => this.#createRuntime({
|
|
1356
1456
|
botId: config.id,
|
|
1357
1457
|
config,
|
|
1358
1458
|
appSecret,
|
|
1359
1459
|
repair: this.#runtimeRepairCapability(config.id),
|
|
1460
|
+
acceptExternal: async (event, { signal } = {}) => {
|
|
1461
|
+
const assertCurrent = () => {
|
|
1462
|
+
this.#assertOpen('capability-unavailable');
|
|
1463
|
+
signal?.throwIfAborted();
|
|
1464
|
+
if (this.#runtimes.get(config.id) !== runtime)
|
|
1465
|
+
throw Object.assign(new Error('consumer-unavailable'), { code: 'consumer-unavailable' });
|
|
1466
|
+
const current = this.#requireBot(config.id);
|
|
1467
|
+
if (current.consumerMode !== 'external-consumer'
|
|
1468
|
+
|| externalAccountOwnership(current) !== externalAccountOwnership(config))
|
|
1469
|
+
throw Object.assign(new Error('account-changed'), { code: 'account-changed' });
|
|
1470
|
+
return current;
|
|
1471
|
+
};
|
|
1472
|
+
const evidence = await this.#withBotTransition(config.id, async () => {
|
|
1473
|
+
const current = assertCurrent();
|
|
1474
|
+
const account = await this.#deliveryAccount(current);
|
|
1475
|
+
assertCurrent();
|
|
1476
|
+
return normalizeExternalText(event, { botId: current.id, appId: current.appId,
|
|
1477
|
+
botOpenId: current.botOpenId, fingerprint: account.fingerprint });
|
|
1478
|
+
});
|
|
1479
|
+
// Never hold the account queue across application work: onEvent may
|
|
1480
|
+
// await a checked read/reply, and stop must be able to abort its signal.
|
|
1481
|
+
assertCurrent();
|
|
1482
|
+
if (evidence === null) return { accepted: true, ignored: true };
|
|
1483
|
+
const result = await this.#inboundConsumers.accept(config.id, evidence, signal);
|
|
1484
|
+
assertCurrent();
|
|
1485
|
+
return result;
|
|
1486
|
+
},
|
|
1360
1487
|
}));
|
|
1361
1488
|
this.#runtimes.set(config.id, runtime);
|
|
1362
1489
|
try {
|
|
@@ -1398,6 +1525,7 @@ export class MultiBotDshFeishuController {
|
|
|
1398
1525
|
async #deleteBot(botId) {
|
|
1399
1526
|
let config = this.#configStore.getBot(botId);
|
|
1400
1527
|
if (!config) return;
|
|
1528
|
+
this.#inboundConsumers.remove(botId);
|
|
1401
1529
|
if (!config.deletionPending) {
|
|
1402
1530
|
config = await this.#configStore.saveBot({ ...config, deletionPending: true });
|
|
1403
1531
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
function refuse(code) { throw Object.assign(new Error(code), { code }); }
|
|
2
|
+
const count = value => Number.isInteger(value) && value >= 0 && value <= 20;
|
|
3
|
+
|
|
4
|
+
export async function readNearbyHistory(readPage, route, query, anchorTime) {
|
|
5
|
+
const before = query.beforeCount ?? 10;
|
|
6
|
+
const after = query.afterCount ?? 5;
|
|
7
|
+
if (!count(before) || !count(after)) refuse('bad-request');
|
|
8
|
+
const start = Math.max(0, anchorTime - 300000);
|
|
9
|
+
const end = anchorTime + 300000;
|
|
10
|
+
const window = { start: Math.floor(start / 1000), end: Math.floor(end / 1000) + 1 };
|
|
11
|
+
let state = { phase: 'window', before: 0, after: 0 };
|
|
12
|
+
if (query.cursor !== undefined) {
|
|
13
|
+
try {
|
|
14
|
+
const decoded = JSON.parse(Buffer.from(query.cursor, 'base64url').toString());
|
|
15
|
+
if (decoded.version !== 1 || decoded.anchor !== route.messageId || decoded.at !== anchorTime
|
|
16
|
+
|| decoded.minBefore !== before || decoded.minAfter !== after || decoded.limit !== query.limit
|
|
17
|
+
|| !['window', 'before', 'after'].includes(decoded.phase)
|
|
18
|
+
|| !count(decoded.before) || !count(decoded.after)
|
|
19
|
+
|| (decoded.page !== undefined && (typeof decoded.page !== 'string' || !decoded.page || decoded.page.length > 3000)))
|
|
20
|
+
refuse('bad-request');
|
|
21
|
+
state = decoded;
|
|
22
|
+
} catch { refuse('bad-request'); }
|
|
23
|
+
}
|
|
24
|
+
const params = { container_id_type: 'chat', container_id: route.conversationId,
|
|
25
|
+
page_size: query.limit, with_sender_name: true,
|
|
26
|
+
sort_type: state.phase === 'before' ? 'ByCreateTimeDesc' : 'ByCreateTimeAsc',
|
|
27
|
+
...(state.page === undefined ? {} : { page_token: state.page }),
|
|
28
|
+
...(state.phase === 'window' ? { start_time: String(window.start), end_time: String(window.end) }
|
|
29
|
+
: state.phase === 'before' ? { end_time: String(window.start + 1) }
|
|
30
|
+
: { start_time: String(Math.floor(end / 1000)) }),
|
|
31
|
+
};
|
|
32
|
+
// Lark defaults an omitted end_time to now and rejects a future start_time.
|
|
33
|
+
// A fresh anchor has no existing messages beyond its future window boundary.
|
|
34
|
+
const page = state.phase === 'after' && end >= Date.now()
|
|
35
|
+
? { events: [], omitted: 0, hasMore: false }
|
|
36
|
+
: await readPage(params);
|
|
37
|
+
const events = [];
|
|
38
|
+
for (const event of page.events) {
|
|
39
|
+
const at = Date.parse(event.at);
|
|
40
|
+
if (!Number.isFinite(at)) refuse('history-unavailable');
|
|
41
|
+
if (state.phase === 'window') {
|
|
42
|
+
if (at < start || at > end) continue;
|
|
43
|
+
events.push(event);
|
|
44
|
+
if (event.messageId !== route.messageId && at < anchorTime) state.before = Math.min(20, state.before + 1);
|
|
45
|
+
if (event.messageId !== route.messageId && at > anchorTime) state.after = Math.min(20, state.after + 1);
|
|
46
|
+
} else if (state.phase === 'before' && at < start && state.before < before) {
|
|
47
|
+
events.push(event); state.before++;
|
|
48
|
+
} else if (state.phase === 'after' && at > end && state.after < after) {
|
|
49
|
+
events.push(event); state.after++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const needsPage = page.hasMore && (state.phase === 'window'
|
|
53
|
+
|| (state.phase === 'before' ? state.before < before : state.after < after));
|
|
54
|
+
if (needsPage) {
|
|
55
|
+
if (page.nextCursor === state.page) refuse('history-unavailable');
|
|
56
|
+
state.page = page.nextCursor;
|
|
57
|
+
} else {
|
|
58
|
+
delete state.page;
|
|
59
|
+
if (state.phase === 'window' && state.before < before) state.phase = 'before';
|
|
60
|
+
else if (state.phase !== 'after' && state.after < after) state.phase = 'after';
|
|
61
|
+
else state.phase = 'done';
|
|
62
|
+
}
|
|
63
|
+
let nextCursor;
|
|
64
|
+
if (state.phase !== 'done') {
|
|
65
|
+
nextCursor = Buffer.from(JSON.stringify({version:1, anchor:route.messageId, at:anchorTime,
|
|
66
|
+
minBefore:before, minAfter:after, limit:query.limit, phase:state.phase,
|
|
67
|
+
before:state.before, after:state.after, ...(state.page ? {page:state.page} : {})})).toString('base64url');
|
|
68
|
+
if (nextCursor.length > 4096) refuse('history-unavailable');
|
|
69
|
+
}
|
|
70
|
+
return {version:1, scope:'nearby', events, omitted:page.omitted, hasMore:!!nextCursor,
|
|
71
|
+
...(nextCursor ? {nextCursor} : {}), window, coverage:'provider-visible-human-text'};
|
|
72
|
+
}
|
|
@@ -40,6 +40,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
40
40
|
const domain = value.domain === 'lark' ? 'lark' : 'feishu';
|
|
41
41
|
return Object.freeze({
|
|
42
42
|
id,
|
|
43
|
+
consumerMode: value.consumerMode === 'external-consumer' ? 'external-consumer' : 'standalone',
|
|
43
44
|
appId,
|
|
44
45
|
secretRef,
|
|
45
46
|
ownerOpenIds: Object.freeze(ownerOpenIds),
|
|
@@ -224,6 +224,15 @@ export class MatrixController {
|
|
|
224
224
|
return { sent: true };
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
async presentSessionSyncApproval(botId, target, interaction, options = {}) {
|
|
228
|
+
this.#requireId(botId);
|
|
229
|
+
const runtime = this.#runtimes.get(botId);
|
|
230
|
+
if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
|
|
231
|
+
throw new Error('Matrix bot is not connected for proactive delivery');
|
|
232
|
+
}
|
|
233
|
+
return runtime.presentSessionSyncApproval(target, interaction, options);
|
|
234
|
+
}
|
|
235
|
+
|
|
227
236
|
async deleteBot(botId) {
|
|
228
237
|
this.#requireId(botId);
|
|
229
238
|
const removed = await this.#withBotTransition(botId, async () => {
|
|
@@ -173,6 +173,7 @@ export class MatrixRuntime {
|
|
|
173
173
|
#replyTimeoutMs;
|
|
174
174
|
#api = null;
|
|
175
175
|
#bridge = null;
|
|
176
|
+
#approvalController = new AbortController();
|
|
176
177
|
#botUserId = null;
|
|
177
178
|
#deviceId = null;
|
|
178
179
|
#crypto = null;
|
|
@@ -289,6 +290,7 @@ export class MatrixRuntime {
|
|
|
289
290
|
async start() {
|
|
290
291
|
if (this.#started && this.#status.ready) return;
|
|
291
292
|
this.#stopped = false;
|
|
293
|
+
this.#approvalController = new AbortController();
|
|
292
294
|
this.#started = true;
|
|
293
295
|
const generation = ++this.#generation;
|
|
294
296
|
const homeserver = this.#config.homeserver;
|
|
@@ -361,6 +363,7 @@ export class MatrixRuntime {
|
|
|
361
363
|
async stop() {
|
|
362
364
|
if (this.#stopped) return;
|
|
363
365
|
this.#stopped = true;
|
|
366
|
+
this.#approvalController.abort();
|
|
364
367
|
this.#started = false;
|
|
365
368
|
this.#generation += 1;
|
|
366
369
|
if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
|
|
@@ -389,6 +392,17 @@ export class MatrixRuntime {
|
|
|
389
392
|
return await bridge.sendConnectionTest(text);
|
|
390
393
|
}
|
|
391
394
|
|
|
395
|
+
async presentSessionSyncApproval(target, interaction, options = {}) {
|
|
396
|
+
if (!this.#status.ready || !this.#bridge) return false;
|
|
397
|
+
const actor = typeof target?.route?.userId === 'string' ? target.route.userId.trim() : '';
|
|
398
|
+
if (!actor) return false;
|
|
399
|
+
return this.#bridge.presentSessionSyncApproval(interaction, {
|
|
400
|
+
key: options.key.startsWith('dm:') ? `direct:${options.key}` : options.key,
|
|
401
|
+
actor, validate: options.validate,
|
|
402
|
+
send: (text) => this.sendProactiveText(target, text),
|
|
403
|
+
}, { ...options, runtimeSignal: this.#approvalController.signal });
|
|
404
|
+
}
|
|
405
|
+
|
|
392
406
|
async sendProactiveText(target, text, options = {}) {
|
|
393
407
|
const api = this.#api;
|
|
394
408
|
if (!api) throw new Error('Matrix bot is not connected');
|
|
@@ -1337,6 +1337,12 @@ export class QqHarnessBridge {
|
|
|
1337
1337
|
}
|
|
1338
1338
|
}
|
|
1339
1339
|
|
|
1340
|
+
async presentSessionSyncApproval(interaction, context, options = {}) {
|
|
1341
|
+
return this.#approvals.handleSessionSyncRequested(interaction, context, {
|
|
1342
|
+
...options, runtimeSignal: this.#signal, accessPolicy: this.#accessPolicy,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1340
1346
|
async #handleInteraction(interaction, {
|
|
1341
1347
|
key,
|
|
1342
1348
|
actor,
|
|
@@ -297,6 +297,20 @@ export class QqController {
|
|
|
297
297
|
});
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
async presentSessionSyncApproval(botId, target, interaction, options = {}) {
|
|
301
|
+
const config = this.#configStore.get(botId);
|
|
302
|
+
if (!config) throw new Error('Unknown QQ bot');
|
|
303
|
+
return this.#withBotTransition(botId, async () => {
|
|
304
|
+
const runtime = this.#runtimes.get(botId);
|
|
305
|
+
if (!runtime?.status?.ready || typeof runtime.presentSessionSyncApproval !== 'function') {
|
|
306
|
+
const error = new Error(t('QQ机器人尚未连接'));
|
|
307
|
+
error.code = 'bot-not-connected';
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
return runtime.presentSessionSyncApproval(target, interaction, options);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
300
314
|
async deleteBot(botId) {
|
|
301
315
|
const warnings = [];
|
|
302
316
|
const config = this.#configStore.get(botId);
|
|
@@ -104,6 +104,16 @@ export class QqRuntime {
|
|
|
104
104
|
return { sent: true };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
async presentSessionSyncApproval(target, interaction, options = {}) {
|
|
108
|
+
if (!this.#status.ready || !this.#bridge) return false;
|
|
109
|
+
const actor = typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '';
|
|
110
|
+
if (!actor) return false;
|
|
111
|
+
return this.#bridge.presentSessionSyncApproval(interaction, {
|
|
112
|
+
key: options.key, actor, validate: options.validate,
|
|
113
|
+
send: (text) => this.sendProactiveText(target, text),
|
|
114
|
+
}, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
async sendProactiveText(target, text, { signal } = {}) {
|
|
108
118
|
const nativeId = target?.kind === 'user'
|
|
109
119
|
? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { t } from './i18n.mjs';
|
|
2
|
+
import { evaluateInboundAccess } from './inbound-access.mjs';
|
|
2
3
|
|
|
3
4
|
const APPROVAL_REPLIES = new Map([
|
|
4
5
|
['批准', 'allowed-once'],
|
|
@@ -9,6 +10,12 @@ const APPROVAL_REPLIES = new Map([
|
|
|
9
10
|
['no', 'rejected'],
|
|
10
11
|
]);
|
|
11
12
|
|
|
13
|
+
export const COMPETITIVE_APPROVAL_CHANNELS = Object.freeze([
|
|
14
|
+
'feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
|
|
15
|
+
'telegram', 'slack', 'discord', 'whatsapp', 'matrix',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const APPROVAL_UNAVAILABLE_TEXT = '机器人端无法处理这次审批,请到 Web 查看。';
|
|
12
19
|
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
|
|
13
20
|
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
|
|
14
21
|
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
|
|
@@ -95,6 +102,7 @@ function approvalResult(pending, outcome) {
|
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
function approvalOutcomeText(outcome) {
|
|
105
|
+
if (outcome === 'unavailable') return t(APPROVAL_UNAVAILABLE_TEXT);
|
|
98
106
|
if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
|
|
99
107
|
if (outcome === 'rejected') return t('已拒绝此次操作。');
|
|
100
108
|
return t(APPROVAL_RESOLVED_TEXT);
|
|
@@ -210,6 +218,51 @@ export class HarnessApprovalQueue {
|
|
|
210
218
|
};
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
/** Present a bound private-chat approval without creating an IM prompt. */
|
|
222
|
+
async handleSessionSyncRequested(interaction, context, { signal, completion, runtimeSignal, accessPolicy } = {}) {
|
|
223
|
+
if (signal?.aborted || runtimeSignal?.aborted) return false;
|
|
224
|
+
const validateRoute = context.validate;
|
|
225
|
+
context = { ...context, validate: async () => {
|
|
226
|
+
await validateRoute?.();
|
|
227
|
+
if (!context.actor || !evaluateInboundAccess(accessPolicy, {
|
|
228
|
+
conversationType: 'direct', senderIds: [context.actor], text: 'yes',
|
|
229
|
+
}).allowed) throw new Error('Approval recipient is no longer allowed');
|
|
230
|
+
} };
|
|
231
|
+
await context.validate();
|
|
232
|
+
if (signal?.aborted || runtimeSignal?.aborted) return false;
|
|
233
|
+
let resolved = false;
|
|
234
|
+
const finish = (outcome) => this.handleResolved({
|
|
235
|
+
kind: 'approval', interactionId: interaction.interactionId, outcome,
|
|
236
|
+
});
|
|
237
|
+
const onStop = () => {
|
|
238
|
+
queueMicrotask(async () => {
|
|
239
|
+
if (resolved) return;
|
|
240
|
+
try {
|
|
241
|
+
await interaction.withdraw?.();
|
|
242
|
+
await finish('unavailable');
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to withdraw a stopped approval:`, error);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
runtimeSignal?.addEventListener('abort', onStop, { once: true });
|
|
249
|
+
Promise.resolve(completion).then(async (outcome) => {
|
|
250
|
+
resolved = true;
|
|
251
|
+
runtimeSignal?.removeEventListener('abort', onStop);
|
|
252
|
+
await finish(outcome);
|
|
253
|
+
}).catch((error) => this.#logger.warn?.(`[dsh-im:${this.#label}] failed to retire a synced approval:`, error));
|
|
254
|
+
try {
|
|
255
|
+
await this.handleRequested(interaction, context);
|
|
256
|
+
if (runtimeSignal?.aborted) onStop();
|
|
257
|
+
return this.#byId.has(interaction.interactionId);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
runtimeSignal?.removeEventListener('abort', onStop);
|
|
260
|
+
await interaction.withdraw?.();
|
|
261
|
+
await finish('unavailable');
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
213
266
|
async handleRequested(interaction, context) {
|
|
214
267
|
if (interaction?.kind !== 'approval') return false;
|
|
215
268
|
const payload = interaction.payload;
|
|
@@ -257,7 +310,7 @@ export class HarnessApprovalQueue {
|
|
|
257
310
|
if (!text) {
|
|
258
311
|
const rejected = await this.#rejectInteraction(interaction, payload);
|
|
259
312
|
await send(rejected
|
|
260
|
-
? t('无法完整展示这次操作,已安全拒绝此次审批。')
|
|
313
|
+
? (interaction.withdraw ? t(APPROVAL_UNAVAILABLE_TEXT) : t('无法完整展示这次操作,已安全拒绝此次审批。'))
|
|
261
314
|
: t(APPROVAL_RESOLVED_TEXT));
|
|
262
315
|
return true;
|
|
263
316
|
}
|
|
@@ -272,6 +325,7 @@ export class HarnessApprovalQueue {
|
|
|
272
325
|
requiresMention: context.requiresMention === true,
|
|
273
326
|
send,
|
|
274
327
|
render,
|
|
328
|
+
validate: context.validate,
|
|
275
329
|
onResolved: typeof context.onResolved === 'function' ? context.onResolved : null,
|
|
276
330
|
text,
|
|
277
331
|
presented: false,
|
|
@@ -342,13 +396,14 @@ export class HarnessApprovalQueue {
|
|
|
342
396
|
for (const pending of pendingItems) this.#remove(pending);
|
|
343
397
|
await Promise.all(pendingItems.map(async (pending) => {
|
|
344
398
|
try {
|
|
345
|
-
await pending.interaction.
|
|
399
|
+
if (pending.interaction.withdraw) await pending.interaction.withdraw();
|
|
400
|
+
else await pending.interaction.respond(
|
|
346
401
|
approvalResult(pending, 'rejected'),
|
|
347
402
|
{ signal: AbortSignal.timeout(5_000) },
|
|
348
403
|
);
|
|
349
|
-
pending.closedOutcome = 'rejected';
|
|
404
|
+
pending.closedOutcome = pending.interaction.withdraw ? 'unavailable' : 'rejected';
|
|
350
405
|
if (pending.presented || pending.deliveryCompleted) {
|
|
351
|
-
await this.#notifyResolved(pending,
|
|
406
|
+
await this.#notifyResolved(pending, pending.closedOutcome);
|
|
352
407
|
}
|
|
353
408
|
} catch (error) {
|
|
354
409
|
if (error?.code === 'interaction-not-pending') {
|
|
@@ -372,9 +427,12 @@ export class HarnessApprovalQueue {
|
|
|
372
427
|
if (pending.presentationTask) return pending.presentationTask;
|
|
373
428
|
// A channel-provided renderer shows the approval as an interactive card
|
|
374
429
|
// (e.g. approve/reject buttons); otherwise fall back to plain text.
|
|
375
|
-
const task =
|
|
376
|
-
|
|
377
|
-
|
|
430
|
+
const task = Promise.resolve().then(async () => {
|
|
431
|
+
await pending.validate?.();
|
|
432
|
+
if (pending.inactive || pending.resolving) return;
|
|
433
|
+
if (pending.render) await pending.render(pending, pending.send);
|
|
434
|
+
else await pending.send(pending.text);
|
|
435
|
+
});
|
|
378
436
|
pending.presentationTask = task;
|
|
379
437
|
try {
|
|
380
438
|
await task;
|
|
@@ -384,6 +442,12 @@ export class HarnessApprovalQueue {
|
|
|
384
442
|
} else if (pending.closedOutcome) {
|
|
385
443
|
await this.#notifyResolved(pending, pending.closedOutcome);
|
|
386
444
|
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (!pending.interaction.withdraw) throw error;
|
|
447
|
+
pending.presentationTask = null;
|
|
448
|
+
await pending.interaction.withdraw();
|
|
449
|
+
await this.handleResolved({ kind: 'approval', interactionId: pending.approvalId, outcome: 'unavailable' });
|
|
450
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] approval presentation unavailable:`, error);
|
|
387
451
|
} finally {
|
|
388
452
|
if (pending.presentationTask === task) pending.presentationTask = null;
|
|
389
453
|
}
|
|
@@ -392,6 +456,15 @@ export class HarnessApprovalQueue {
|
|
|
392
456
|
async #submit(pending, outcome) {
|
|
393
457
|
pending.submitting = true;
|
|
394
458
|
try {
|
|
459
|
+
if (pending.validate) {
|
|
460
|
+
try { await pending.validate(); }
|
|
461
|
+
catch (error) {
|
|
462
|
+
await pending.interaction.withdraw?.();
|
|
463
|
+
throw Object.assign(new Error('Approval route is no longer available', { cause: error }), {
|
|
464
|
+
code: 'interaction-not-pending',
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
395
468
|
await pending.interaction.respond(approvalResult(pending, outcome));
|
|
396
469
|
} catch (error) {
|
|
397
470
|
if (error?.code === 'interaction-not-pending') {
|
|
@@ -491,6 +564,10 @@ export class HarnessApprovalQueue {
|
|
|
491
564
|
|
|
492
565
|
async #rejectInteraction(interaction, payload) {
|
|
493
566
|
try {
|
|
567
|
+
if (interaction.withdraw) {
|
|
568
|
+
await interaction.withdraw();
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
494
571
|
await interaction.respond({
|
|
495
572
|
ok: true,
|
|
496
573
|
value: {
|
|
@@ -504,33 +504,9 @@ function consumeInteractionOwnership(ownership, entries) {
|
|
|
504
504
|
ownership.completed = true;
|
|
505
505
|
continue;
|
|
506
506
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
&& event.data?.turn === ownership.turn
|
|
511
|
-
&& typeof event.data?.callId === 'string'
|
|
512
|
-
&& event.data.callId) {
|
|
513
|
-
toolCall = {
|
|
514
|
-
callId: event.data.callId,
|
|
515
|
-
name: event.data?.name,
|
|
516
|
-
arguments: event.data?.arguments,
|
|
517
|
-
};
|
|
518
|
-
} else if (event.type === 'tool/code-dispatch-start'
|
|
519
|
-
&& ownership.active
|
|
520
|
-
&& typeof event.data?.subCallId === 'string'
|
|
521
|
-
&& event.data.subCallId) {
|
|
522
|
-
let argumentsText;
|
|
523
|
-
try {
|
|
524
|
-
argumentsText = JSON.stringify(event.data?.arguments);
|
|
525
|
-
} catch {
|
|
526
|
-
argumentsText = undefined;
|
|
527
|
-
}
|
|
528
|
-
toolCall = {
|
|
529
|
-
callId: event.data.subCallId,
|
|
530
|
-
name: event.data?.name,
|
|
531
|
-
arguments: argumentsText,
|
|
532
|
-
};
|
|
533
|
-
}
|
|
507
|
+
const toolCall = ownership.active
|
|
508
|
+
&& (event.type !== 'tool/call' || event.data?.turn === ownership.turn)
|
|
509
|
+
? harnessToolCallFromEvent(event) : null;
|
|
534
510
|
if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall));
|
|
535
511
|
}
|
|
536
512
|
}
|
|
@@ -540,17 +516,36 @@ function consumeInteractionOwnership(ownership, entries) {
|
|
|
540
516
|
* this Session. The modern Harness adapter uses the same ownership registry
|
|
541
517
|
* as HarnessClient so it never steals a browser-owned question or approval.
|
|
542
518
|
*/
|
|
543
|
-
export function
|
|
519
|
+
export function activeHarnessInteractionOwner(scope, sessionId, entries = []) {
|
|
544
520
|
if (!scope || !['object', 'function', 'string'].includes(typeof scope)
|
|
545
|
-
|| typeof sessionId !== 'string' || !sessionId) return
|
|
521
|
+
|| typeof sessionId !== 'string' || !sessionId) return null;
|
|
546
522
|
const owners = interactionRegistry(scope).ownerships.get(sessionId);
|
|
547
|
-
if (!owners || owners.size === 0) return
|
|
523
|
+
if (!owners || owners.size === 0) return null;
|
|
548
524
|
for (const ownership of owners) consumeInteractionOwnership(ownership, entries);
|
|
549
|
-
return [...owners].
|
|
525
|
+
return [...owners].filter((ownership) => (
|
|
550
526
|
ownership.active
|
|
551
527
|
&& !ownership.completed
|
|
552
528
|
&& typeof ownership.reconnect === 'function'
|
|
553
|
-
));
|
|
529
|
+
)).sort((left, right) => left.order - right.order)[0] ?? null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Whether a live IM consumer owns this turn. */
|
|
533
|
+
export function hasActiveHarnessInteractionOwner(scope, sessionId, entries = []) {
|
|
534
|
+
return activeHarnessInteractionOwner(scope, sessionId, entries) !== null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Read the operation attached to a normal or PTC tool-call event. */
|
|
538
|
+
export function harnessToolCallFromEvent(event) {
|
|
539
|
+
const data = event?.data;
|
|
540
|
+
if (event?.type === 'tool/call' && typeof data?.callId === 'string') {
|
|
541
|
+
return { callId: data.callId, name: data.name, arguments: data.arguments };
|
|
542
|
+
}
|
|
543
|
+
if (event?.type === 'tool/code-dispatch-start' && typeof data?.subCallId === 'string') {
|
|
544
|
+
let argumentsText;
|
|
545
|
+
try { argumentsText = JSON.stringify(data.arguments); } catch { /* Unserializable details cannot be approved. */ }
|
|
546
|
+
return { callId: data.subCallId, name: data.name, arguments: argumentsText };
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
554
549
|
}
|
|
555
550
|
|
|
556
551
|
export class HarnessReplyTracker {
|
|
@@ -934,6 +929,7 @@ export class HarnessClient {
|
|
|
934
929
|
#managedProcess = null;
|
|
935
930
|
#interactionRegistry;
|
|
936
931
|
#interactionOwnerships;
|
|
932
|
+
#competitiveApprovals;
|
|
937
933
|
#interactionClaims;
|
|
938
934
|
#controlOwnerships;
|
|
939
935
|
|
|
@@ -941,6 +937,7 @@ export class HarnessClient {
|
|
|
941
937
|
baseUrl,
|
|
942
938
|
apiProxy,
|
|
943
939
|
interactionScope = apiProxy,
|
|
940
|
+
competitiveApprovals = false,
|
|
944
941
|
workspace,
|
|
945
942
|
ungroupedWorkspace,
|
|
946
943
|
agentPreset,
|
|
@@ -991,6 +988,7 @@ export class HarnessClient {
|
|
|
991
988
|
|| !['object', 'function'].includes(typeof interactionScope))) {
|
|
992
989
|
throw new TypeError('interactionScope must identify the current Host');
|
|
993
990
|
}
|
|
991
|
+
this.#competitiveApprovals = competitiveApprovals === true;
|
|
994
992
|
this.#workspace = workspace;
|
|
995
993
|
// Only the reserved IM directory bypasses grouping, even after /ws switches.
|
|
996
994
|
this.#ungroupedWorkspace = ungroupedWorkspace;
|
|
@@ -1783,6 +1781,7 @@ export class HarnessClient {
|
|
|
1783
1781
|
lastSeq: baselineSeq,
|
|
1784
1782
|
reconnect: null,
|
|
1785
1783
|
order: -1,
|
|
1784
|
+
competitiveApprovals: this.#competitiveApprovals,
|
|
1786
1785
|
toolCalls: new Map(),
|
|
1787
1786
|
control,
|
|
1788
1787
|
}
|
|
@@ -2130,6 +2129,11 @@ export class HarnessClient {
|
|
|
2130
2129
|
? this.#interactionClaims.get(claimKey)?.recovered === true
|
|
2131
2130
|
: false,
|
|
2132
2131
|
...(toolCall ? { toolCall } : {}),
|
|
2132
|
+
...(kind === 'approval' && payload.competitive === true ? {
|
|
2133
|
+
withdraw: () => this.respondInteraction(envelope.rpcId, {
|
|
2134
|
+
ok: false, error: { code: 'unavailable', message: 'IM cannot answer this approval', details: {} },
|
|
2135
|
+
}, { signal: AbortSignal.timeout(5_000) }),
|
|
2136
|
+
} : {}),
|
|
2133
2137
|
reconnect: close,
|
|
2134
2138
|
respond: (result, options = {}) => this.respondInteraction(
|
|
2135
2139
|
envelope.rpcId,
|