@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.36.0",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
@@ -87,9 +87,9 @@
|
|
|
87
87
|
"platform": "web"
|
|
88
88
|
},
|
|
89
89
|
"compatibility": {
|
|
90
|
-
"dsh": "0.
|
|
90
|
+
"dsh": "0.2.0-rc.2",
|
|
91
91
|
"dshReleases": {
|
|
92
|
-
"0.
|
|
92
|
+
"0.2.0-rc.2": "compatible"
|
|
93
93
|
},
|
|
94
94
|
"profiles": [
|
|
95
95
|
"web"
|
|
@@ -45,7 +45,7 @@ function pluginPaths(config) {
|
|
|
45
45
|
|
|
46
46
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
47
47
|
if (!ctx?.credentials) throw new TypeError('dsh-dingtalk requires ctx.credentials');
|
|
48
|
-
const connection = harnessConnection(ctx, config);
|
|
48
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
49
49
|
|
|
50
50
|
const ConfigStore = internals.ConfigStore ?? DingtalkConfigStore;
|
|
51
51
|
const DeviceAuth = internals.DeviceAuth ?? DingtalkDeviceAuth;
|
|
@@ -101,7 +101,7 @@ function pluginPaths(config) {
|
|
|
101
101
|
*/
|
|
102
102
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
103
103
|
if (!ctx?.credentials) throw new TypeError('dsh-feishu requires ctx.credentials');
|
|
104
|
-
const connection = harnessConnection(ctx, config);
|
|
104
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
105
105
|
|
|
106
106
|
const lark = internals.lark ?? Lark;
|
|
107
107
|
const Controller = internals.Controller ?? MultiBotDshFeishuController;
|
|
@@ -226,7 +226,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
226
226
|
verifyApp,
|
|
227
227
|
credentials: ctx.credentials,
|
|
228
228
|
configStore: observedConfigStore,
|
|
229
|
-
createRuntime: async ({ botId, config: botConfig, appSecret, repair }) => {
|
|
229
|
+
createRuntime: async ({ botId, config: botConfig, appSecret, repair, acceptExternal }) => {
|
|
230
230
|
const state = await stateFor(botConfig);
|
|
231
231
|
const id = botId ?? botConfig.id ?? botConfig.appId;
|
|
232
232
|
await workspaces.ensure(id, {
|
|
@@ -248,6 +248,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
248
248
|
}
|
|
249
249
|
return new Runtime({
|
|
250
250
|
lark,
|
|
251
|
+
consumerMode: botConfig.consumerMode,
|
|
252
|
+
acceptExternal,
|
|
251
253
|
botId: id,
|
|
252
254
|
repair,
|
|
253
255
|
appId: botConfig.appId,
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
|
|
35
35
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
36
36
|
if (!ctx?.credentials) throw new TypeError('dsh-im matrix requires ctx.credentials');
|
|
37
|
-
const connection = harnessConnection(ctx, config);
|
|
37
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
38
38
|
|
|
39
39
|
const ResolvedConfigStore = internals.ConfigStore ?? MatrixConfigStore;
|
|
40
40
|
const ResolvedStateStore = internals.StateStore ?? ConversationStateStore;
|
|
@@ -45,7 +45,7 @@ function pluginPaths(config) {
|
|
|
45
45
|
|
|
46
46
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
47
47
|
if (!ctx?.credentials) throw new TypeError('dsh-im QQ requires ctx.credentials');
|
|
48
|
-
const connection = harnessConnection(ctx, config);
|
|
48
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
49
49
|
|
|
50
50
|
const ConfigStore = internals.ConfigStore ?? QqConfigStore;
|
|
51
51
|
const StateStore = internals.StateStore ?? QqStateStore;
|
|
@@ -44,7 +44,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
44
44
|
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
|
|
45
45
|
} = definitions;
|
|
46
46
|
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
|
47
|
-
const connection = harnessConnection(ctx, config);
|
|
47
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: ['telegram', 'discord'].includes(channel) });
|
|
48
48
|
|
|
49
49
|
const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore;
|
|
50
50
|
const ResolvedStateStore = internals.StateStore ?? StateStore;
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
|
|
34
34
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
35
35
|
if (!ctx?.credentials) throw new TypeError('dsh-im slack requires ctx.credentials');
|
|
36
|
-
const connection = harnessConnection(ctx, config);
|
|
36
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
37
37
|
|
|
38
38
|
const ResolvedConfigStore = internals.ConfigStore ?? SlackConfigStore;
|
|
39
39
|
const ResolvedStateStore = internals.StateStore ?? SlackStateStore;
|
|
@@ -44,7 +44,7 @@ function pluginPaths(config) {
|
|
|
44
44
|
|
|
45
45
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
46
46
|
if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
|
|
47
|
-
const connection = harnessConnection(ctx, config);
|
|
47
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
48
48
|
|
|
49
49
|
const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
|
|
50
50
|
const StateStore = internals.StateStore ?? WecomStateStore;
|
|
@@ -49,7 +49,7 @@ function normalizeOrigin(value) {
|
|
|
49
49
|
|
|
50
50
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
51
51
|
if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat app requires ctx.credentials');
|
|
52
|
-
const connection = harnessConnection(ctx, config);
|
|
52
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
53
53
|
|
|
54
54
|
const ConfigStore = internals.ConfigStore ?? WecomAppConfigStore;
|
|
55
55
|
const StateStore = internals.StateStore ?? WecomAppStateStore;
|
|
@@ -77,7 +77,7 @@ function diagnosticWorkspaces(store) {
|
|
|
77
77
|
|
|
78
78
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
79
79
|
if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
|
|
80
|
-
const connection = harnessConnection(ctx, config);
|
|
80
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
81
81
|
|
|
82
82
|
const ConfigStore = internals.ConfigStore ?? WeixinConfigStore;
|
|
83
83
|
const StateStore = internals.StateStore ?? WeixinStateStore;
|
|
@@ -54,7 +54,7 @@ function pluginPaths(config) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
|
57
|
-
const connection = harnessConnection(ctx, config);
|
|
57
|
+
const connection = harnessConnection(ctx, config, { competitiveApprovals: true });
|
|
58
58
|
const logger = typeof ctx.logger === 'function'
|
|
59
59
|
? ctx.logger('dsh-im:whatsapp') : (ctx.logger ?? console);
|
|
60
60
|
const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
|
|
@@ -231,8 +231,35 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
231
231
|
if (typeof stateFor !== 'function') {
|
|
232
232
|
throw new TypeError('delivery adapter requires a bot state getter');
|
|
233
233
|
}
|
|
234
|
+
const syncTarget = async (botId, targetId, sessionId) => {
|
|
235
|
+
const configured = sessionSyncConfigs(workspaces, botId).find((entry) => entry.targetId === targetId);
|
|
236
|
+
const target = workspaces.deliveryTargetFor(botId, targetId);
|
|
237
|
+
if (!configured || !target || !privateConversationKeyMatchesTarget(channel, configured.conversationKey, target)) {
|
|
238
|
+
throw sessionSyncUnavailable('Session sync target is no longer enabled');
|
|
239
|
+
}
|
|
240
|
+
const sessions = stateSessions(await stateFor(botId));
|
|
241
|
+
if (sessions?.[configured.conversationKey] !== sessionId) {
|
|
242
|
+
throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
|
|
243
|
+
}
|
|
244
|
+
return { target, conversationKey: configured.conversationKey };
|
|
245
|
+
};
|
|
234
246
|
return Object.freeze({
|
|
235
247
|
channel,
|
|
248
|
+
consumeInbound: (botId, options) => {
|
|
249
|
+
if (typeof coreController.consumeInbound !== 'function')
|
|
250
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
251
|
+
return coreController.consumeInbound(botId, options);
|
|
252
|
+
},
|
|
253
|
+
historyChecked: (botId, route, query, options) => {
|
|
254
|
+
if (typeof coreController.historyChecked !== 'function')
|
|
255
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
256
|
+
return coreController.historyChecked(botId, route, query, options);
|
|
257
|
+
},
|
|
258
|
+
replyChecked: (botId, route, text, options) => {
|
|
259
|
+
if (typeof coreController.replyChecked !== 'function')
|
|
260
|
+
throw Object.assign(new Error('capability-unavailable'), { code: 'capability-unavailable' });
|
|
261
|
+
return coreController.replyChecked(botId, route, text, options);
|
|
262
|
+
},
|
|
236
263
|
describeAccount: (botId) => {
|
|
237
264
|
if (typeof coreController.describeDeliveryAccount !== 'function') {
|
|
238
265
|
const error = new Error('Verified account capability unavailable');
|
|
@@ -339,20 +366,41 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
339
366
|
}
|
|
340
367
|
return matches;
|
|
341
368
|
},
|
|
369
|
+
async describeSessionSyncApprovalTarget(botId, targetId, sessionId) {
|
|
370
|
+
const { conversationKey } = await syncTarget(botId, targetId, sessionId);
|
|
371
|
+
return { conversationKey };
|
|
372
|
+
},
|
|
373
|
+
async presentSessionSyncApproval(botId, targetId, sessionId, interaction, options = {}) {
|
|
374
|
+
const original = await syncTarget(botId, targetId, sessionId);
|
|
375
|
+
if (typeof coreController.presentSessionSyncApproval !== 'function') {
|
|
376
|
+
throw sessionSyncUnavailable('This channel cannot present synced approvals');
|
|
377
|
+
}
|
|
378
|
+
const validate = async () => {
|
|
379
|
+
const current = await syncTarget(botId, targetId, sessionId);
|
|
380
|
+
if (current.conversationKey !== original.conversationKey || options.signal?.aborted) {
|
|
381
|
+
throw sessionSyncUnavailable('The approval target changed or the approval ended');
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const guarded = {
|
|
385
|
+
...interaction,
|
|
386
|
+
respond: async (result, responseOptions) => {
|
|
387
|
+
try { await validate(); }
|
|
388
|
+
catch (error) {
|
|
389
|
+
await interaction.withdraw?.();
|
|
390
|
+
throw Object.assign(new Error('Approval route is no longer available', { cause: error }), {
|
|
391
|
+
code: 'interaction-not-pending',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return interaction.respond(result, responseOptions);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
return coreController.presentSessionSyncApproval(botId, original.target, guarded, {
|
|
398
|
+
...options, key: original.conversationKey, validate,
|
|
399
|
+
});
|
|
400
|
+
},
|
|
342
401
|
async sendSessionSyncText(botId, targetId, sessionId, text, options = {}) {
|
|
343
402
|
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
|
|
344
|
-
const
|
|
345
|
-
.find((target) => target.targetId === targetId);
|
|
346
|
-
const target = workspaces.deliveryTargetFor(botId, targetId);
|
|
347
|
-
if (!configured || !target || !privateConversationKeyMatchesTarget(
|
|
348
|
-
channel,
|
|
349
|
-
configured.conversationKey,
|
|
350
|
-
target,
|
|
351
|
-
)) throw sessionSyncUnavailable('Session sync target is no longer enabled');
|
|
352
|
-
const sessions = stateSessions(await stateFor(botId));
|
|
353
|
-
if (sessions?.[configured.conversationKey] !== sessionId) {
|
|
354
|
-
throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
|
|
355
|
-
}
|
|
403
|
+
const { target } = await syncTarget(botId, targetId, sessionId);
|
|
356
404
|
if (typeof coreController.sendProactiveText !== 'function') {
|
|
357
405
|
throw new TypeError('delivery controller cannot send proactive text');
|
|
358
406
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
|
|
3
|
+
import { COMPETITIVE_APPROVAL_CHANNELS } from '../../src/channels/shared/harness-approval.mjs';
|
|
4
|
+
import { t } from '../../src/channels/shared/i18n.mjs';
|
|
3
5
|
|
|
4
6
|
const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
7
|
const TARGET_ID_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/;
|
|
@@ -33,6 +35,17 @@ const DELIVERY_ERROR_CODES = new Set([
|
|
|
33
35
|
'send-result-unknown',
|
|
34
36
|
'session-sync-unavailable',
|
|
35
37
|
'cancelled',
|
|
38
|
+
'provider-unavailable',
|
|
39
|
+
'consumer-unavailable',
|
|
40
|
+
'consumer-conflict',
|
|
41
|
+
'ingress-not-accepted',
|
|
42
|
+
'invalid-inbound',
|
|
43
|
+
'stale-route',
|
|
44
|
+
'reply-result-unknown',
|
|
45
|
+
'history-permission-denied',
|
|
46
|
+
'history-unavailable',
|
|
47
|
+
'thread-unavailable',
|
|
48
|
+
'untrusted-source',
|
|
36
49
|
]);
|
|
37
50
|
|
|
38
51
|
const SESSION_SYNC_METHODS = Object.freeze([
|
|
@@ -132,11 +145,13 @@ export class DeliveryService {
|
|
|
132
145
|
|
|
133
146
|
registerAdapter(value) {
|
|
134
147
|
const adapter = validateAdapter(value);
|
|
135
|
-
|
|
148
|
+
this.#adapters.get(adapter.channel)?.controller.abort(deliveryError('provider-unavailable'));
|
|
149
|
+
const registration = Object.freeze({ adapter, controller: new AbortController() });
|
|
136
150
|
this.#adapters.set(adapter.channel, registration);
|
|
137
151
|
return () => {
|
|
138
152
|
if (this.#adapters.get(adapter.channel) !== registration) return false;
|
|
139
153
|
this.#adapters.delete(adapter.channel);
|
|
154
|
+
registration.controller.abort(deliveryError('provider-unavailable'));
|
|
140
155
|
return true;
|
|
141
156
|
};
|
|
142
157
|
}
|
|
@@ -273,6 +288,59 @@ export class DeliveryService {
|
|
|
273
288
|
return targets;
|
|
274
289
|
}
|
|
275
290
|
|
|
291
|
+
/** Offer one existing bound private chat the same approval the Host is awaiting. */
|
|
292
|
+
async presentSessionSyncApproval(sessionId, interaction, { signal, completion } = {}) {
|
|
293
|
+
const targets = (await this.listSessionSyncTargets(sessionId))
|
|
294
|
+
.filter((target) => COMPETITIVE_APPROVAL_CHANNELS.includes(target.channel));
|
|
295
|
+
if (signal?.aborted || targets.length === 0) return false;
|
|
296
|
+
let outcome = null;
|
|
297
|
+
let notified = false;
|
|
298
|
+
const notify = async (finished = false) => {
|
|
299
|
+
if (!finished && (signal?.aborted || outcome !== null)) return;
|
|
300
|
+
const text = finished
|
|
301
|
+
? t('未找到可处理端,本次操作未获批准。')
|
|
302
|
+
: t('有操作在等待审批:{tool},请到 Web 处理。', { tool: interaction.payload.toolName });
|
|
303
|
+
const results = await Promise.allSettled(targets.map((target) => this.sendSessionSyncText(
|
|
304
|
+
target.botId, target.targetId, sessionId, text, finished ? {} : { signal },
|
|
305
|
+
)));
|
|
306
|
+
notified ||= results.some((result) => result.status === 'fulfilled');
|
|
307
|
+
};
|
|
308
|
+
Promise.resolve(completion).then(async (value) => {
|
|
309
|
+
outcome = value;
|
|
310
|
+
if (value === 'unavailable' && notified) await notify(true);
|
|
311
|
+
}).catch((error) => console.warn('[dsh-im] approval status notification failed:', error?.code ?? error?.name));
|
|
312
|
+
const candidates = new Map();
|
|
313
|
+
let unresolvedTarget = false;
|
|
314
|
+
for (const target of targets) {
|
|
315
|
+
try {
|
|
316
|
+
const adapter = await this.#adapterFor(target.botId);
|
|
317
|
+
if (typeof adapter.describeSessionSyncApprovalTarget !== 'function') {
|
|
318
|
+
unresolvedTarget = true;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const info = await adapter.describeSessionSyncApprovalTarget(target.botId, target.targetId, sessionId);
|
|
322
|
+
candidates.set(JSON.stringify([target.channel, target.botId, info.conversationKey]), { adapter, target });
|
|
323
|
+
} catch (error) {
|
|
324
|
+
unresolvedTarget = true;
|
|
325
|
+
console.warn('[dsh-im] approval target unavailable:', error?.code ?? error?.name);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (signal?.aborted) return false;
|
|
329
|
+
if (!unresolvedTarget && candidates.size === 1) {
|
|
330
|
+
const { adapter, target } = candidates.values().next().value;
|
|
331
|
+
try {
|
|
332
|
+
if (await adapter.presentSessionSyncApproval(target.botId, target.targetId, sessionId, interaction, {
|
|
333
|
+
signal, completion,
|
|
334
|
+
})) return true;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!signal?.aborted) console.warn('[dsh-im] synced approval unavailable:', error?.code ?? error?.name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (outcome === 'unavailable') await notify(true);
|
|
340
|
+
else await notify();
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
276
344
|
async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
|
|
277
345
|
const id = botIdOf(botId);
|
|
278
346
|
const targetKey = targetIdOf(targetId);
|
|
@@ -315,6 +383,64 @@ export class DeliveryService {
|
|
|
315
383
|
} catch (error) { throw publicOperationError(error); }
|
|
316
384
|
}
|
|
317
385
|
|
|
386
|
+
async consumeInbound(botId, options = {}) {
|
|
387
|
+
const id = botIdOf(botId);
|
|
388
|
+
cancellation(options.signal);
|
|
389
|
+
if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '') || typeof options.onEvent !== 'function')
|
|
390
|
+
throw deliveryError('bad-request');
|
|
391
|
+
const registration = await this.#checkedRegistrationFor(id);
|
|
392
|
+
if (typeof registration.adapter.consumeInbound !== 'function' || typeof options.onEvent !== 'function')
|
|
393
|
+
throw deliveryError('capability-unavailable');
|
|
394
|
+
this.#assertRegistered(registration);
|
|
395
|
+
const dispose = await registration.adapter.consumeInbound(id, {
|
|
396
|
+
...options,
|
|
397
|
+
signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
|
|
398
|
+
onEvent: async (evidence, context) => {
|
|
399
|
+
this.#assertRegistered(registration);
|
|
400
|
+
const result = await options.onEvent(evidence, context);
|
|
401
|
+
this.#assertRegistered(registration);
|
|
402
|
+
return result;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
try { this.#assertRegistered(registration); } catch (error) { dispose(); throw error; }
|
|
406
|
+
return dispose;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async historyChecked(botId, route, query, options = {}) {
|
|
410
|
+
const id = botIdOf(botId);
|
|
411
|
+
cancellation(options.signal);
|
|
412
|
+
if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')) throw deliveryError('bad-request');
|
|
413
|
+
const registration = await this.#checkedRegistrationFor(id);
|
|
414
|
+
if (typeof registration.adapter.historyChecked !== 'function') throw deliveryError('capability-unavailable');
|
|
415
|
+
this.#assertRegistered(registration);
|
|
416
|
+
const signal = options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal;
|
|
417
|
+
try {
|
|
418
|
+
const result = await registration.adapter.historyChecked(id, structuredClone(route), structuredClone(query), { ...options, signal });
|
|
419
|
+
this.#assertRegistered(registration);
|
|
420
|
+
cancellation(options.signal);
|
|
421
|
+
return result;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.#assertRegistered(registration);
|
|
424
|
+
if (signal.aborted || error?.name === 'AbortError') throw deliveryError('cancelled');
|
|
425
|
+
throw publicOperationError(error, 'history-unavailable');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async replyChecked(botId, route, text, options = {}) {
|
|
430
|
+
const id = botIdOf(botId);
|
|
431
|
+
cancellation(options.signal);
|
|
432
|
+
if (!/^[a-f0-9]{64}$/.test(options.expectedFingerprint ?? '')
|
|
433
|
+
|| typeof text !== 'string' || !text.trim() || text.length > 4000)
|
|
434
|
+
throw deliveryError('bad-request');
|
|
435
|
+
const registration = await this.#checkedRegistrationFor(id);
|
|
436
|
+
if (typeof registration.adapter.replyChecked !== 'function') throw deliveryError('capability-unavailable');
|
|
437
|
+
this.#assertRegistered(registration);
|
|
438
|
+
try { return await registration.adapter.replyChecked(id, structuredClone(route), text, {
|
|
439
|
+
...options, signal: options.signal ? AbortSignal.any([options.signal, registration.controller.signal]) : registration.controller.signal,
|
|
440
|
+
}); }
|
|
441
|
+
catch (error) { throw publicOperationError(error); }
|
|
442
|
+
}
|
|
443
|
+
|
|
318
444
|
async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain', receipt = false } = {}) {
|
|
319
445
|
const id = botIdOf(botId);
|
|
320
446
|
const key = targetIdOf(targetId);
|
|
@@ -124,7 +124,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
|
|
|
124
124
|
case 'whatsapp':
|
|
125
125
|
return whatsappSuggestion(key);
|
|
126
126
|
case 'matrix':
|
|
127
|
-
return matrixSuggestion(key);
|
|
127
|
+
return matrixSuggestion(key.replace(/^(direct|group):/, ''));
|
|
128
128
|
default:
|
|
129
129
|
return null;
|
|
130
130
|
}
|
|
@@ -132,6 +132,7 @@ export function deliverySuggestionFromConversationKey(channel, key) {
|
|
|
132
132
|
|
|
133
133
|
/** Convert only a persisted private-chat key into its stable delivery route. */
|
|
134
134
|
export function privateDeliverySuggestionFromConversationKey(channel, key) {
|
|
135
|
+
if (channel === 'matrix' && typeof key === 'string') key = key.replace(/^direct:dm:/, 'dm:');
|
|
135
136
|
const separator = typeof key === 'string' ? key.indexOf(':') : -1;
|
|
136
137
|
const prefix = separator > 0 ? key.slice(0, separator) : '';
|
|
137
138
|
const privatePrefix = {
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { modernHarnessApi } from './modern-harness-api.mjs';
|
|
2
2
|
|
|
3
|
-
function optionalService(ctx, name) {
|
|
4
|
-
if (ctx && Object.hasOwn(ctx, name)) return ctx[name];
|
|
5
|
-
return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
3
|
/** Connect to this Host directly unless an external Harness URL was configured. */
|
|
9
|
-
export function harnessConnection(ctx, config = {}) {
|
|
4
|
+
export function harnessConnection(ctx, config = {}, { competitiveApprovals = false } = {}) {
|
|
10
5
|
if (config.harnessBaseUrl !== undefined) {
|
|
11
6
|
return { baseUrl: new URL(config.harnessBaseUrl) };
|
|
12
7
|
}
|
|
13
|
-
const apiProxy =
|
|
8
|
+
const apiProxy = modernHarnessApi(ctx, { deliveryService: config.deliveryService });
|
|
14
9
|
return {
|
|
15
10
|
apiProxy,
|
|
11
|
+
competitiveApprovals,
|
|
16
12
|
// Cordis child contexts share one root; different Hosts must not share
|
|
17
13
|
// ownership of pending questions and approvals.
|
|
18
14
|
interactionScope: ctx.root ?? ctx,
|
|
@@ -93,6 +93,10 @@ export function createImHostPlugin(internals = {}) {
|
|
|
93
93
|
ctx.provide('dshIm', Object.freeze({
|
|
94
94
|
contractVersion: 1,
|
|
95
95
|
receiptVersion: 1,
|
|
96
|
+
inboundVersion: 1,
|
|
97
|
+
consumeInbound: (botId, options) => deliveryService.consumeInbound(botId, options),
|
|
98
|
+
historyChecked: (botId, route, query, options) => deliveryService.historyChecked(botId, route, query, options),
|
|
99
|
+
replyChecked: (botId, route, text, options) => deliveryService.replyChecked(botId, route, text, options),
|
|
96
100
|
describeBot: (botId) => deliveryService.describeBot(botId),
|
|
97
101
|
sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
|
|
98
102
|
send: (botId, targetId, text, options) => (
|
|
@@ -106,9 +110,8 @@ export function createImHostPlugin(internals = {}) {
|
|
|
106
110
|
await activateChannels(readyCtx, config, deliveryService);
|
|
107
111
|
};
|
|
108
112
|
if (typeof ctx?.inject === 'function') {
|
|
109
|
-
const modern = typeof ctx?.typertGateway?.stream === 'function';
|
|
110
113
|
await ctx.inject(
|
|
111
|
-
|
|
114
|
+
['sessionController', 'workspaceController'],
|
|
112
115
|
activate,
|
|
113
116
|
);
|
|
114
117
|
ctx.inject(['webServer'], (httpCtx) => {
|
|
@@ -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(() => () => {
|