@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2
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/CHANGELOG.md +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +440 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- package/src/relay/state.mjs +2279 -2011
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a chat message into an action (v0.7.0).
|
|
3
|
+
*
|
|
4
|
+
* ## What this replaces
|
|
5
|
+
*
|
|
6
|
+
* `dsh-chatops` 0.1.0 kept its eleven commands in a twelve-arm `switch` inside a 1,060-line
|
|
7
|
+
* class that also held the session catalogue, the approval broker, the file delivery and
|
|
8
|
+
* the vendor card probes. Three consequences, all of which this file is shaped to avoid:
|
|
9
|
+
*
|
|
10
|
+
* * **authorisation was a property of the caller, not the command.** Every command was
|
|
11
|
+
* reachable by every trusted window, so `/sessions`, `/use`, `/policy 2` and `/approve`
|
|
12
|
+
* sat behind one undifferentiated check. Here each command declares the capability it
|
|
13
|
+
* needs, and the router refuses by name when the caller lacks it — which is also what
|
|
14
|
+
* makes "who can do what" answerable without reading the implementations.
|
|
15
|
+
* * **the surface was undiscoverable.** `/help` was a hand-maintained string that could
|
|
16
|
+
* drift from the `switch`. Here the table *is* the help, so a command that exists is
|
|
17
|
+
* documented and a command that is documented exists.
|
|
18
|
+
* * **nothing was testable in isolation.** A command handler now receives a plain object
|
|
19
|
+
* of injected actions and returns a plain result. No host, no clock, no network.
|
|
20
|
+
*
|
|
21
|
+
* ## The fleet commands
|
|
22
|
+
*
|
|
23
|
+
* A chat bridge for this project has a reason to exist beyond the plugin it was ported
|
|
24
|
+
* from: `w2m` already runs commands on every machine. So the router exposes the fleet —
|
|
25
|
+
* `machines` to see it, `run` to use it, `where` to ask which machine a task ran on — and
|
|
26
|
+
* those are the commands that make a phone a usable console rather than a notification
|
|
27
|
+
* feed. They are also the reason `run` needs `fleet.dispatch`: dispatching is the one
|
|
28
|
+
* action here with effects on other machines, and it is gated separately from everything
|
|
29
|
+
* that merely reads.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Capabilities a command may require. A caller holds a set of these. */
|
|
33
|
+
export const CAPABILITY = Object.freeze({
|
|
34
|
+
/** Nothing beyond being an admitted identity. */
|
|
35
|
+
VIEW: 'view',
|
|
36
|
+
/** Ask a session to do work, or bind a window to one. */
|
|
37
|
+
OPERATE: 'operate',
|
|
38
|
+
/** Read a session's transcript, which can contain anything the model has seen. */
|
|
39
|
+
TRANSCRIPT: 'transcript',
|
|
40
|
+
/** Move a file out of the project. */
|
|
41
|
+
FILES: 'files',
|
|
42
|
+
/** Answer an approval, including one for a privileged operation. */
|
|
43
|
+
APPROVE: 'approve',
|
|
44
|
+
/** Change a session's sandbox policy. */
|
|
45
|
+
POLICY: 'policy',
|
|
46
|
+
/** Dispatch a command to the fleet. */
|
|
47
|
+
FLEET_DISPATCH: 'fleet.dispatch',
|
|
48
|
+
/** Change who is trusted. The one capability that can widen all the others. */
|
|
49
|
+
ADMIN: 'admin',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Which capabilities a freshly admitted owner holds. */
|
|
53
|
+
export const OWNER_CAPABILITIES = Object.freeze([
|
|
54
|
+
CAPABILITY.VIEW,
|
|
55
|
+
CAPABILITY.OPERATE,
|
|
56
|
+
CAPABILITY.TRANSCRIPT,
|
|
57
|
+
CAPABILITY.FILES,
|
|
58
|
+
CAPABILITY.APPROVE,
|
|
59
|
+
CAPABILITY.POLICY,
|
|
60
|
+
CAPABILITY.FLEET_DISPATCH,
|
|
61
|
+
CAPABILITY.ADMIN,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Why a command was not run. Returned as a code, so a test can assert on it. */
|
|
65
|
+
export const ROUTE_REFUSAL = Object.freeze({
|
|
66
|
+
/** Not a command — the caller wants to send it as a prompt. */
|
|
67
|
+
NOT_A_COMMAND: 'not_a_command',
|
|
68
|
+
/** A `/name` that no command claims. */
|
|
69
|
+
UNKNOWN_COMMAND: 'unknown_command',
|
|
70
|
+
/** The command needs a capability the caller does not hold. */
|
|
71
|
+
FORBIDDEN: 'forbidden',
|
|
72
|
+
/** The arguments do not fit the command. */
|
|
73
|
+
BAD_ARGUMENTS: 'bad_arguments',
|
|
74
|
+
/** The command exists but this install has not wired its action. */
|
|
75
|
+
UNAVAILABLE: 'unavailable',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The command table.
|
|
80
|
+
*
|
|
81
|
+
* Each entry: `name`, `usage`, `summary`, `capability`, and `run(args, ctx)`. Keeping the
|
|
82
|
+
* help text in the entry is what stops it drifting from the implementation.
|
|
83
|
+
*/
|
|
84
|
+
export function commandTable() {
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
name: 'help',
|
|
88
|
+
usage: '/help [command]',
|
|
89
|
+
summary: 'List the commands, or explain one.',
|
|
90
|
+
capability: CAPABILITY.VIEW,
|
|
91
|
+
run(args, ctx) {
|
|
92
|
+
if (args.length === 0) {
|
|
93
|
+
const lines = ctx.commands.map((c) => `${c.usage} — ${c.summary}`);
|
|
94
|
+
return { text: [`可用指令:`, ...lines.map((l) => ` ${l}`)].join('\n') };
|
|
95
|
+
}
|
|
96
|
+
const wanted = args[0].replace(/^\//, '');
|
|
97
|
+
const command = ctx.commands.find((c) => c.name === wanted);
|
|
98
|
+
if (!command) return { text: `没有名为 ${wanted} 的指令。` };
|
|
99
|
+
return { text: `${command.usage}\n${command.summary}\n所需权限:${command.capability}` };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'whoami',
|
|
104
|
+
usage: '/whoami',
|
|
105
|
+
summary: 'Show the identity this window is bound to and what it may do.',
|
|
106
|
+
capability: CAPABILITY.VIEW,
|
|
107
|
+
run(_args, ctx) {
|
|
108
|
+
return {
|
|
109
|
+
text: [
|
|
110
|
+
`身份:${ctx.identity.channel}:${ctx.identity.id}`,
|
|
111
|
+
`窗口:${ctx.identity.window}`,
|
|
112
|
+
`权限:${[...ctx.capabilities].sort().join(', ') || '(无)'}`,
|
|
113
|
+
].join('\n'),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'sessions',
|
|
119
|
+
usage: '/sessions',
|
|
120
|
+
summary: 'List the sessions this window could act on.',
|
|
121
|
+
capability: CAPABILITY.VIEW,
|
|
122
|
+
async run(_args, ctx) {
|
|
123
|
+
const sessions = await ctx.actions.listSessions();
|
|
124
|
+
if (sessions.length === 0) return { text: '没有任何会话。' };
|
|
125
|
+
const bound = await ctx.actions.boundSession(ctx.identity.window);
|
|
126
|
+
const lines = sessions.map((s, i) => {
|
|
127
|
+
const marker = s.id === bound ? '*' : ' ';
|
|
128
|
+
return `${marker}${i + 1}. ${s.title || s.id} (${s.id.slice(0, 8)})`;
|
|
129
|
+
});
|
|
130
|
+
return { text: ['会话(* 为当前绑定):', ...lines].join('\n') };
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'use',
|
|
135
|
+
usage: '/use <序号|会话 id 前缀>',
|
|
136
|
+
summary: 'Bind this window to a session.',
|
|
137
|
+
capability: CAPABILITY.OPERATE,
|
|
138
|
+
async run(args, ctx) {
|
|
139
|
+
if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/use <序号|会话 id 前缀>' };
|
|
140
|
+
const sessions = await ctx.actions.listSessions();
|
|
141
|
+
const key = args[0];
|
|
142
|
+
let target = null;
|
|
143
|
+
if (/^\d+$/.test(key)) {
|
|
144
|
+
target = sessions[Number(key) - 1] ?? null;
|
|
145
|
+
} else {
|
|
146
|
+
// Prefix matching is deliberately strict about ambiguity: binding the wrong
|
|
147
|
+
// session because two share a prefix is a silent misroute, and the caller cannot
|
|
148
|
+
// tell it happened.
|
|
149
|
+
const matches = sessions.filter((s) => s.id.startsWith(key));
|
|
150
|
+
if (matches.length > 1) {
|
|
151
|
+
return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `${key} 匹配到 ${matches.length} 个会话,请写更长的前缀。` };
|
|
152
|
+
}
|
|
153
|
+
target = matches[0] ?? null;
|
|
154
|
+
}
|
|
155
|
+
if (!target) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `找不到会话 ${key}。` };
|
|
156
|
+
await ctx.actions.bind(ctx.identity.window, target.id);
|
|
157
|
+
return { text: `已绑定到:${target.title || target.id}` };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'log',
|
|
162
|
+
usage: '/log [行数]',
|
|
163
|
+
summary: 'Show the tail of the bound session.',
|
|
164
|
+
capability: CAPABILITY.TRANSCRIPT,
|
|
165
|
+
async run(args, ctx) {
|
|
166
|
+
const limit = args.length === 0 ? 20 : Number(args[0]);
|
|
167
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
|
|
168
|
+
return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '行数需为 1..200。' };
|
|
169
|
+
}
|
|
170
|
+
const sessionId = await ctx.actions.boundSession(ctx.identity.window);
|
|
171
|
+
if (!sessionId) return { text: '这个窗口还没绑定会话,先 /use。' };
|
|
172
|
+
const lines = await ctx.actions.tail(sessionId, limit);
|
|
173
|
+
return { text: lines.length === 0 ? '(没有输出)' : lines.join('\n') };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'machines',
|
|
178
|
+
usage: '/machines',
|
|
179
|
+
summary: 'List the machines the fleet currently holds.',
|
|
180
|
+
capability: CAPABILITY.VIEW,
|
|
181
|
+
async run(_args, ctx) {
|
|
182
|
+
if (!ctx.actions.listMachines) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
|
|
183
|
+
const machines = await ctx.actions.listMachines();
|
|
184
|
+
if (machines.length === 0) return { text: '编队里没有在线机器。' };
|
|
185
|
+
const lines = machines.map((m) => `${m.online ? '●' : '○'} ${m.name} ${m.os ?? '?'}/${m.arch ?? '?'} ${m.id.slice(0, 8)}`);
|
|
186
|
+
return { text: ['编队:', ...lines].join('\n') };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'run',
|
|
191
|
+
usage: '/run <命令…>',
|
|
192
|
+
summary: 'Run a command on the whole fleet and report the verdict.',
|
|
193
|
+
capability: CAPABILITY.FLEET_DISPATCH,
|
|
194
|
+
async run(args, ctx) {
|
|
195
|
+
if (args.length === 0) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/run <命令…>' };
|
|
196
|
+
if (!ctx.actions.dispatch) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
|
|
197
|
+
const outcome = await ctx.actions.dispatch(args);
|
|
198
|
+
return { text: outcome.summary, taskId: outcome.taskId };
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'where',
|
|
203
|
+
usage: '/where <任务 id>',
|
|
204
|
+
summary: 'Show how a task is doing, machine by machine.',
|
|
205
|
+
capability: CAPABILITY.VIEW,
|
|
206
|
+
async run(args, ctx) {
|
|
207
|
+
if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/where <任务 id>' };
|
|
208
|
+
if (!ctx.actions.where) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
|
|
209
|
+
const outcome = await ctx.actions.where(args[0]);
|
|
210
|
+
return { text: outcome.text };
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'report',
|
|
215
|
+
usage: '/report <任务 id>',
|
|
216
|
+
summary: 'Show a task\'s full per-machine report.',
|
|
217
|
+
capability: CAPABILITY.VIEW,
|
|
218
|
+
async run(args, ctx) {
|
|
219
|
+
if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/report <任务 id>' };
|
|
220
|
+
if (!ctx.actions.fullReport) return { refusal: ROUTE_REFUSAL.UNAVAILABLE, text: '这台机器没有连接编队。' };
|
|
221
|
+
const outcome = await ctx.actions.fullReport(args[0]);
|
|
222
|
+
return { text: outcome.text };
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'approve',
|
|
227
|
+
usage: '/approve [审批 id]',
|
|
228
|
+
summary: 'Approve the oldest pending request for this window.',
|
|
229
|
+
capability: CAPABILITY.APPROVE,
|
|
230
|
+
async run(args, ctx) {
|
|
231
|
+
return decideApproval(args, ctx, 'allowed-once');
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'reject',
|
|
236
|
+
usage: '/reject [审批 id]',
|
|
237
|
+
summary: 'Reject the oldest pending request for this window.',
|
|
238
|
+
capability: CAPABILITY.APPROVE,
|
|
239
|
+
async run(args, ctx) {
|
|
240
|
+
return decideApproval(args, ctx, 'rejected');
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'pending',
|
|
245
|
+
usage: '/pending',
|
|
246
|
+
summary: 'Show what this window is being asked to approve.',
|
|
247
|
+
capability: CAPABILITY.APPROVE,
|
|
248
|
+
run(_args, ctx) {
|
|
249
|
+
const waiting = ctx.actions.pending(ctx.identity.window);
|
|
250
|
+
if (waiting.length === 0) return { text: '没有待审批的请求。' };
|
|
251
|
+
const lines = waiting.map(
|
|
252
|
+
(p) => `${p.approval_id.slice(0, 8)} ${p.tool} ${Math.ceil(p.remaining_ms / 1000)}s 后超时${p.reason ? ` (${p.reason})` : ''}`,
|
|
253
|
+
);
|
|
254
|
+
return { text: ['待审批:', ...lines].join('\n') };
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: 'policy',
|
|
259
|
+
usage: '/policy <0|1|2>',
|
|
260
|
+
summary: 'Set the bound session\'s sandbox policy. 0 read-only, 1 ask, 2 danger-full-access.',
|
|
261
|
+
capability: CAPABILITY.POLICY,
|
|
262
|
+
async run(args, ctx) {
|
|
263
|
+
if (args.length !== 1) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/policy <0|1|2>' };
|
|
264
|
+
// Numeric index, never a string: a policy name taken from chat text would let the
|
|
265
|
+
// caller choose an arbitrary value if the enum ever grows a member the model should
|
|
266
|
+
// not be able to select.
|
|
267
|
+
const mode = ctx.policyModes[Number(args[0])];
|
|
268
|
+
if (!mode) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '策略需为 0、1 或 2。' };
|
|
269
|
+
const sessionId = await ctx.actions.boundSession(ctx.identity.window);
|
|
270
|
+
if (!sessionId) return { text: '这个窗口还没绑定会话,先 /use。' };
|
|
271
|
+
await ctx.actions.setPolicy(sessionId, mode);
|
|
272
|
+
return { text: `已把 ${sessionId.slice(0, 8)} 的策略设为 ${mode}。` };
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'trust',
|
|
277
|
+
usage: '/trust <channel:id>',
|
|
278
|
+
summary: 'Trust an identity. Widens access, so it needs admin.',
|
|
279
|
+
capability: CAPABILITY.ADMIN,
|
|
280
|
+
async run(args, ctx) {
|
|
281
|
+
if (args.length !== 1 || !args[0].includes(':')) {
|
|
282
|
+
return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/trust <channel:id>,例如 feishu:ou_abc' };
|
|
283
|
+
}
|
|
284
|
+
const cut = args[0].indexOf(':');
|
|
285
|
+
const channel = args[0].slice(0, cut);
|
|
286
|
+
const id = args[0].slice(cut + 1);
|
|
287
|
+
const result = ctx.actions.trust(channel, id);
|
|
288
|
+
return result.ok ? { text: `已信任 ${channel}:${id}。` } : { text: `未变更:${result.reason}` };
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'untrust',
|
|
293
|
+
usage: '/untrust <channel:id>',
|
|
294
|
+
summary: 'Revoke an identity. Needs admin.',
|
|
295
|
+
capability: CAPABILITY.ADMIN,
|
|
296
|
+
async run(args, ctx) {
|
|
297
|
+
if (args.length !== 1 || !args[0].includes(':')) {
|
|
298
|
+
return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '用法:/untrust <channel:id>' };
|
|
299
|
+
}
|
|
300
|
+
const cut = args[0].indexOf(':');
|
|
301
|
+
const channel = args[0].slice(0, cut);
|
|
302
|
+
const id = args[0].slice(cut + 1);
|
|
303
|
+
const result = ctx.actions.distrust(channel, id);
|
|
304
|
+
return result.ok ? { text: `已撤销 ${channel}:${id}。` } : { text: `未变更:${result.reason}` };
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Shared by `/approve` and `/reject`, so the two cannot drift apart. */
|
|
311
|
+
async function decideApproval(args, ctx, outcome) {
|
|
312
|
+
const waiting = ctx.actions.pending(ctx.identity.window);
|
|
313
|
+
if (waiting.length === 0) return { text: '没有待审批的请求。' };
|
|
314
|
+
// An explicit id is honoured only if it belongs to *this* window: an id from another
|
|
315
|
+
// conversation would otherwise let a trusted identity answer a request it was never
|
|
316
|
+
// shown, which is the attribution problem in miniature.
|
|
317
|
+
let target;
|
|
318
|
+
if (args.length === 1) {
|
|
319
|
+
target = waiting.find((p) => p.approval_id.startsWith(args[0]));
|
|
320
|
+
if (!target) return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: '这个窗口没有匹配的待审批请求。' };
|
|
321
|
+
} else {
|
|
322
|
+
target = waiting[0];
|
|
323
|
+
}
|
|
324
|
+
const result = ctx.actions.decideApproval(target.approval_id, outcome);
|
|
325
|
+
if (!result.ok) {
|
|
326
|
+
// Reported as a failure rather than a cheerful confirmation. 0.1.0 replied
|
|
327
|
+
// "✅ 已批准,继续执行" on the strength of a promise resolving, with nothing tying it to
|
|
328
|
+
// the request it was supposedly authorising.
|
|
329
|
+
return { refusal: ROUTE_REFUSAL.BAD_ARGUMENTS, text: `未生效:${result.reason}` };
|
|
330
|
+
}
|
|
331
|
+
return { text: outcome === 'allowed-once' ? '已批准。' : '已拒绝。' };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Parse one inbound message.
|
|
336
|
+
*
|
|
337
|
+
* @param {string} text
|
|
338
|
+
* @returns {{kind: 'command', name: string, args: string[]} | {kind: 'prompt', text: string}}
|
|
339
|
+
* A leading `/` that names nothing is **not** a prompt: sending `/sesions` to the
|
|
340
|
+
* model because it was misspelled is how a typo becomes unexpected model work.
|
|
341
|
+
*/
|
|
342
|
+
export function parseCommand(text) {
|
|
343
|
+
if (typeof text !== 'string') return { kind: 'prompt', text: '' };
|
|
344
|
+
const trimmed = text.trim();
|
|
345
|
+
if (!trimmed.startsWith('/')) return { kind: 'prompt', text: trimmed };
|
|
346
|
+
const [head, ...args] = trimmed.split(/\s+/);
|
|
347
|
+
return { kind: 'command', name: head.slice(1).toLowerCase(), args };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Route one message.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} text
|
|
354
|
+
* @param {object} ctx
|
|
355
|
+
* @param {Set<string>} ctx.capabilities
|
|
356
|
+
* @param {{channel: string, id: string, window: string}} ctx.identity
|
|
357
|
+
* @param {object} ctx.actions
|
|
358
|
+
* @param {Array} [ctx.commands] Defaults to the full table.
|
|
359
|
+
* @returns {Promise<{kind: string, name?: string, text?: string, taskId?: string, refusal?: string}>}
|
|
360
|
+
*/
|
|
361
|
+
export async function route(text, ctx) {
|
|
362
|
+
const parsed = parseCommand(text);
|
|
363
|
+
if (parsed.kind === 'prompt') return { kind: 'prompt', text: parsed.text };
|
|
364
|
+
|
|
365
|
+
const commands = ctx.commands ?? commandTable();
|
|
366
|
+
const command = commands.find((c) => c.name === parsed.name);
|
|
367
|
+
if (!command) {
|
|
368
|
+
return {
|
|
369
|
+
kind: 'refused',
|
|
370
|
+
name: parsed.name,
|
|
371
|
+
refusal: ROUTE_REFUSAL.UNKNOWN_COMMAND,
|
|
372
|
+
text: `未知指令 /${parsed.name}。发送 /help 查看可用指令。`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// Authorisation is checked before the handler runs, never inside it, so a handler
|
|
376
|
+
// cannot accidentally become reachable by forgetting its own check.
|
|
377
|
+
if (!ctx.capabilities?.has(command.capability)) {
|
|
378
|
+
return {
|
|
379
|
+
kind: 'refused',
|
|
380
|
+
name: command.name,
|
|
381
|
+
refusal: ROUTE_REFUSAL.FORBIDDEN,
|
|
382
|
+
text: `/${command.name} 需要 ${command.capability} 权限,当前身份没有。`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const full = { ...ctx, commands };
|
|
387
|
+
let result;
|
|
388
|
+
try {
|
|
389
|
+
result = await command.run(parsed.args, full);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
// A handler that throws is a defect, and it is reported as one rather than as a refusal
|
|
392
|
+
// the operator might mistake for a permission decision.
|
|
393
|
+
return {
|
|
394
|
+
kind: 'error',
|
|
395
|
+
name: command.name,
|
|
396
|
+
text: `/${command.name} 执行失败:${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (result?.refusal) {
|
|
401
|
+
return { kind: 'refused', name: command.name, refusal: result.refusal, text: result.text };
|
|
402
|
+
}
|
|
403
|
+
return { kind: 'ok', name: command.name, text: result?.text ?? '', taskId: result?.taskId };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The help text, generated from the table so it cannot drift. */
|
|
407
|
+
export function helpText(commands = commandTable()) {
|
|
408
|
+
return commands.map((c) => `${c.usage.padEnd(28)} ${c.summary}`).join('\n');
|
|
409
|
+
}
|