@xmanrui/dsh-im 4.10.0 → 4.12.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 +15 -1
- package/PROACTIVE_DELIVERY.md +15 -1
- package/README.en.md +7 -2
- package/README.md +8 -3
- package/lib/client.js +2341 -1315
- package/lib/index.js +250 -245
- package/package.json +4 -3
- package/plugin-src/client/channel-card-meta.js +9 -1
- package/plugin-src/client/channels/dingtalk/api.js +4 -0
- package/plugin-src/client/channels/dingtalk/index.js +41 -7
- package/plugin-src/client/channels/feishu/api.js +4 -0
- package/plugin-src/client/channels/feishu/group-settings.js +581 -0
- package/plugin-src/client/channels/feishu/index.js +64 -233
- package/plugin-src/client/channels/qq/api.js +4 -0
- package/plugin-src/client/channels/qq/index.js +41 -7
- package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
- package/plugin-src/client/channels/shared/token-api.js +4 -0
- package/plugin-src/client/channels/shared/token-channel.js +62 -29
- package/plugin-src/client/channels/wecom/api.js +4 -0
- package/plugin-src/client/channels/wecom/index.js +41 -7
- package/plugin-src/client/channels/weixin/api.js +4 -0
- package/plugin-src/client/channels/weixin/index.js +41 -7
- package/plugin-src/client/channels/whatsapp/api.js +4 -0
- package/plugin-src/client/channels/whatsapp/index.js +40 -7
- package/plugin-src/client/delivery-settings.js +73 -5
- package/plugin-src/client/i18n.js +22 -0
- package/plugin-src/client/model-setting.js +104 -0
- package/plugin-src/client/styles.js +62 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +3 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +3 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
- package/plugin-src/host/channels/shared/production.mjs +3 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
- package/plugin-src/host/channels/slack/production.mjs +3 -0
- package/plugin-src/host/channels/slack/rpc.mjs +9 -0
- package/plugin-src/host/channels/wecom/production.mjs +3 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +3 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
- package/plugin-src/host/delivery-adapter.mjs +141 -3
- package/plugin-src/host/delivery-rpc.mjs +12 -0
- package/plugin-src/host/delivery-service.mjs +110 -3
- package/plugin-src/host/delivery-suggestions.mjs +46 -0
- package/plugin-src/host/harness-session-coordinator.mjs +6 -5
- package/plugin-src/host/index.mjs +18 -1
- package/plugin-src/host/session-sync-coordinator.mjs +217 -0
- package/scripts/verify-package.mjs +12 -6
- package/src/channels/shared/bot-workspace-store.mjs +285 -38
- package/src/channels/shared/harness-client.mjs +124 -43
- package/src/channels/shared/i18n-en/wecom.mjs +24 -0
- package/src/channels/shared/model-setting.mjs +110 -0
- package/src/channels/wecom/wecom-bridge.mjs +275 -2
- package/src/channels/wecom/wecom-cards.mjs +101 -0
- package/src/channels/wecom/wecom-runtime.mjs +2 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
|
@@ -9,6 +9,7 @@ export const DELIVERY_ENDPOINTS = Object.freeze({
|
|
|
9
9
|
createTarget: 'target.create',
|
|
10
10
|
updateTarget: 'target.update',
|
|
11
11
|
deleteTarget: 'target.delete',
|
|
12
|
+
setSessionSync: 'target.session-sync.set',
|
|
12
13
|
testTarget: 'target.test',
|
|
13
14
|
});
|
|
14
15
|
|
|
@@ -22,6 +23,7 @@ const PUBLIC_ERRORS = new Set([
|
|
|
22
23
|
'bot-not-connected',
|
|
23
24
|
'target-rejected',
|
|
24
25
|
'delivery-failed',
|
|
26
|
+
'session-sync-unavailable',
|
|
25
27
|
'cancelled',
|
|
26
28
|
]);
|
|
27
29
|
|
|
@@ -85,6 +87,11 @@ function validPayload(endpoint, payload) {
|
|
|
85
87
|
|| (exactKeys(payload, ['botId', 'target'])
|
|
86
88
|
&& validBotId(payload.botId) && validDraftTarget(payload.target));
|
|
87
89
|
}
|
|
90
|
+
if (endpoint === DELIVERY_ENDPOINTS.setSessionSync) {
|
|
91
|
+
return exactKeys(payload, ['botId', 'targetId', 'enabled'])
|
|
92
|
+
&& validBotId(payload.botId) && validTargetId(payload.targetId)
|
|
93
|
+
&& typeof payload.enabled === 'boolean';
|
|
94
|
+
}
|
|
88
95
|
return exactKeys(payload, ['botId', 'targetId'])
|
|
89
96
|
&& validBotId(payload.botId) && validTargetId(payload.targetId);
|
|
90
97
|
}
|
|
@@ -131,6 +138,11 @@ export function createDeliveryRpcHandler(service) {
|
|
|
131
138
|
value = await service.updateTarget(payload.botId, payload.targetId, payload.target);
|
|
132
139
|
} else if (endpoint === DELIVERY_ENDPOINTS.deleteTarget) {
|
|
133
140
|
value = await service.deleteTarget(payload.botId, payload.targetId);
|
|
141
|
+
} else if (endpoint === DELIVERY_ENDPOINTS.setSessionSync) {
|
|
142
|
+
if (typeof service.setSessionSync !== 'function') {
|
|
143
|
+
throw new TypeError('Session sync is unavailable');
|
|
144
|
+
}
|
|
145
|
+
value = await service.setSessionSync(payload.botId, payload.targetId, payload.enabled);
|
|
134
146
|
} else {
|
|
135
147
|
value = await service.send(
|
|
136
148
|
payload.botId,
|
|
@@ -25,9 +25,16 @@ const DELIVERY_ERROR_CODES = new Set([
|
|
|
25
25
|
'bot-not-connected',
|
|
26
26
|
'target-rejected',
|
|
27
27
|
'delivery-failed',
|
|
28
|
+
'session-sync-unavailable',
|
|
28
29
|
'cancelled',
|
|
29
30
|
]);
|
|
30
31
|
|
|
32
|
+
const SESSION_SYNC_METHODS = Object.freeze([
|
|
33
|
+
'setSessionSync',
|
|
34
|
+
'listSessionSyncTargets',
|
|
35
|
+
'sendSessionSyncText',
|
|
36
|
+
]);
|
|
37
|
+
|
|
31
38
|
function deliveryError(code, message = code, options) {
|
|
32
39
|
const error = new Error(message, options);
|
|
33
40
|
error.code = code;
|
|
@@ -93,8 +100,29 @@ function validateAdapter(adapter) {
|
|
|
93
100
|
return adapter;
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
function sessionSyncState(value, available) {
|
|
104
|
+
const enabled = value?.enabled === true;
|
|
105
|
+
if (!available) return { enabled, state: 'unavailable' };
|
|
106
|
+
const state = value?.state;
|
|
107
|
+
if (['off', 'active', 'waiting', 'unavailable'].includes(state)) {
|
|
108
|
+
return { enabled, state };
|
|
109
|
+
}
|
|
110
|
+
return { enabled: false, state: 'unavailable' };
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
export class DeliveryService {
|
|
97
114
|
#adapters = new Map();
|
|
115
|
+
#unavailableSessionSyncChannels;
|
|
116
|
+
|
|
117
|
+
constructor({ unavailableSessionSyncChannels = [] } = {}) {
|
|
118
|
+
if (!Array.isArray(unavailableSessionSyncChannels)
|
|
119
|
+
|| unavailableSessionSyncChannels.some((channel) => (
|
|
120
|
+
typeof channel !== 'string' || !CHANNEL_PATTERN.test(channel)
|
|
121
|
+
))) {
|
|
122
|
+
throw new TypeError('unavailableSessionSyncChannels must contain valid channel ids');
|
|
123
|
+
}
|
|
124
|
+
this.#unavailableSessionSyncChannels = new Set(unavailableSessionSyncChannels);
|
|
125
|
+
}
|
|
98
126
|
|
|
99
127
|
registerAdapter(value) {
|
|
100
128
|
const adapter = validateAdapter(value);
|
|
@@ -113,7 +141,15 @@ export class DeliveryService {
|
|
|
113
141
|
try {
|
|
114
142
|
const targets = await adapter.listTargets(id);
|
|
115
143
|
if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
|
|
116
|
-
|
|
144
|
+
const available = this.#supportsSessionSync(adapter);
|
|
145
|
+
return {
|
|
146
|
+
botId: id,
|
|
147
|
+
channel: adapter.channel,
|
|
148
|
+
targets: targets.map((target) => ({
|
|
149
|
+
...target,
|
|
150
|
+
sessionSync: sessionSyncState(target?.sessionSync, available),
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
117
153
|
} catch (error) {
|
|
118
154
|
throw publicOperationError(error);
|
|
119
155
|
}
|
|
@@ -188,6 +224,72 @@ export class DeliveryService {
|
|
|
188
224
|
}
|
|
189
225
|
}
|
|
190
226
|
|
|
227
|
+
async setSessionSync(botId, targetId, enabled) {
|
|
228
|
+
const id = botIdOf(botId);
|
|
229
|
+
const targetKey = targetIdOf(targetId);
|
|
230
|
+
if (typeof enabled !== 'boolean') throw deliveryError('bad-request', 'Invalid enabled state');
|
|
231
|
+
const adapter = await this.#adapterFor(id);
|
|
232
|
+
const hasMethods = SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
|
|
233
|
+
if (!hasMethods || (enabled && !this.#supportsSessionSync(adapter))) {
|
|
234
|
+
throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return await adapter.setSessionSync(id, targetKey, enabled);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw publicOperationError(error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async listSessionSyncTargets(sessionId) {
|
|
244
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
245
|
+
throw deliveryError('bad-request', 'Invalid Session id');
|
|
246
|
+
}
|
|
247
|
+
const targets = [];
|
|
248
|
+
for (const { adapter } of this.#adapters.values()) {
|
|
249
|
+
if (!this.#supportsSessionSync(adapter)) continue;
|
|
250
|
+
try {
|
|
251
|
+
const listed = await adapter.listSessionSyncTargets(sessionId);
|
|
252
|
+
if (!Array.isArray(listed)) throw new TypeError('Adapter returned invalid sync targets');
|
|
253
|
+
for (const target of listed) {
|
|
254
|
+
targets.push({
|
|
255
|
+
channel: adapter.channel,
|
|
256
|
+
botId: botIdOf(target?.botId),
|
|
257
|
+
targetId: targetIdOf(target?.targetId),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.warn(
|
|
262
|
+
`[dsh-im] ignored ${adapter.channel} Session sync target lookup failure`
|
|
263
|
+
+ ` (${error?.code ?? error?.name ?? 'unknown-error'})`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return targets;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
|
|
271
|
+
const id = botIdOf(botId);
|
|
272
|
+
const targetKey = targetIdOf(targetId);
|
|
273
|
+
if (typeof sessionId !== 'string' || !sessionId
|
|
274
|
+
|| typeof text !== 'string' || !text.trim()) {
|
|
275
|
+
throw deliveryError('bad-request', 'Invalid session sync delivery');
|
|
276
|
+
}
|
|
277
|
+
cancellation(signal);
|
|
278
|
+
const adapter = await this.#adapterFor(id);
|
|
279
|
+
if (!this.#supportsSessionSync(adapter)) {
|
|
280
|
+
throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await adapter.sendSessionSyncText(id, targetKey, sessionId, text, { signal });
|
|
284
|
+
return { sent: true };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
|
|
287
|
+
throw deliveryError('cancelled', 'Request cancelled', { cause: error });
|
|
288
|
+
}
|
|
289
|
+
throw publicOperationError(error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
191
293
|
async send(botId, targetIdOrDraft, text, { signal } = {}) {
|
|
192
294
|
const id = botIdOf(botId);
|
|
193
295
|
const targetKey = typeof targetIdOrDraft === 'string'
|
|
@@ -232,8 +334,13 @@ export class DeliveryService {
|
|
|
232
334
|
}
|
|
233
335
|
throw deliveryError('unknown-bot', 'Unknown bot');
|
|
234
336
|
}
|
|
337
|
+
|
|
338
|
+
#supportsSessionSync(adapter) {
|
|
339
|
+
return !this.#unavailableSessionSyncChannels.has(adapter.channel)
|
|
340
|
+
&& SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
|
|
341
|
+
}
|
|
235
342
|
}
|
|
236
343
|
|
|
237
|
-
export function createDeliveryService() {
|
|
238
|
-
return new DeliveryService();
|
|
344
|
+
export function createDeliveryService(options) {
|
|
345
|
+
return new DeliveryService(options);
|
|
239
346
|
}
|
|
@@ -115,6 +115,52 @@ export function deliverySuggestionFromConversationKey(channel, key) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Convert only a persisted private-chat key into its stable delivery route. */
|
|
119
|
+
export function privateDeliverySuggestionFromConversationKey(channel, key) {
|
|
120
|
+
const separator = typeof key === 'string' ? key.indexOf(':') : -1;
|
|
121
|
+
const prefix = separator > 0 ? key.slice(0, separator) : '';
|
|
122
|
+
const privatePrefix = {
|
|
123
|
+
weixin: 'p2p',
|
|
124
|
+
feishu: 'p2p',
|
|
125
|
+
dingtalk: 'p2p',
|
|
126
|
+
wecom: 'direct',
|
|
127
|
+
qq: 'c2c',
|
|
128
|
+
slack: 'direct',
|
|
129
|
+
telegram: 'direct',
|
|
130
|
+
discord: 'direct',
|
|
131
|
+
whatsapp: 'direct',
|
|
132
|
+
}[channel];
|
|
133
|
+
if (!privatePrefix || prefix !== privatePrefix) return null;
|
|
134
|
+
return deliverySuggestionFromConversationKey(channel, key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function routeIdentity(value) {
|
|
138
|
+
if (!isRecord(value?.route) || typeof value.kind !== 'string') return null;
|
|
139
|
+
return JSON.stringify([
|
|
140
|
+
value.kind,
|
|
141
|
+
Object.keys(value.route).sort().map((key) => [key, value.route[key]]),
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Check that one saved private conversation key still identifies this target route. */
|
|
146
|
+
export function privateConversationKeyMatchesTarget(channel, key, target) {
|
|
147
|
+
const suggestion = privateDeliverySuggestionFromConversationKey(channel, key);
|
|
148
|
+
const suggestionIdentity = routeIdentity(suggestion);
|
|
149
|
+
return suggestionIdentity !== null && suggestionIdentity === routeIdentity(target);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Resolve exactly one current private conversation for a normalized delivery target. */
|
|
153
|
+
export function resolvePrivateConversationKey(channel, sessions, target) {
|
|
154
|
+
if (!CHANNELS.has(channel) || !isRecord(sessions)) return null;
|
|
155
|
+
const matches = [];
|
|
156
|
+
for (const [key, sessionId] of Object.entries(sessions)) {
|
|
157
|
+
if (typeof sessionId !== 'string' || !sessionId) continue;
|
|
158
|
+
if (privateConversationKeyMatchesTarget(channel, key, target)) matches.push(key);
|
|
159
|
+
if (matches.length > 1) return null;
|
|
160
|
+
}
|
|
161
|
+
return matches[0] ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
118
164
|
/**
|
|
119
165
|
* Extract and de-duplicate stable delivery routes from a persisted sessions map.
|
|
120
166
|
* Session ids and every other state field are intentionally ignored.
|
|
@@ -55,12 +55,12 @@ function deepFreeze(value) {
|
|
|
55
55
|
return Object.freeze(value);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function steeringMessage(text) {
|
|
58
|
+
function steeringMessage(text, rpcId) {
|
|
59
59
|
return deepFreeze({
|
|
60
60
|
id: randomUUID(),
|
|
61
61
|
role: 'user',
|
|
62
62
|
content: [{ type: 'text', text }],
|
|
63
|
-
source: { kind: 'user' },
|
|
63
|
+
source: { kind: 'user', rpcId },
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -71,7 +71,7 @@ function agentBusyError(cause) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function createControlExecutor(agents) {
|
|
74
|
-
return ({ sessionId, expectedTurn, promptRpcId, action, text }) => {
|
|
74
|
+
return ({ sessionId, expectedTurn, promptRpcId, inputRpcId, action, text }) => {
|
|
75
75
|
const agent = agents.get(sessionId);
|
|
76
76
|
// An unattached Session cannot be coordinated in-process. Let the client
|
|
77
77
|
// retain its legacy HTTP path for deployments where attachment is lazy.
|
|
@@ -83,10 +83,11 @@ function createControlExecutor(agents) {
|
|
|
83
83
|
return true;
|
|
84
84
|
}
|
|
85
85
|
if (action === 'steer') {
|
|
86
|
-
if (typeof text !== 'string' || !text.trim()
|
|
86
|
+
if (typeof text !== 'string' || !text.trim()
|
|
87
|
+
|| typeof inputRpcId !== 'string' || !inputRpcId) return false;
|
|
87
88
|
// inject() never wakes an idle driver. Because validation and injection
|
|
88
89
|
// share one JS tick, this context can only target this live turn's next step.
|
|
89
|
-
agent.inject(steeringMessage(text));
|
|
90
|
+
agent.inject(steeringMessage(text, inputRpcId));
|
|
90
91
|
return true;
|
|
91
92
|
}
|
|
92
93
|
throw new TypeError(`Unsupported Harness control action: ${String(action)}`);
|
|
@@ -14,6 +14,7 @@ import { installDeliveryRpc } from './delivery-rpc.mjs';
|
|
|
14
14
|
import { installDeliveryHttp } from './delivery-http.mjs';
|
|
15
15
|
import { createDeliveryService } from './delivery-service.mjs';
|
|
16
16
|
import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
|
|
17
|
+
import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
|
|
17
18
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
18
19
|
|
|
19
20
|
export const name = 'dsh-im-host';
|
|
@@ -36,6 +37,8 @@ export function createImHostPlugin(internals = {}) {
|
|
|
36
37
|
const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
|
|
37
38
|
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
|
38
39
|
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
|
|
40
|
+
const startSessionSync = internals.installSessionSyncCoordinator
|
|
41
|
+
?? installSessionSyncCoordinator;
|
|
39
42
|
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
|
40
43
|
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
|
41
44
|
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
|
@@ -63,7 +66,11 @@ export function createImHostPlugin(internals = {}) {
|
|
|
63
66
|
name,
|
|
64
67
|
inject,
|
|
65
68
|
async apply(ctx, config = {}) {
|
|
66
|
-
const
|
|
69
|
+
const unavailableSessionSyncChannels = channels
|
|
70
|
+
.map(([channel]) => channel)
|
|
71
|
+
.filter((channel) => channel !== 'office'
|
|
72
|
+
&& config[channel]?.harnessBaseUrl !== undefined);
|
|
73
|
+
const deliveryService = makeDeliveryService({ unavailableSessionSyncChannels });
|
|
67
74
|
if (typeof ctx?.provide === 'function') {
|
|
68
75
|
ctx.provide('dshIm', Object.freeze({
|
|
69
76
|
send: (botId, targetId, text, options) => (
|
|
@@ -135,6 +142,16 @@ export function createImHostPlugin(internals = {}) {
|
|
|
135
142
|
if (failures.length === channels.length) {
|
|
136
143
|
throw new AggregateError(failures, 'dsh-im failed to activate every channel');
|
|
137
144
|
}
|
|
145
|
+
if (typeof ctx?.on === 'function') {
|
|
146
|
+
try {
|
|
147
|
+
startSessionSync(ctx, deliveryService, {
|
|
148
|
+
logger,
|
|
149
|
+
inputScope: ctx.root ?? ctx,
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error?.('[dsh-im] failed to activate Session sync; continuing with channels', error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
138
155
|
}
|
|
139
156
|
}
|
|
140
157
|
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssistantTextAccumulator,
|
|
3
|
+
consumeDshImInputOrigin,
|
|
4
|
+
textFromHarnessContent,
|
|
5
|
+
} from '../../src/channels/shared/harness-client.mjs';
|
|
6
|
+
|
|
7
|
+
const DSH_USER_PREFIX = '[来自 DSH]\n';
|
|
8
|
+
const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n';
|
|
9
|
+
|
|
10
|
+
function completedTurn(reason) {
|
|
11
|
+
return (typeof reason === 'string' ? reason : reason?.kind) === 'completed';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function recipientKey(target) {
|
|
15
|
+
return JSON.stringify([target.channel, target.botId, target.targetId]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionIdOf(session) {
|
|
19
|
+
const value = session?.id ?? session?.sessionId;
|
|
20
|
+
return typeof value === 'string' && value ? value : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function userInputOrigin(scope, event) {
|
|
24
|
+
const source = event?.data?.source;
|
|
25
|
+
if (source?.kind !== 'user' || typeof source.rpcId !== 'string' || !source.rpcId) {
|
|
26
|
+
return 'other';
|
|
27
|
+
}
|
|
28
|
+
return consumeDshImInputOrigin(scope, source.rpcId) ? 'im' : 'dsh';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validRecipient(target) {
|
|
32
|
+
return target && typeof target === 'object'
|
|
33
|
+
&& typeof target.channel === 'string' && target.channel
|
|
34
|
+
&& typeof target.botId === 'string' && target.botId
|
|
35
|
+
&& typeof target.targetId === 'string' && target.targetId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createSessionSyncCoordinator({ deliveryService, logger = console }) {
|
|
39
|
+
if (typeof deliveryService?.listSessionSyncTargets !== 'function'
|
|
40
|
+
|| typeof deliveryService?.sendSessionSyncText !== 'function') {
|
|
41
|
+
throw new TypeError('Session sync requires a complete delivery service');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const turns = new Map();
|
|
45
|
+
const tails = new Map();
|
|
46
|
+
let closed = false;
|
|
47
|
+
|
|
48
|
+
const logFailure = (phase, target, error) => {
|
|
49
|
+
logger.warn?.(
|
|
50
|
+
`[dsh-im] ignored Session sync ${phase} failure`
|
|
51
|
+
+ (target ? ` (${target.channel}/${target.botId}/${target.targetId})` : '')
|
|
52
|
+
+ ` [${error?.code ?? error?.name ?? 'unknown-error'}]`,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const deliver = async (sessionId, targets, text, phase) => {
|
|
57
|
+
const unique = new Map();
|
|
58
|
+
for (const target of targets) {
|
|
59
|
+
if (validRecipient(target)) unique.set(recipientKey(target), target);
|
|
60
|
+
}
|
|
61
|
+
const entries = [...unique.entries()];
|
|
62
|
+
const results = await Promise.allSettled(entries.map(([, target]) => (
|
|
63
|
+
deliveryService.sendSessionSyncText(
|
|
64
|
+
target.botId,
|
|
65
|
+
target.targetId,
|
|
66
|
+
sessionId,
|
|
67
|
+
text,
|
|
68
|
+
)
|
|
69
|
+
)));
|
|
70
|
+
const successful = new Map();
|
|
71
|
+
results.forEach((result, index) => {
|
|
72
|
+
const [key, target] = entries[index];
|
|
73
|
+
if (result.status === 'fulfilled') successful.set(key, target);
|
|
74
|
+
else logFailure(phase, target, result.reason);
|
|
75
|
+
});
|
|
76
|
+
return successful;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const processEvent = async (sessionId, event, origin) => {
|
|
80
|
+
if (closed || !event || typeof event !== 'object') return;
|
|
81
|
+
|
|
82
|
+
if (event.type === 'turn/start') {
|
|
83
|
+
const turn = event.data?.turn;
|
|
84
|
+
if (!Number.isSafeInteger(turn)) return;
|
|
85
|
+
turns.set(sessionId, {
|
|
86
|
+
turn,
|
|
87
|
+
step: null,
|
|
88
|
+
origin: 'unknown',
|
|
89
|
+
recipients: null,
|
|
90
|
+
assistant: new AssistantTextAccumulator(),
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const state = turns.get(sessionId);
|
|
96
|
+
if (!state) return;
|
|
97
|
+
|
|
98
|
+
if (event.type === 'step/start') {
|
|
99
|
+
if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
|
|
100
|
+
state.step = Number.isSafeInteger(event.data?.step) ? event.data.step : null;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (event.type === 'step/end') {
|
|
105
|
+
if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
|
|
106
|
+
if (event.data?.step === undefined || event.data.step === state.step) state.step = null;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (event.type === 'user/message') {
|
|
111
|
+
if (event.surfaceOp !== 'append') return;
|
|
112
|
+
if (state.origin === 'unknown') state.origin = origin;
|
|
113
|
+
if (state.origin !== 'dsh' || origin !== 'dsh') return;
|
|
114
|
+
const text = textFromHarnessContent(event.data?.content);
|
|
115
|
+
if (!text) return;
|
|
116
|
+
|
|
117
|
+
let targets;
|
|
118
|
+
try {
|
|
119
|
+
targets = await deliveryService.listSessionSyncTargets(sessionId);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logFailure('lookup', null, error);
|
|
122
|
+
targets = [];
|
|
123
|
+
}
|
|
124
|
+
const successful = await deliver(
|
|
125
|
+
sessionId,
|
|
126
|
+
targets,
|
|
127
|
+
`${DSH_USER_PREFIX}${text}`,
|
|
128
|
+
'user delivery',
|
|
129
|
+
);
|
|
130
|
+
if (state.recipients === null) {
|
|
131
|
+
state.recipients = successful;
|
|
132
|
+
} else {
|
|
133
|
+
for (const key of [...state.recipients.keys()]) {
|
|
134
|
+
if (!successful.has(key)) state.recipients.delete(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (event.type === 'assistant/message') {
|
|
141
|
+
if (state.origin !== 'dsh' || event.surfaceOp !== 'append'
|
|
142
|
+
|| event.data?.interrupted === true
|
|
143
|
+
|| (event.data?.turn !== undefined && event.data.turn !== state.turn)) return;
|
|
144
|
+
const text = textFromHarnessContent(event.data?.message?.content);
|
|
145
|
+
const step = Number.isSafeInteger(event.data?.step) ? event.data.step : state.step;
|
|
146
|
+
state.assistant.setCanonical(step, text);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (event.type !== 'turn/end' || event.data?.turn !== state.turn) return;
|
|
151
|
+
turns.delete(sessionId);
|
|
152
|
+
if (state.origin !== 'dsh' || !completedTurn(event.data?.reason)
|
|
153
|
+
|| !state.recipients?.size || !state.assistant.text) return;
|
|
154
|
+
await deliver(
|
|
155
|
+
sessionId,
|
|
156
|
+
state.recipients.values(),
|
|
157
|
+
`${DSH_ASSISTANT_PREFIX}${state.assistant.text}`,
|
|
158
|
+
'assistant delivery',
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const enqueue = (sessionId, event, origin = 'other') => {
|
|
163
|
+
if (closed || typeof sessionId !== 'string' || !sessionId) return Promise.resolve();
|
|
164
|
+
const previous = tails.get(sessionId) ?? Promise.resolve();
|
|
165
|
+
const task = previous.then(
|
|
166
|
+
() => processEvent(sessionId, event, origin),
|
|
167
|
+
() => processEvent(sessionId, event, origin),
|
|
168
|
+
);
|
|
169
|
+
const tail = task.catch((error) => logFailure('event handling', null, error)).finally(() => {
|
|
170
|
+
if (tails.get(sessionId) === tail) tails.delete(sessionId);
|
|
171
|
+
});
|
|
172
|
+
tails.set(sessionId, tail);
|
|
173
|
+
return tail;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return Object.freeze({
|
|
177
|
+
enqueue,
|
|
178
|
+
async whenIdle() {
|
|
179
|
+
while (tails.size > 0) await Promise.allSettled([...tails.values()]);
|
|
180
|
+
},
|
|
181
|
+
close() {
|
|
182
|
+
closed = true;
|
|
183
|
+
turns.clear();
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function installSessionSyncCoordinator(ctx, deliveryService, {
|
|
189
|
+
logger = console,
|
|
190
|
+
inputScope = ctx?.root ?? ctx,
|
|
191
|
+
} = {}) {
|
|
192
|
+
if (typeof ctx?.on !== 'function') {
|
|
193
|
+
throw new TypeError('Session sync requires Host session events');
|
|
194
|
+
}
|
|
195
|
+
const coordinator = createSessionSyncCoordinator({ deliveryService, logger });
|
|
196
|
+
const disposeEvent = ctx.on('session/event', (session, event) => {
|
|
197
|
+
const sessionId = sessionIdOf(session);
|
|
198
|
+
if (!sessionId) return;
|
|
199
|
+
// Origin lookup must be synchronous: the originating ask may clean its id
|
|
200
|
+
// immediately after this event callback returns.
|
|
201
|
+
const origin = event?.type === 'user/message'
|
|
202
|
+
? userInputOrigin(inputScope, event)
|
|
203
|
+
: 'other';
|
|
204
|
+
void coordinator.enqueue(sessionId, event, origin);
|
|
205
|
+
}, { global: true });
|
|
206
|
+
let disposed = false;
|
|
207
|
+
const close = () => {
|
|
208
|
+
if (disposed) return;
|
|
209
|
+
disposed = true;
|
|
210
|
+
disposeEvent?.();
|
|
211
|
+
coordinator.close();
|
|
212
|
+
};
|
|
213
|
+
if (typeof ctx.effect === 'function') {
|
|
214
|
+
ctx.effect(() => close, 'dsh-im: Session sync coordinator');
|
|
215
|
+
}
|
|
216
|
+
return Object.freeze({ ...coordinator, close });
|
|
217
|
+
}
|
|
@@ -141,15 +141,21 @@ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).leng
|
|
|
141
141
|
if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
|
|
142
142
|
throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
|
|
143
143
|
}
|
|
144
|
-
// Connections still have no channel-enable toggle.
|
|
145
|
-
//
|
|
146
|
-
// switch template and one mapped field-input
|
|
144
|
+
// Connections still have no channel-enable toggle. Checkable inputs are owned
|
|
145
|
+
// only by the shared context editor and the saved-target Session sync row.
|
|
146
|
+
// The context editor contains one switch template and one mapped field-input
|
|
147
|
+
// template; the delivery target adds one ordinary checkbox template.
|
|
147
148
|
const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
|
|
148
|
-
const
|
|
149
|
+
const deliverySettingsSource = await readFile(resolve(root, 'plugin-src/client/delivery-settings.js'), 'utf8');
|
|
150
|
+
const otherClientSources = clientSources
|
|
151
|
+
.replace(contextEditorSource, '')
|
|
152
|
+
.replace(deliverySettingsSource, '');
|
|
149
153
|
if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
|
|
154
|
+
|| (deliverySettingsSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
|
|
155
|
+
|| /role:\s*["']switch["']/u.test(deliverySettingsSource)
|
|
150
156
|
|| (client.match(/role:\s*["']switch["']/g) ?? []).length !== 1
|
|
151
|
-
|| (client.match(/type:\s*["']checkbox["']/g) ?? []).length !==
|
|
152
|
-
throw new Error('checkable inputs must be limited to
|
|
157
|
+
|| (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 3) {
|
|
158
|
+
throw new Error('checkable inputs must be limited to context enhancement and Session sync');
|
|
153
159
|
}
|
|
154
160
|
for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
|
|
155
161
|
if (!host.includes(marker) || !client.includes(marker)) {
|