@xmanrui/dsh-im 4.10.0 → 4.11.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 +5 -1
- package/README.md +6 -2
- package/lib/client.js +1619 -769
- package/lib/index.js +248 -244
- 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 +25 -2
- 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 +26 -204
- package/plugin-src/client/channels/qq/api.js +4 -0
- package/plugin-src/client/channels/qq/index.js +25 -2
- package/plugin-src/client/channels/shared/token-api.js +4 -0
- package/plugin-src/client/channels/shared/token-channel.js +25 -3
- package/plugin-src/client/channels/wecom/api.js +4 -0
- package/plugin-src/client/channels/wecom/index.js +25 -2
- package/plugin-src/client/channels/weixin/api.js +4 -0
- package/plugin-src/client/channels/weixin/index.js +25 -2
- package/plugin-src/client/channels/whatsapp/api.js +4 -0
- package/plugin-src/client/channels/whatsapp/index.js +24 -2
- package/plugin-src/client/delivery-settings.js +73 -5
- package/plugin-src/client/i18n.js +20 -0
- package/plugin-src/client/model-setting.js +104 -0
- package/plugin-src/client/styles.js +50 -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/model-setting.mjs +110 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
deliverySuggestionsFromSessions,
|
|
3
|
+
privateConversationKeyMatchesTarget,
|
|
4
|
+
resolvePrivateConversationKey,
|
|
5
|
+
} from './delivery-suggestions.mjs';
|
|
2
6
|
|
|
3
7
|
const CHANNELS = new Set([
|
|
4
8
|
'weixin',
|
|
@@ -22,6 +26,12 @@ function invalidTarget(message) {
|
|
|
22
26
|
return error;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
function sessionSyncUnavailable(message = 'Session sync is unavailable for this target') {
|
|
30
|
+
const error = new Error(message);
|
|
31
|
+
error.code = 'session-sync-unavailable';
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
function isRecord(value) {
|
|
26
36
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
37
|
}
|
|
@@ -129,6 +139,49 @@ export function normalizeDeliveryTarget(channel, value, { targetIdRequired = tru
|
|
|
129
139
|
return normalized;
|
|
130
140
|
}
|
|
131
141
|
|
|
142
|
+
function targetWithoutSessionSync(target) {
|
|
143
|
+
return {
|
|
144
|
+
targetId: target.targetId,
|
|
145
|
+
...(target.name === undefined ? {} : { name: target.name }),
|
|
146
|
+
kind: target.kind,
|
|
147
|
+
route: target.route,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sessionSyncConfigs(workspaces, botId) {
|
|
152
|
+
if (typeof workspaces.listSessionSyncTargets !== 'function') return [];
|
|
153
|
+
const targets = workspaces.listSessionSyncTargets();
|
|
154
|
+
return botId === undefined ? targets : targets.filter((target) => target.botId === botId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stateSessions(state) {
|
|
158
|
+
if (!state || typeof state.snapshot !== 'function') return null;
|
|
159
|
+
const sessions = state.snapshot()?.sessions;
|
|
160
|
+
return sessions && typeof sessions === 'object' && !Array.isArray(sessions) ? sessions : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sessionSyncView(channel, target, sessions, configured) {
|
|
164
|
+
if (configured) {
|
|
165
|
+
const valid = privateConversationKeyMatchesTarget(
|
|
166
|
+
channel,
|
|
167
|
+
configured.conversationKey,
|
|
168
|
+
target,
|
|
169
|
+
);
|
|
170
|
+
if (!valid || !sessions) return { enabled: true, state: 'unavailable' };
|
|
171
|
+
return {
|
|
172
|
+
enabled: true,
|
|
173
|
+
state: typeof sessions[configured.conversationKey] === 'string'
|
|
174
|
+
&& Boolean(sessions[configured.conversationKey])
|
|
175
|
+
? 'active'
|
|
176
|
+
: 'waiting',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const conversationKey = sessions
|
|
180
|
+
? resolvePrivateConversationKey(channel, sessions, target)
|
|
181
|
+
: null;
|
|
182
|
+
return { enabled: false, state: conversationKey ? 'off' : 'unavailable' };
|
|
183
|
+
}
|
|
184
|
+
|
|
132
185
|
/** Bind one channel's existing workspace store and unwrapped controller to DeliveryService. */
|
|
133
186
|
export function createDeliveryAdapter({ channel, workspaces, coreController, stateFor }) {
|
|
134
187
|
if (!supportsDeliveryChannel(channel)) throw new TypeError(`Unsupported delivery channel: ${channel}`);
|
|
@@ -145,7 +198,22 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
145
198
|
channel,
|
|
146
199
|
ownsBot: (botId) => workspaces.has(botId),
|
|
147
200
|
listBots: () => workspaces.listBotIds(),
|
|
148
|
-
listTargets
|
|
201
|
+
async listTargets(botId) {
|
|
202
|
+
const targets = workspaces.listDeliveryTargets(botId);
|
|
203
|
+
const configured = new Map(
|
|
204
|
+
sessionSyncConfigs(workspaces, botId).map((target) => [target.targetId, target]),
|
|
205
|
+
);
|
|
206
|
+
let sessions = null;
|
|
207
|
+
try {
|
|
208
|
+
sessions = stateSessions(await stateFor(botId));
|
|
209
|
+
} catch {
|
|
210
|
+
// Target CRUD remains usable when its persisted conversation state cannot be read.
|
|
211
|
+
}
|
|
212
|
+
return targets.map((target) => ({
|
|
213
|
+
...target,
|
|
214
|
+
sessionSync: sessionSyncView(channel, target, sessions, configured.get(target.targetId)),
|
|
215
|
+
}));
|
|
216
|
+
},
|
|
149
217
|
async listSuggestions(botId) {
|
|
150
218
|
const state = await stateFor(botId);
|
|
151
219
|
if (!state || typeof state.snapshot !== 'function') {
|
|
@@ -169,12 +237,82 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
|
|
|
169
237
|
),
|
|
170
238
|
deleteTarget: (botId, targetId) => workspaces.deleteDeliveryTarget(botId, targetId),
|
|
171
239
|
async sendText(botId, target, text, options = {}) {
|
|
172
|
-
const normalized = normalizeDeliveryTarget(channel, target);
|
|
240
|
+
const normalized = normalizeDeliveryTarget(channel, targetWithoutSessionSync(target));
|
|
173
241
|
if (typeof coreController.sendProactiveText !== 'function') {
|
|
174
242
|
throw new TypeError('delivery controller cannot send proactive text');
|
|
175
243
|
}
|
|
176
244
|
await coreController.sendProactiveText(botId, normalized, text, options);
|
|
177
245
|
return { sent: true };
|
|
178
246
|
},
|
|
247
|
+
async setSessionSync(botId, targetId, enabled) {
|
|
248
|
+
if (typeof workspaces.setDeliveryTargetSessionSync !== 'function') {
|
|
249
|
+
throw sessionSyncUnavailable();
|
|
250
|
+
}
|
|
251
|
+
const target = workspaces.deliveryTargetFor(botId, targetId);
|
|
252
|
+
if (!target) {
|
|
253
|
+
const error = new Error('Unknown target');
|
|
254
|
+
error.code = 'unknown-target';
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
if (!enabled) {
|
|
258
|
+
await workspaces.setDeliveryTargetSessionSync(botId, targetId, null);
|
|
259
|
+
let sessions = null;
|
|
260
|
+
try {
|
|
261
|
+
sessions = stateSessions(await stateFor(botId));
|
|
262
|
+
} catch {
|
|
263
|
+
// The setting is already safely disabled; only its availability is unknown.
|
|
264
|
+
}
|
|
265
|
+
return sessionSyncView(channel, target, sessions, null);
|
|
266
|
+
}
|
|
267
|
+
const sessions = stateSessions(await stateFor(botId));
|
|
268
|
+
const conversationKey = resolvePrivateConversationKey(channel, sessions, target);
|
|
269
|
+
if (!conversationKey) throw sessionSyncUnavailable();
|
|
270
|
+
await workspaces.setDeliveryTargetSessionSync(botId, targetId, conversationKey);
|
|
271
|
+
return { enabled: true, state: 'active' };
|
|
272
|
+
},
|
|
273
|
+
async listSessionSyncTargets(sessionId) {
|
|
274
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
275
|
+
throw new TypeError('sessionId is required');
|
|
276
|
+
}
|
|
277
|
+
const matches = [];
|
|
278
|
+
for (const configured of sessionSyncConfigs(workspaces)) {
|
|
279
|
+
const target = workspaces.deliveryTargetFor(configured.botId, configured.targetId);
|
|
280
|
+
if (!target || !privateConversationKeyMatchesTarget(
|
|
281
|
+
channel,
|
|
282
|
+
configured.conversationKey,
|
|
283
|
+
target,
|
|
284
|
+
)) continue;
|
|
285
|
+
let sessions;
|
|
286
|
+
try {
|
|
287
|
+
sessions = stateSessions(await stateFor(configured.botId));
|
|
288
|
+
} catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (sessions?.[configured.conversationKey] === sessionId) {
|
|
292
|
+
matches.push({ botId: configured.botId, targetId: configured.targetId });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return matches;
|
|
296
|
+
},
|
|
297
|
+
async sendSessionSyncText(botId, targetId, sessionId, text, options = {}) {
|
|
298
|
+
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
|
|
299
|
+
const configured = sessionSyncConfigs(workspaces, botId)
|
|
300
|
+
.find((target) => target.targetId === targetId);
|
|
301
|
+
const target = workspaces.deliveryTargetFor(botId, targetId);
|
|
302
|
+
if (!configured || !target || !privateConversationKeyMatchesTarget(
|
|
303
|
+
channel,
|
|
304
|
+
configured.conversationKey,
|
|
305
|
+
target,
|
|
306
|
+
)) throw sessionSyncUnavailable('Session sync target is no longer enabled');
|
|
307
|
+
const sessions = stateSessions(await stateFor(botId));
|
|
308
|
+
if (sessions?.[configured.conversationKey] !== sessionId) {
|
|
309
|
+
throw sessionSyncUnavailable('Session sync target is no longer bound to this Session');
|
|
310
|
+
}
|
|
311
|
+
if (typeof coreController.sendProactiveText !== 'function') {
|
|
312
|
+
throw new TypeError('delivery controller cannot send proactive text');
|
|
313
|
+
}
|
|
314
|
+
await coreController.sendProactiveText(botId, target, text, options);
|
|
315
|
+
return { sent: true };
|
|
316
|
+
},
|
|
179
317
|
});
|
|
180
318
|
}
|
|
@@ -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
|
|