@xmanrui/dsh-im 0.4.0 → 0.6.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/README.md +36 -0
- package/lib/client.js +709 -285
- package/lib/index.js +114 -110
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/api.js +2 -0
- package/plugin-src/client/channels/dingtalk/index.js +67 -14
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +70 -22
- package/plugin-src/client/channels/qq/api.js +2 -0
- package/plugin-src/client/channels/qq/index.js +45 -8
- package/plugin-src/client/channels/shared/token-api.js +2 -0
- package/plugin-src/client/channels/shared/token-channel.js +34 -8
- package/plugin-src/client/channels/wecom/api.js +2 -0
- package/plugin-src/client/channels/wecom/index.js +45 -8
- package/plugin-src/client/channels/weixin/api.js +2 -0
- package/plugin-src/client/channels/weixin/index.js +68 -8
- package/plugin-src/client/channels/whatsapp/api.js +2 -0
- package/plugin-src/client/channels/whatsapp/index.js +27 -5
- package/plugin-src/client/i18n.js +13 -0
- package/plugin-src/client/styles.js +13 -0
- package/plugin-src/client/workspace-editor.js +96 -0
- package/plugin-src/client/workspace-snapshot-fence.js +28 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +34 -11
- package/plugin-src/host/channels/dingtalk/rpc.mjs +17 -2
- package/plugin-src/host/channels/feishu/production.mjs +42 -5
- package/plugin-src/host/channels/feishu/rpc.mjs +17 -2
- package/plugin-src/host/channels/qq/production.mjs +48 -22
- package/plugin-src/host/channels/qq/rpc.mjs +16 -2
- package/plugin-src/host/channels/shared/production.mjs +48 -22
- package/plugin-src/host/channels/shared/rpc.mjs +15 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +25 -0
- package/plugin-src/host/channels/slack/production.mjs +48 -23
- package/plugin-src/host/channels/slack/rpc.mjs +16 -0
- package/plugin-src/host/channels/wecom/production.mjs +49 -23
- package/plugin-src/host/channels/wecom/rpc.mjs +16 -2
- package/plugin-src/host/channels/weixin/production.mjs +31 -11
- package/plugin-src/host/channels/weixin/rpc.mjs +21 -2
- package/plugin-src/host/channels/whatsapp/production.mjs +49 -23
- package/plugin-src/host/channels/whatsapp/rpc.mjs +16 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +25 -11
- package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
- package/src/channels/dingtalk/harness-client.mjs +17 -3
- package/src/channels/dingtalk/state-store.mjs +5 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +39 -16
- package/src/channels/feishu/harness-client.mjs +19 -5
- package/src/channels/feishu/state-store.mjs +5 -0
- package/src/channels/qq/qq-bridge.mjs +28 -15
- package/src/channels/qq/qq-controller.mjs +8 -1
- package/src/channels/qq/state-store.mjs +5 -0
- package/src/channels/shared/bot-workspace-store.mjs +618 -0
- package/src/channels/shared/conversation-state-store.mjs +5 -0
- package/src/channels/shared/text-harness-bridge.mjs +26 -13
- package/src/channels/shared/workspace-command.mjs +115 -0
- package/src/channels/shared/workspace-session.mjs +44 -0
- package/src/channels/wecom/state-store.mjs +5 -0
- package/src/channels/wecom/wecom-bridge.mjs +25 -13
- package/src/channels/wecom/wecom-controller.mjs +8 -1
- package/src/channels/weixin/harness-client.mjs +19 -5
- package/src/channels/weixin/state-store.mjs +5 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +19 -6
- package/src/channels/weixin/weixin-controller.mjs +6 -1
- package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
|
5
|
+
|
|
6
|
+
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
|
7
|
+
|
|
8
|
+
function botIdOf(value) {
|
|
9
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
|
10
|
+
throw new TypeError('Invalid bot id');
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeDocument(value) {
|
|
16
|
+
if (!value || value.version !== 1 || !value.workspaces
|
|
17
|
+
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
|
|
18
|
+
const workspaces = {};
|
|
19
|
+
for (const [botId, workspace] of Object.entries(value.workspaces)) {
|
|
20
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)
|
|
21
|
+
|| typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
|
|
22
|
+
workspaces[botId] = resolve(workspace);
|
|
23
|
+
}
|
|
24
|
+
return { version: 1, workspaces };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function validateWorkspacePath(value) {
|
|
28
|
+
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
|
|
29
|
+
const error = new Error('工作区必须是绝对路径。');
|
|
30
|
+
error.code = 'workspace-not-absolute';
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const workspace = resolve(value.trim());
|
|
34
|
+
let info;
|
|
35
|
+
try {
|
|
36
|
+
info = await stat(workspace);
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
const error = new Error('工作区路径不存在。', { cause });
|
|
39
|
+
error.code = 'workspace-not-found';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (!info.isDirectory()) {
|
|
43
|
+
const error = new Error('工作区路径必须指向一个目录。');
|
|
44
|
+
error.code = 'workspace-not-directory';
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return workspace;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class BotWorkspaceStore {
|
|
51
|
+
#path;
|
|
52
|
+
#defaultWorkspace;
|
|
53
|
+
#workspaces = {};
|
|
54
|
+
#generations = new Map();
|
|
55
|
+
#nextGeneration = 1;
|
|
56
|
+
#incarnations = new Map();
|
|
57
|
+
#nextIncarnation = 1;
|
|
58
|
+
#removals = new Map();
|
|
59
|
+
#removalDetails = new WeakMap();
|
|
60
|
+
#dirtyRemovals = new Set();
|
|
61
|
+
#writeQueue = Promise.resolve();
|
|
62
|
+
#botQueues = new Map();
|
|
63
|
+
|
|
64
|
+
constructor(path, { defaultWorkspace = process.cwd() } = {}) {
|
|
65
|
+
if (typeof path !== 'string' || !path) throw new TypeError('workspace store path is required');
|
|
66
|
+
this.#path = path;
|
|
67
|
+
this.#defaultWorkspace = resolve(defaultWorkspace);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async load() {
|
|
71
|
+
try {
|
|
72
|
+
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
73
|
+
if (!normalized) throw new Error('dsh-im workspace config is invalid');
|
|
74
|
+
this.#workspaces = normalized.workspaces;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
77
|
+
this.#workspaces = {};
|
|
78
|
+
}
|
|
79
|
+
this.#generations.clear();
|
|
80
|
+
this.#nextGeneration = 1;
|
|
81
|
+
this.#incarnations.clear();
|
|
82
|
+
this.#nextIncarnation = 1;
|
|
83
|
+
this.#removals.clear();
|
|
84
|
+
this.#dirtyRemovals.clear();
|
|
85
|
+
for (const botId of Object.keys(this.#workspaces)) {
|
|
86
|
+
this.#generations.set(botId, this.#freshGeneration());
|
|
87
|
+
this.#incarnations.set(botId, this.#freshIncarnation());
|
|
88
|
+
}
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
has(botId) {
|
|
93
|
+
const id = botIdOf(botId);
|
|
94
|
+
return Object.hasOwn(this.#workspaces, id) && !this.#removals.has(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
incarnationFor(botId) {
|
|
98
|
+
return this.#incarnations.get(botIdOf(botId)) ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
workspaceFor(botId) {
|
|
102
|
+
return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
generationFor(botId) {
|
|
106
|
+
return this.#generations.get(botIdOf(botId)) ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async whenIdle() {
|
|
110
|
+
await this.#writeQueue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async whenBotIdle(botId) {
|
|
114
|
+
const id = botIdOf(botId);
|
|
115
|
+
while (true) {
|
|
116
|
+
const pending = this.#botQueues.get(id);
|
|
117
|
+
if (!pending) return;
|
|
118
|
+
await pending;
|
|
119
|
+
if (this.#botQueues.get(id) === pending) return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async ensure(botId, { workspace = this.#defaultWorkspace } = {}) {
|
|
124
|
+
const id = botIdOf(botId);
|
|
125
|
+
const initialWorkspace = resolve(workspace);
|
|
126
|
+
return this.#enqueue(id, async () => {
|
|
127
|
+
if (!this.#workspaces[id]) {
|
|
128
|
+
this.#workspaces[id] = initialWorkspace;
|
|
129
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
130
|
+
this.#incarnations.set(id, this.#freshIncarnation());
|
|
131
|
+
try {
|
|
132
|
+
await this.#persist();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
delete this.#workspaces[id];
|
|
135
|
+
this.#generations.delete(id);
|
|
136
|
+
this.#incarnations.delete(id);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
} else if (!this.#generations.has(id)) {
|
|
140
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
141
|
+
}
|
|
142
|
+
return this.#workspaces[id];
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async setWorkspace(botId, value, { clearSessions, incarnation } = {}) {
|
|
147
|
+
const id = botIdOf(botId);
|
|
148
|
+
if (!this.has(id)
|
|
149
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
150
|
+
const error = new Error('找不到要修改的机器人。');
|
|
151
|
+
error.code = 'workspace-bot-not-found';
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const workspace = await validateWorkspacePath(value);
|
|
155
|
+
return this.#enqueue(id, async () => {
|
|
156
|
+
if (!this.has(id)
|
|
157
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
158
|
+
const error = new Error('找不到要修改的机器人。');
|
|
159
|
+
error.code = 'workspace-bot-not-found';
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
if (workspace === this.workspaceFor(id)) return workspace;
|
|
163
|
+
const previous = this.#workspaces[id];
|
|
164
|
+
// Advance first so a session creation that started before this queued
|
|
165
|
+
// transition can never be written back after the clear.
|
|
166
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
167
|
+
// Clear the old session mapping before publishing the new workspace.
|
|
168
|
+
// A crash can then lose conversation continuity, but can never pair the
|
|
169
|
+
// new workspace with sessions created in the old one.
|
|
170
|
+
await clearSessions?.();
|
|
171
|
+
this.#workspaces[id] = workspace;
|
|
172
|
+
try {
|
|
173
|
+
await this.#persist();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
this.#workspaces[id] = previous;
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return workspace;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async invalidateSessions(botId, { clearSessions } = {}) {
|
|
183
|
+
const id = botIdOf(botId);
|
|
184
|
+
return this.#enqueue(id, async () => {
|
|
185
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
186
|
+
await clearSessions?.();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fence one lifecycle and return the opaque token required to abort/finish it. */
|
|
191
|
+
async beginRemoval(botId, { clearSessions } = {}) {
|
|
192
|
+
const id = botIdOf(botId);
|
|
193
|
+
return this.#enqueue(id, async () => {
|
|
194
|
+
const existing = this.#removals.get(id);
|
|
195
|
+
if (existing) return existing;
|
|
196
|
+
const transaction = Object.freeze({});
|
|
197
|
+
this.#removals.set(id, transaction);
|
|
198
|
+
this.#removalDetails.set(transaction, {
|
|
199
|
+
botId: id,
|
|
200
|
+
incarnation: this.incarnationFor(id),
|
|
201
|
+
});
|
|
202
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
203
|
+
try {
|
|
204
|
+
await clearSessions?.();
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (this.#removals.get(id) === transaction) this.#removals.delete(id);
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
return transaction;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Re-open only the lifecycle represented by transaction; stale tokens are no-ops. */
|
|
214
|
+
async abortRemoval(transaction) {
|
|
215
|
+
const { botId: id } = this.#removalDetailsFor(transaction);
|
|
216
|
+
return this.#enqueue(id, async () => {
|
|
217
|
+
if (this.#removals.get(id) !== transaction) return false;
|
|
218
|
+
this.#removals.delete(id);
|
|
219
|
+
if (Object.hasOwn(this.#workspaces, id)) {
|
|
220
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
221
|
+
if (!this.#incarnations.has(id)) {
|
|
222
|
+
this.#incarnations.set(id, this.#freshIncarnation());
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Retire only the lifecycle represented by transaction; stale tokens are no-ops. */
|
|
230
|
+
async finishRemoval(transaction) {
|
|
231
|
+
const { botId: id, incarnation } = this.#removalDetailsFor(transaction);
|
|
232
|
+
return this.#enqueue(id, async () => {
|
|
233
|
+
if (this.#removals.get(id) !== transaction) {
|
|
234
|
+
return { removed: false, persisted: true, error: null, stale: true };
|
|
235
|
+
}
|
|
236
|
+
if (this.incarnationFor(id) !== incarnation) {
|
|
237
|
+
this.#removals.delete(id);
|
|
238
|
+
return { removed: false, persisted: true, error: null, stale: true };
|
|
239
|
+
}
|
|
240
|
+
this.#removals.delete(id);
|
|
241
|
+
return this.#retireCurrentIncarnation(id);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Commit the workspace lifecycle after the config store durably removed a bot. */
|
|
246
|
+
async retireAfterConfigCommit(botId) {
|
|
247
|
+
const id = botIdOf(botId);
|
|
248
|
+
return this.#enqueue(id, async () => {
|
|
249
|
+
this.#removals.delete(id);
|
|
250
|
+
return this.#retireCurrentIncarnation(id);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async remove(botId) {
|
|
255
|
+
const result = await this.retireAfterConfigCommit(botId);
|
|
256
|
+
if (result.error) throw result.error;
|
|
257
|
+
return result.removed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async reconcile(activeBotIds) {
|
|
261
|
+
const active = new Set([...activeBotIds].map(botIdOf));
|
|
262
|
+
const candidates = new Set([...Object.keys(this.#workspaces), ...this.#dirtyRemovals]);
|
|
263
|
+
for (const botId of candidates) {
|
|
264
|
+
if (!active.has(botId)) await this.remove(botId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
decorateStatus(status) {
|
|
269
|
+
if (!status || typeof status !== 'object' || !Array.isArray(status.bots)) return status;
|
|
270
|
+
return {
|
|
271
|
+
...status,
|
|
272
|
+
bots: status.bots.map((bot) => bot?.botId
|
|
273
|
+
? { ...bot, workspace: this.workspaceFor(bot.botId) }
|
|
274
|
+
: bot),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#freshGeneration() {
|
|
279
|
+
const generation = this.#nextGeneration;
|
|
280
|
+
this.#nextGeneration += 1;
|
|
281
|
+
return generation;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#freshIncarnation() {
|
|
285
|
+
const incarnation = this.#nextIncarnation;
|
|
286
|
+
this.#nextIncarnation += 1;
|
|
287
|
+
return incarnation;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#removalDetailsFor(transaction) {
|
|
291
|
+
if (!transaction || typeof transaction !== 'object') {
|
|
292
|
+
throw new TypeError('Invalid workspace removal transaction');
|
|
293
|
+
}
|
|
294
|
+
const details = this.#removalDetails.get(transaction);
|
|
295
|
+
if (!details) throw new TypeError('Invalid workspace removal transaction');
|
|
296
|
+
return details;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async #retireCurrentIncarnation(id) {
|
|
300
|
+
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
|
|
301
|
+
const needsCleanup = hadWorkspace || this.#dirtyRemovals.has(id);
|
|
302
|
+
delete this.#workspaces[id];
|
|
303
|
+
this.#generations.delete(id);
|
|
304
|
+
this.#incarnations.delete(id);
|
|
305
|
+
if (!needsCleanup) return {
|
|
306
|
+
removed: false, persisted: true, error: null, stale: false,
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
await this.#persistCurrentDocument();
|
|
310
|
+
return {
|
|
311
|
+
removed: hadWorkspace, persisted: true, error: null, stale: false,
|
|
312
|
+
};
|
|
313
|
+
} catch (error) {
|
|
314
|
+
this.#dirtyRemovals.add(id);
|
|
315
|
+
return {
|
|
316
|
+
removed: hadWorkspace, persisted: false, error, stale: false,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async #enqueue(botId, operation) {
|
|
322
|
+
const queued = this.#writeQueue.then(operation, operation);
|
|
323
|
+
const settled = queued.then(() => undefined, () => undefined);
|
|
324
|
+
this.#writeQueue = settled;
|
|
325
|
+
this.#botQueues.set(botId, settled);
|
|
326
|
+
void settled.finally(() => {
|
|
327
|
+
if (this.#botQueues.get(botId) === settled) this.#botQueues.delete(botId);
|
|
328
|
+
});
|
|
329
|
+
return queued;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #persist() {
|
|
333
|
+
const document = { version: 1, workspaces: this.#workspaces };
|
|
334
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
335
|
+
const temporary = `${this.#path}.tmp`;
|
|
336
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
337
|
+
encoding: 'utf8',
|
|
338
|
+
mode: 0o600,
|
|
339
|
+
});
|
|
340
|
+
await rename(temporary, this.#path);
|
|
341
|
+
this.#dirtyRemovals.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async #persistCurrentDocument() {
|
|
345
|
+
if (Object.keys(this.#workspaces).length > 0) {
|
|
346
|
+
await this.#persist();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
await unlink(this.#path);
|
|
351
|
+
this.#dirtyRemovals.clear();
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
354
|
+
this.#dirtyRemovals.clear();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function decorateResult(workspaces, result) {
|
|
360
|
+
return result && typeof result.then === 'function'
|
|
361
|
+
? result.then((value) => workspaces.decorateStatus(value))
|
|
362
|
+
: workspaces.decorateStatus(result);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function targetStatus(controller) {
|
|
366
|
+
return Promise.resolve(controller.status());
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Observe the config store's durable removal commit without changing its API. */
|
|
370
|
+
export function observeBotWorkspaceRemovals(
|
|
371
|
+
configStore,
|
|
372
|
+
{ workspaces, method = 'remove', botIdFromRemoved = (removed) => removed?.botId },
|
|
373
|
+
) {
|
|
374
|
+
if (!configStore || !workspaces || typeof configStore[method] !== 'function') {
|
|
375
|
+
throw new TypeError('configStore removal observer dependencies are required');
|
|
376
|
+
}
|
|
377
|
+
return new Proxy(configStore, {
|
|
378
|
+
get(target, property) {
|
|
379
|
+
const value = Reflect.get(target, property, target);
|
|
380
|
+
if (property === method) {
|
|
381
|
+
return async (...args) => {
|
|
382
|
+
const removed = await value.apply(target, args);
|
|
383
|
+
const botId = removed ? botIdFromRemoved(removed, args) : null;
|
|
384
|
+
if (botId) await workspaces.retireAfterConfigCommit(botId);
|
|
385
|
+
return removed;
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|
394
|
+
if (!harness || !workspaces || !state) throw new TypeError('harness, workspaces, and state are required');
|
|
395
|
+
const incarnation = workspaces.incarnationFor(botId);
|
|
396
|
+
const isCurrentScope = () => workspaces.has(botId)
|
|
397
|
+
&& workspaces.incarnationFor(botId) === incarnation;
|
|
398
|
+
const sessionGenerations = new Map();
|
|
399
|
+
const scopedHarness = new Proxy(harness, {
|
|
400
|
+
get(target, property) {
|
|
401
|
+
if (property === 'currentWorkspace') {
|
|
402
|
+
return () => {
|
|
403
|
+
if (!isCurrentScope()) {
|
|
404
|
+
const error = new Error('找不到要修改的机器人。');
|
|
405
|
+
error.code = 'workspace-bot-not-found';
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
return workspaces.workspaceFor(botId);
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (property === 'assertWorkspaceScope') {
|
|
412
|
+
return () => {
|
|
413
|
+
if (!isCurrentScope()) {
|
|
414
|
+
const error = new Error('找不到要修改的机器人。');
|
|
415
|
+
error.code = 'workspace-bot-not-found';
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
|
|
421
|
+
return async (...args) => {
|
|
422
|
+
if (!isCurrentScope()) {
|
|
423
|
+
const error = new Error('找不到要修改的机器人。');
|
|
424
|
+
error.code = 'workspace-bot-not-found';
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
const paths = await target.listWorkspaces(...args);
|
|
428
|
+
if (!isCurrentScope()) {
|
|
429
|
+
const error = new Error('找不到要修改的机器人。');
|
|
430
|
+
error.code = 'workspace-bot-not-found';
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
return paths;
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (property === 'switchWorkspace') {
|
|
437
|
+
return (workspace) => {
|
|
438
|
+
if (!isCurrentScope()) {
|
|
439
|
+
const error = new Error('找不到要修改的机器人。');
|
|
440
|
+
error.code = 'workspace-bot-not-found';
|
|
441
|
+
return Promise.reject(error);
|
|
442
|
+
}
|
|
443
|
+
return workspaces.setWorkspace(botId, workspace, {
|
|
444
|
+
clearSessions: () => state.clearSessions(),
|
|
445
|
+
incarnation,
|
|
446
|
+
});
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (property === 'createSession') {
|
|
450
|
+
return async (options = {}) => {
|
|
451
|
+
await workspaces.whenBotIdle(botId);
|
|
452
|
+
if (!isCurrentScope()) {
|
|
453
|
+
const error = new Error('找不到要修改的机器人。');
|
|
454
|
+
error.code = 'workspace-bot-not-found';
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
const generation = workspaces.generationFor(botId);
|
|
458
|
+
const sessionId = await target.createSession({
|
|
459
|
+
...options,
|
|
460
|
+
workspace: workspaces.workspaceFor(botId),
|
|
461
|
+
});
|
|
462
|
+
sessionGenerations.set(sessionId, generation);
|
|
463
|
+
return sessionId;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (property === 'sessionExists') {
|
|
467
|
+
return (sessionId, ...args) => {
|
|
468
|
+
if (!isCurrentScope()) return false;
|
|
469
|
+
const generation = sessionGenerations.get(sessionId);
|
|
470
|
+
if (generation !== undefined && generation !== workspaces.generationFor(botId)) {
|
|
471
|
+
sessionGenerations.delete(sessionId);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
return target.sessionExists(sessionId, ...args);
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (property === 'ask') {
|
|
478
|
+
return (sessionId, ...args) => {
|
|
479
|
+
const generation = sessionGenerations.get(sessionId);
|
|
480
|
+
sessionGenerations.delete(sessionId);
|
|
481
|
+
if (!isCurrentScope()
|
|
482
|
+
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
|
483
|
+
const error = new Error('The bot workspace changed before this prompt started.');
|
|
484
|
+
error.code = WORKSPACE_SESSION_STALE;
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
return target.ask(sessionId, ...args);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const value = Reflect.get(target, property, target);
|
|
491
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const scopedState = new Proxy(state, {
|
|
495
|
+
get(target, property) {
|
|
496
|
+
if (property === 'sessionFor') {
|
|
497
|
+
return (key, ...args) => {
|
|
498
|
+
if (!isCurrentScope()) return null;
|
|
499
|
+
const sessionId = target.sessionFor(key, ...args);
|
|
500
|
+
if (sessionId && !sessionGenerations.has(sessionId)) {
|
|
501
|
+
sessionGenerations.set(sessionId, workspaces.generationFor(botId));
|
|
502
|
+
}
|
|
503
|
+
return sessionId;
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (property === 'setSession') {
|
|
507
|
+
return (key, sessionId, ...args) => {
|
|
508
|
+
const generation = sessionGenerations.get(sessionId);
|
|
509
|
+
if (!isCurrentScope()
|
|
510
|
+
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
|
511
|
+
sessionGenerations.delete(sessionId);
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
return target.setSession(key, sessionId, ...args);
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const value = Reflect.get(target, property, target);
|
|
518
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
return Object.freeze({ harness: scopedHarness, state: scopedState });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function createBotScopedHarness(harness, options) {
|
|
525
|
+
return createBotWorkspaceScope(harness, options).harness;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function createWorkspaceAwareController(controller, { workspaces, stateFor }) {
|
|
529
|
+
if (!controller || !workspaces || typeof stateFor !== 'function') {
|
|
530
|
+
throw new TypeError('controller, workspaces, and stateFor are required');
|
|
531
|
+
}
|
|
532
|
+
const transitions = new Map();
|
|
533
|
+
const withBotTransition = (botId, operation) => {
|
|
534
|
+
const previous = transitions.get(botId) ?? Promise.resolve();
|
|
535
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
536
|
+
transitions.set(botId, current);
|
|
537
|
+
return current.finally(() => {
|
|
538
|
+
if (transitions.get(botId) === current) transitions.delete(botId);
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
const updateWorkspace = (botId, workspace) => {
|
|
542
|
+
// Capture at API invocation, before even waiting for an older outer
|
|
543
|
+
// transition. A queued request still belongs to the incarnation that the
|
|
544
|
+
// caller observed, not a deterministic same-id rebind that appears later.
|
|
545
|
+
const incarnation = workspaces.incarnationFor(botId);
|
|
546
|
+
return withBotTransition(botId, async () => {
|
|
547
|
+
const snapshot = await controller.status();
|
|
548
|
+
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
|
549
|
+
const error = new Error('找不到要修改的机器人。');
|
|
550
|
+
error.code = 'workspace-bot-not-found';
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
const state = await stateFor(botId);
|
|
554
|
+
await workspaces.setWorkspace(botId, workspace, {
|
|
555
|
+
clearSessions: () => state.clearSessions(),
|
|
556
|
+
incarnation,
|
|
557
|
+
});
|
|
558
|
+
return workspaces.decorateStatus(await controller.status());
|
|
559
|
+
});
|
|
560
|
+
};
|
|
561
|
+
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
|
|
562
|
+
// Fence the old runtime without changing the durable mapping. A crash
|
|
563
|
+
// before the controller removes its config therefore keeps the bot's
|
|
564
|
+
// workspace, while a crash after that commit is healed by startup
|
|
565
|
+
// reconciliation.
|
|
566
|
+
const removal = await workspaces.beginRemoval(botId, {
|
|
567
|
+
clearSessions: async () => {
|
|
568
|
+
try {
|
|
569
|
+
const state = await stateFor(botId);
|
|
570
|
+
if (!state || typeof state.clearSessions !== 'function') {
|
|
571
|
+
throw new TypeError('bot state does not support session cleanup');
|
|
572
|
+
}
|
|
573
|
+
await state.clearSessions();
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.warn(
|
|
576
|
+
`[dsh-im] ignored session cleanup failure while deleting bot ${botId}:`,
|
|
577
|
+
error?.message ?? error,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
try {
|
|
583
|
+
const result = await invokeDelete();
|
|
584
|
+
await workspaces.finishRemoval(removal);
|
|
585
|
+
return workspaces.decorateStatus(result);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const after = await targetStatus(controller).catch(() => null);
|
|
588
|
+
const knownAbsent = Array.isArray(after?.bots)
|
|
589
|
+
&& !after.bots.some((bot) => bot?.botId === botId);
|
|
590
|
+
if (knownAbsent) await workspaces.finishRemoval(removal);
|
|
591
|
+
else await workspaces.abortRemoval(removal);
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return new Proxy(controller, {
|
|
597
|
+
get(target, property) {
|
|
598
|
+
if (property === 'updateWorkspace') return updateWorkspace;
|
|
599
|
+
const value = Reflect.get(target, property, target);
|
|
600
|
+
if (typeof value !== 'function') return value;
|
|
601
|
+
if (property === 'deleteBot') {
|
|
602
|
+
return (botId, ...args) => deleteWithWorkspace(
|
|
603
|
+
botId,
|
|
604
|
+
() => value.call(target, botId, ...args),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
if (property === 'disconnect') {
|
|
608
|
+
return async (...args) => {
|
|
609
|
+
const before = await target.status();
|
|
610
|
+
const botId = before?.bots?.[0]?.botId;
|
|
611
|
+
if (!botId) return decorateResult(workspaces, value.apply(target, args));
|
|
612
|
+
return deleteWithWorkspace(botId, () => value.apply(target, args));
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return (...args) => decorateResult(workspaces, value.apply(target, args));
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { runWorkspaceCommand } from './workspace-command.mjs';
|
|
2
|
+
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
3
|
+
|
|
1
4
|
function cleanText(value) {
|
|
2
5
|
return typeof value === 'string' ? value.trim() : '';
|
|
3
6
|
}
|
|
@@ -97,6 +100,8 @@ export class TextHarnessBridge {
|
|
|
97
100
|
'',
|
|
98
101
|
'直接发送文字即可继续当前会话。',
|
|
99
102
|
'/new 开启一个全新会话',
|
|
103
|
+
'/workspace 工作区绝对路径 切换工作区',
|
|
104
|
+
'/workspacelist 列出工作区绝对路径',
|
|
100
105
|
'/status 检查连接状态',
|
|
101
106
|
'/help 显示本帮助',
|
|
102
107
|
].join('\n'));
|
|
@@ -109,6 +114,14 @@ export class TextHarnessBridge {
|
|
|
109
114
|
await this.#state.markSeen(messageId);
|
|
110
115
|
return;
|
|
111
116
|
}
|
|
117
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
118
|
+
if (workspaceCommand) {
|
|
119
|
+
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
120
|
+
await this.#bot.sendText(target, reply);
|
|
121
|
+
}
|
|
122
|
+
await this.#state.markSeen(messageId);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
112
125
|
const conversationKey = `${message.kind}:${message.conversationId}`;
|
|
113
126
|
if (command === '/new') {
|
|
114
127
|
await this.#state.clearSession(conversationKey);
|
|
@@ -117,12 +130,6 @@ export class TextHarnessBridge {
|
|
|
117
130
|
return;
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
let sessionId = this.#state.sessionFor(conversationKey);
|
|
121
|
-
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
|
122
|
-
sessionId = await this.#harness.createSession();
|
|
123
|
-
await this.#state.setSession(conversationKey, sessionId);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
133
|
await this.#bot.sendTyping?.(target).catch((error) => {
|
|
127
134
|
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
|
128
135
|
});
|
|
@@ -138,13 +145,19 @@ export class TextHarnessBridge {
|
|
|
138
145
|
);
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
|
-
const answer = await
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
const { answer } = await askInWorkspaceSession({
|
|
149
|
+
harness: this.#harness,
|
|
150
|
+
state: this.#state,
|
|
151
|
+
key: conversationKey,
|
|
152
|
+
text,
|
|
153
|
+
askOptions: {
|
|
154
|
+
timeoutMs: this.#replyTimeoutMs,
|
|
155
|
+
onUpdate: stream ? async (update) => {
|
|
156
|
+
const progress = update.type === 'text' ? update.text
|
|
157
|
+
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
|
|
158
|
+
if (progress) await stream.update(progress);
|
|
159
|
+
} : undefined,
|
|
160
|
+
},
|
|
148
161
|
});
|
|
149
162
|
if (stream) {
|
|
150
163
|
try {
|