@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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pipeline: one inbound message in, one action out (v0.7.0).
|
|
3
|
+
*
|
|
4
|
+
* ## What this replaces
|
|
5
|
+
*
|
|
6
|
+
* `dsh-chatops` 0.1.0's `SessionBridge` was 1,060 lines carrying ten to eleven
|
|
7
|
+
* responsibilities — the session catalogue, title resolution through three fallbacks,
|
|
8
|
+
* command parsing, eleven command implementations, prompt delivery, agent-event mirroring,
|
|
9
|
+
* the approval answerer, vendor card probing, file delivery, direct reads of the host's
|
|
10
|
+
* on-disk state, and an import of a vendor's view module. Its longest function was 87 lines
|
|
11
|
+
* of session-catalog work living inside a chat bridge, and it held ten mutable maps with no
|
|
12
|
+
* disposer owning any of them.
|
|
13
|
+
*
|
|
14
|
+
* Here the responsibilities are already elsewhere, and this file is the order they run in:
|
|
15
|
+
*
|
|
16
|
+
* admit (policy) -> route (commands) -> deliver (outbound queue)
|
|
17
|
+
* |
|
|
18
|
+
* +-> dispatch (the fleet, injected)
|
|
19
|
+
* +-> approvals (the broker)
|
|
20
|
+
* +-> files (the reader and the fan-out)
|
|
21
|
+
*
|
|
22
|
+
* The whole of it is one function per direction, and the interesting content is the refusal
|
|
23
|
+
* handling rather than the happy path.
|
|
24
|
+
*
|
|
25
|
+
* ## Two rules this file exists to enforce
|
|
26
|
+
*
|
|
27
|
+
* **A refusal is a reply, not silence.** Every dropped message produces something the
|
|
28
|
+
* operator can see — but only for identities that were admitted at least once, because
|
|
29
|
+
* replying to a stranger confirms the bot exists and spends the rate limit on them. The
|
|
30
|
+
* distinction is deliberate and the tests assert both halves.
|
|
31
|
+
*
|
|
32
|
+
* **Nothing is trusted because it arrived** is `policy.mjs`'s rule; this file's rule is that
|
|
33
|
+
* a failure anywhere in the pipeline still produces a reply. A bridge that goes quiet on an
|
|
34
|
+
* exception is indistinguishable from a bridge that is not running.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createPolicy, DENIAL } from './policy.mjs';
|
|
38
|
+
import { route, commandTable, OWNER_CAPABILITIES } from './commands.mjs';
|
|
39
|
+
import { createOutboundQueue } from './outbound-queue.mjs';
|
|
40
|
+
import { createApprovalBroker } from './approval-broker.mjs';
|
|
41
|
+
import { createFileReader, createFileSender, fanOutFile } from './file-safety.mjs';
|
|
42
|
+
import { validateChannel, CHANNEL_FEATURE, CHANNEL_STATE } from './channel.mjs';
|
|
43
|
+
|
|
44
|
+
/** Prefixes the registry declares, derived so the two cannot disagree. */
|
|
45
|
+
import { CHANNELS as CHANNEL_REGISTRY } from './policy.mjs';
|
|
46
|
+
|
|
47
|
+
function declaredPrefixes() {
|
|
48
|
+
const all = [];
|
|
49
|
+
for (const spec of Object.values(CHANNEL_REGISTRY)) {
|
|
50
|
+
all.push(...spec.prefixes, ...(spec.roomPrefixes ?? []));
|
|
51
|
+
}
|
|
52
|
+
all.push('filehelper');
|
|
53
|
+
return all;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the bridge.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {object[]} options.channels Channel implementations. Each is validated at construction.
|
|
61
|
+
* @param {object} options.actions The injected action set `commands.mjs` calls.
|
|
62
|
+
* @param {object} [options.policy] Overrides for `createPolicy`.
|
|
63
|
+
* @param {number} [options.chunkBytes]
|
|
64
|
+
* @param {number} [options.throttleMs]
|
|
65
|
+
* @param {string} [options.projectRoot] Root the file fence is anchored to.
|
|
66
|
+
* @param {number} [options.maxFileBytes]
|
|
67
|
+
* @param {() => number} [options.now]
|
|
68
|
+
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
69
|
+
* @param {object} [options.broker] Overrides for `createApprovalBroker`.
|
|
70
|
+
*/
|
|
71
|
+
export function createBridge(options) {
|
|
72
|
+
const audit = options.audit ?? (() => {});
|
|
73
|
+
const channels = options.channels ?? [];
|
|
74
|
+
if (channels.length === 0) throw new TypeError('createBridge needs at least one channel');
|
|
75
|
+
|
|
76
|
+
// A channel that does not satisfy the contract is a startup error, not a runtime surprise
|
|
77
|
+
// discovered the first time someone tries to send a file.
|
|
78
|
+
const known = declaredPrefixes();
|
|
79
|
+
for (const channel of channels) {
|
|
80
|
+
const verdict = validateChannel(channel, known);
|
|
81
|
+
if (!verdict.ok) throw new TypeError(`invalid channel: ${verdict.reason}`);
|
|
82
|
+
}
|
|
83
|
+
// Two maps, not one. `owners` records who claimed a prefix so a duplicate is a startup
|
|
84
|
+
// error; `byPrefix` is the prefix -> channel-name lookup routing uses. Folding them into a
|
|
85
|
+
// single map looks tidy and is wrong: the duplicate check writes `channel.name` while the
|
|
86
|
+
// routing lookup needs the channel *itself*, so whichever read happens second gets the
|
|
87
|
+
// other one's value and every reply silently resolves to no channel.
|
|
88
|
+
const owners = new Map();
|
|
89
|
+
const byPrefix = new Map();
|
|
90
|
+
for (const channel of channels) {
|
|
91
|
+
for (const prefix of channel.prefixes) {
|
|
92
|
+
if (owners.has(prefix)) {
|
|
93
|
+
throw new TypeError(`prefix ${prefix} is claimed by both ${owners.get(prefix)} and ${channel.name}`);
|
|
94
|
+
}
|
|
95
|
+
owners.set(prefix, channel.name);
|
|
96
|
+
byPrefix.set(prefix, channel);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Accept either a ready policy or the overrides to build one.
|
|
102
|
+
*
|
|
103
|
+
* The chat action set needs the *same* policy instance the bridge admits with, or `/trust`
|
|
104
|
+
* writes to a copy that admission never reads and the command silently does nothing. Duck-typed
|
|
105
|
+
* on `admit` rather than `instanceof`, because the two modules must not have to import each
|
|
106
|
+
* other's constructors to agree on this.
|
|
107
|
+
*/
|
|
108
|
+
const policy =
|
|
109
|
+
options.policy && typeof options.policy.admit === 'function'
|
|
110
|
+
? options.policy
|
|
111
|
+
: createPolicy({ audit, ...options.policy });
|
|
112
|
+
const commands = commandTable();
|
|
113
|
+
const broker = createApprovalBroker({
|
|
114
|
+
now: options.now,
|
|
115
|
+
audit,
|
|
116
|
+
...options.broker,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const queues = new Map();
|
|
120
|
+
function queueFor(channel) {
|
|
121
|
+
let queue = queues.get(channel.name);
|
|
122
|
+
if (!queue) {
|
|
123
|
+
queue = createOutboundQueue({
|
|
124
|
+
// Two parameters, not one destructured object: the queue hands over a chunk plus
|
|
125
|
+
// its metadata, and the window is in the metadata. Reading `{window, text}` off a
|
|
126
|
+
// single argument yields `undefined` for both, which fails silently — every reply
|
|
127
|
+
// is "sent" and the channel receives nothing.
|
|
128
|
+
send: async (chunk, meta) => channel.send({ window: meta.window, text: chunk }),
|
|
129
|
+
chunkBytes: options.chunkBytes,
|
|
130
|
+
throttleMs: options.throttleMs,
|
|
131
|
+
now: options.now,
|
|
132
|
+
audit,
|
|
133
|
+
});
|
|
134
|
+
queues.set(channel.name, queue);
|
|
135
|
+
}
|
|
136
|
+
return queue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reader =
|
|
140
|
+
options.projectRoot !== undefined
|
|
141
|
+
? createFileReader({
|
|
142
|
+
projectRoot: options.projectRoot,
|
|
143
|
+
maxBytes: options.maxFileBytes,
|
|
144
|
+
audit,
|
|
145
|
+
// Injectable so a caller can test the routing and the counting without a real
|
|
146
|
+
// filesystem — the fence's own symlink behaviour is proven in its own suite, and a
|
|
147
|
+
// bridge test that needs POSIX links on a Windows host cannot run there at all.
|
|
148
|
+
...options.readerIo,
|
|
149
|
+
})
|
|
150
|
+
: null;
|
|
151
|
+
|
|
152
|
+
const started = [];
|
|
153
|
+
|
|
154
|
+
/** Which channel owns a window key. Resolved from the registry, never from a hard-coded prefix. */
|
|
155
|
+
function channelFor(windowKey) {
|
|
156
|
+
const key = String(windowKey ?? '');
|
|
157
|
+
const prefix = key.split(':')[0];
|
|
158
|
+
return byPrefix.get(prefix) ?? byPrefix.get(key) ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Reply on whichever channel the message came from. Best effort, and never throws. */
|
|
162
|
+
async function reply(windowKey, text, lane = 'interactive') {
|
|
163
|
+
const channel = channelFor(windowKey) ?? auditChannel();
|
|
164
|
+
if (!channel) {
|
|
165
|
+
audit('reply/no-channel', { window: windowKey });
|
|
166
|
+
return { sent: 0, failed: 0 };
|
|
167
|
+
}
|
|
168
|
+
return queueFor(channel).enqueue({ window: windowKey, text, lane });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where a reply goes when the window names no channel at all.
|
|
173
|
+
*
|
|
174
|
+
* A malformed or unregistered window is an operator's configuration being wrong, and
|
|
175
|
+
* answering it is the difference between "my bot is broken" and "my bot told me what is
|
|
176
|
+
* wrong". It is **not** routed by guessing a channel from the key — that would let a
|
|
177
|
+
* message choose its own reply path — so it goes to the first channel that started, with
|
|
178
|
+
* the reason named in the text.
|
|
179
|
+
*/
|
|
180
|
+
function auditChannel() {
|
|
181
|
+
return started[0] ?? channels[0] ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle one inbound message.
|
|
186
|
+
*
|
|
187
|
+
* @param {{windowKey: string, talkerId?: string, text: string, addressed?: boolean}} message
|
|
188
|
+
* @returns {Promise<{action: string, [k: string]: unknown}>}
|
|
189
|
+
*/
|
|
190
|
+
async function handleInbound(message) {
|
|
191
|
+
const windowKey = message?.windowKey;
|
|
192
|
+
// Admission first, and before anything else — including before deciding whether to
|
|
193
|
+
// reply, because whether we reply depends on what admission concluded.
|
|
194
|
+
const admitted = policy.admit(message);
|
|
195
|
+
|
|
196
|
+
if (!admitted.allow) {
|
|
197
|
+
// A stranger gets silence: replying confirms the bot exists and spends the
|
|
198
|
+
// anti-flood budget on someone who will never be admitted. An identity that has been
|
|
199
|
+
// admitted before gets the reason, because for them a silent drop is a bug report.
|
|
200
|
+
if (admitted.reason === DENIAL.NOT_ADOPTED || admitted.reason === DENIAL.SENDER_UNTRUSTED) {
|
|
201
|
+
audit('inbound/ignored', { window: windowKey, reason: admitted.reason });
|
|
202
|
+
return { action: 'ignored', reason: admitted.reason };
|
|
203
|
+
}
|
|
204
|
+
const text = `这条消息没有被处理:${admitted.reason}`;
|
|
205
|
+
await reply(windowKey, text);
|
|
206
|
+
return { action: 'refused', reason: admitted.reason };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const caps = new Set(OWNER_CAPABILITIES);
|
|
210
|
+
let routed;
|
|
211
|
+
try {
|
|
212
|
+
routed = await route(message.text, {
|
|
213
|
+
capabilities: caps,
|
|
214
|
+
identity: { channel: admitted.channel, id: admitted.id, window: windowKey },
|
|
215
|
+
policyModes: options.policyModes ?? ['read-only', 'ask', 'danger-full-access'],
|
|
216
|
+
actions: actionsFor(windowKey),
|
|
217
|
+
commands,
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
// Unreachable through `route`, which catches its own handler failures. Kept because a
|
|
221
|
+
// bridge that goes quiet on an exception looks exactly like a bridge that is not
|
|
222
|
+
// running, and that is the failure this whole file is written against.
|
|
223
|
+
audit('inbound/error', { window: windowKey, detail: String(error) });
|
|
224
|
+
await reply(windowKey, `处理失败:${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
return { action: 'error' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (routed.kind === 'prompt') {
|
|
229
|
+
if (typeof options.actions.prompt !== 'function') {
|
|
230
|
+
await reply(windowKey, '这台机器没有接入会话,无法发送 prompt。');
|
|
231
|
+
return { action: 'unavailable' };
|
|
232
|
+
}
|
|
233
|
+
// The window's bound session is resolved by the injected action, not here: binding is
|
|
234
|
+
// session-catalogue work and does not belong in the transport layer.
|
|
235
|
+
const delivered = await options.actions.prompt(windowKey, routed.text);
|
|
236
|
+
await reply(windowKey, delivered?.ack ?? '已收到。');
|
|
237
|
+
return { action: 'prompt' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await reply(windowKey, routed.text);
|
|
241
|
+
return { action: routed.kind, name: routed.name, refusal: routed.refusal, taskId: routed.taskId };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The action set the command table sees, with the window bound in. */
|
|
245
|
+
function actionsFor(windowKey) {
|
|
246
|
+
const injected = options.actions ?? {};
|
|
247
|
+
return {
|
|
248
|
+
...injected,
|
|
249
|
+
pending: (window) => broker.listFor(window),
|
|
250
|
+
decideApproval: (id, outcome) => broker.decide(id, outcome),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Offer an approval to the bridge, as the host's answerer would.
|
|
256
|
+
*
|
|
257
|
+
* The four-line wiring this module exists to make readable. `next` is called exactly when
|
|
258
|
+
* the bridge declines or its deadline passes, and **never** when it answers — so the
|
|
259
|
+
* host's own answerers are reached in every case where this bridge has nothing to say.
|
|
260
|
+
*/
|
|
261
|
+
async function offerApproval({ request, windowKey, timeoutMs, next }) {
|
|
262
|
+
const offered = broker.offer({ request, windowKey, timeoutMs, delegate: next });
|
|
263
|
+
if (!offered.claim) return { claimed: false, reason: offered.reason };
|
|
264
|
+
return { claimed: true, id: offered.id, outcome: await offered.promise };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Send one project file to every window bound to a session. */
|
|
268
|
+
async function sendFile({ relPath, windows, caption = '' }) {
|
|
269
|
+
if (!reader) return { ok: false, reason: 'no_project_root' };
|
|
270
|
+
const file = await reader.read(relPath);
|
|
271
|
+
if (!file.ok) return { ok: false, reason: file.reason, detail: file.detail };
|
|
272
|
+
|
|
273
|
+
const outcome = await fanOutFile({
|
|
274
|
+
file,
|
|
275
|
+
caption,
|
|
276
|
+
targets: windows.map((w) => {
|
|
277
|
+
const channel = channelFor(w);
|
|
278
|
+
if (!channel || !channel.capabilities?.[CHANNEL_FEATURE.FILES]) {
|
|
279
|
+
return { window: w, deliver: async () => ({ ok: false, reason: 'channel_cannot_send_files' }) };
|
|
280
|
+
}
|
|
281
|
+
const sender = createFileSender({ send: async (f, c) => channel.send({ window: w, text: c, file: f }), audit });
|
|
282
|
+
return { window: w, deliver: sender.deliver };
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
// The count the caller reports must come from here, not from `windows.length`.
|
|
286
|
+
return { ok: outcome.allSent, ...outcome, name: file.name, bytes: file.bytes };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function start() {
|
|
290
|
+
for (const channel of channels) {
|
|
291
|
+
try {
|
|
292
|
+
await channel.start((message) => handleInbound(message));
|
|
293
|
+
started.push(channel);
|
|
294
|
+
audit('channel/started', { channel: channel.name });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// One channel failing to start must not take the others down — 0.1.0 got this right
|
|
297
|
+
// and it is kept. What it got wrong was staying silent about it: the state is
|
|
298
|
+
// recorded, and `status()` reports it rather than leaving `online: false` to mean
|
|
299
|
+
// four different things.
|
|
300
|
+
channel.setState?.(CHANNEL_STATE.UNCONFIGURED);
|
|
301
|
+
audit('channel/start-failed', { channel: channel.name, detail: String(error) });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { started: started.length, total: channels.length };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function stop() {
|
|
308
|
+
for (const queue of queues.values()) await queue.dispose();
|
|
309
|
+
queues.clear();
|
|
310
|
+
for (const channel of [...started].reverse()) {
|
|
311
|
+
try {
|
|
312
|
+
await channel.stop();
|
|
313
|
+
} catch {
|
|
314
|
+
/* a channel that cannot stop cleanly is not worth failing a shutdown over */
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
broker.dispose();
|
|
318
|
+
started.length = 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** What is running, told apart rather than collapsed into a boolean. */
|
|
322
|
+
function status() {
|
|
323
|
+
return {
|
|
324
|
+
channels: channels.map((c) => ({ name: c.name, state: c.state ?? 'unknown' })),
|
|
325
|
+
pendingApprovals: broker.size,
|
|
326
|
+
owners: policy.ownerCount,
|
|
327
|
+
admitted: policy.hasOwners(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
start,
|
|
333
|
+
stop,
|
|
334
|
+
handleInbound,
|
|
335
|
+
offerApproval,
|
|
336
|
+
sendFile,
|
|
337
|
+
status,
|
|
338
|
+
// Exposed for the integration suite and for wiring the host's own surfaces.
|
|
339
|
+
policy,
|
|
340
|
+
broker,
|
|
341
|
+
commands,
|
|
342
|
+
channelFor,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract every IM channel implements (v0.7.0).
|
|
3
|
+
*
|
|
4
|
+
* ## What this replaces
|
|
5
|
+
*
|
|
6
|
+
* `dsh-chatops` 0.1.0 had three overlapping shapes — a `BridgeChannel`, a `ManagedChannel`,
|
|
7
|
+
* and the types in a file named after one vendor — and none of them was the real contract.
|
|
8
|
+
* The bridge was typed against one, handed another, and reached past its own type with
|
|
9
|
+
* three `as any` casts to call `channelFor`. Beyond the declared members it also *probed*
|
|
10
|
+
* for `statusSnapshot`, `sendFile`, `sendApprovalCard`, `sendProgressCard`,
|
|
11
|
+
* `completeProgressCard` and `onCardAction`, each on an `any`, so a channel missing one
|
|
12
|
+
* failed only at runtime and only once the feature was used.
|
|
13
|
+
*
|
|
14
|
+
* A channel whose prefix was missing from the authorisation regexes had every message from
|
|
15
|
+
* it silently dropped. Adding a fifth channel meant touching nine places, four of them
|
|
16
|
+
* duplicated prefix tables that had to agree.
|
|
17
|
+
*
|
|
18
|
+
* ## The shape here
|
|
19
|
+
*
|
|
20
|
+
* One interface, five members, and a **capabilities** descriptor instead of method
|
|
21
|
+
* sniffing: a channel that cannot send cards says so, and the caller asks rather than
|
|
22
|
+
* probing. The registry in `policy.mjs` is the single source of channel identity, so a
|
|
23
|
+
* channel cannot exist in one table and be missing from another.
|
|
24
|
+
*
|
|
25
|
+
* A channel never decides who may speak. That is `policy.mjs`, and keeping it out of the
|
|
26
|
+
* channel is what stops a vendor from quietly becoming part of the authorisation path —
|
|
27
|
+
* which is exactly what a hard-coded `fsu:` prefix inside the approval broker was.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What a channel can do, asked instead of guessed. */
|
|
31
|
+
export const CHANNEL_FEATURE = Object.freeze({
|
|
32
|
+
/** Messages longer than the platform budget must be split. */
|
|
33
|
+
CHUNKING: 'chunking',
|
|
34
|
+
/** Can carry a file or image. */
|
|
35
|
+
FILES: 'files',
|
|
36
|
+
/** Can render an interactive approval card. */
|
|
37
|
+
CARDS: 'cards',
|
|
38
|
+
/** Can edit or complete a message already sent. */
|
|
39
|
+
EDIT: 'edit',
|
|
40
|
+
/** Group conversations reach this channel. */
|
|
41
|
+
ROOMS: 'rooms',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Channel lifecycle states, reported rather than inferred from a boolean. */
|
|
45
|
+
export const CHANNEL_STATE = Object.freeze({
|
|
46
|
+
/** Not started, or deliberately off. */
|
|
47
|
+
IDLE: 'idle',
|
|
48
|
+
/** Credentials missing or refused; the channel will not take work. */
|
|
49
|
+
UNCONFIGURED: 'unconfigured',
|
|
50
|
+
/** Trying. Not yet able to send. */
|
|
51
|
+
CONNECTING: 'connecting',
|
|
52
|
+
READY: 'ready',
|
|
53
|
+
/** Was ready, lost it, retrying. The state 0.1.0 collapsed into `online: false`. */
|
|
54
|
+
DEGRADED: 'degraded',
|
|
55
|
+
STOPPED: 'stopped',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate that an object satisfies the channel contract, and say what is missing.
|
|
60
|
+
*
|
|
61
|
+
* Called at construction rather than on first use. The failure this prevents is the one
|
|
62
|
+
* that plugin had: a channel that starts, reports itself registered, accepts routed
|
|
63
|
+
* messages, and throws only when a feature is first exercised.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} channel
|
|
66
|
+
* @param {string[]} [declaredPrefixes] The prefixes `policy.mjs` claims for this channel.
|
|
67
|
+
* @returns {{ok: true} | {ok: false, missing: string[], reason: string}}
|
|
68
|
+
*/
|
|
69
|
+
export function validateChannel(channel, declaredPrefixes) {
|
|
70
|
+
if (!channel || typeof channel !== 'object') {
|
|
71
|
+
return { ok: false, missing: [], reason: 'channel is not an object' };
|
|
72
|
+
}
|
|
73
|
+
const missing = [];
|
|
74
|
+
if (typeof channel.name !== 'string' || channel.name === '') missing.push('name');
|
|
75
|
+
if (typeof channel.start !== 'function') missing.push('start()');
|
|
76
|
+
if (typeof channel.stop !== 'function') missing.push('stop()');
|
|
77
|
+
if (typeof channel.send !== 'function') missing.push('send()');
|
|
78
|
+
if (!Array.isArray(channel.prefixes) || channel.prefixes.length === 0) missing.push('prefixes[]');
|
|
79
|
+
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
return { ok: false, missing, reason: `channel is missing: ${missing.join(', ')}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A channel whose prefixes the registry does not know would have every message from it
|
|
85
|
+
// refused as `channel_unknown` -- silently, from the operator's point of view. Naming it
|
|
86
|
+
// here turns a dead channel into a startup error.
|
|
87
|
+
if (Array.isArray(declaredPrefixes) && declaredPrefixes.length > 0) {
|
|
88
|
+
const unknown = channel.prefixes.filter((p) => !declaredPrefixes.includes(p));
|
|
89
|
+
if (unknown.length > 0) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
missing: [],
|
|
93
|
+
reason:
|
|
94
|
+
`channel ${channel.name} claims prefix(es) ${unknown.join(', ')} that the channel registry ` +
|
|
95
|
+
`does not declare, so every message from it would be refused as channel_unknown`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const features = channel.capabilities ?? {};
|
|
101
|
+
const badFeature = Object.values(CHANNEL_FEATURE).find((f) => features[f] !== undefined && typeof features[f] !== 'boolean');
|
|
102
|
+
if (badFeature) {
|
|
103
|
+
return { ok: false, missing: [], reason: `capability ${badFeature} must be a boolean, not ${typeof features[badFeature]}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A channel that exists in memory, for tests and for a dry run.
|
|
111
|
+
*
|
|
112
|
+
* It is a **first-class implementation**, not a mock: the same object is what a real
|
|
113
|
+
* adapter is checked against, and what the integration suite drives end to end. A test
|
|
114
|
+
* double that diverges from the interface is how an interface stops meaning anything, so
|
|
115
|
+
* this one is validated by the same `validateChannel` a real channel is.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [options]
|
|
118
|
+
* @param {string} [options.name]
|
|
119
|
+
* @param {string[]} [options.prefixes]
|
|
120
|
+
* @param {object} [options.capabilities]
|
|
121
|
+
* @param {(message: object) => void} [options.onOutbound] Called for each send; the test's
|
|
122
|
+
* window into what the bridge tried to say.
|
|
123
|
+
* @param {() => boolean} [options.failWhen] Return true to make the next send reject.
|
|
124
|
+
*/
|
|
125
|
+
export function createMemoryChannel(options = {}) {
|
|
126
|
+
const name = options.name ?? 'memory';
|
|
127
|
+
const prefixes = options.prefixes ?? ['mem'];
|
|
128
|
+
const capabilities = {
|
|
129
|
+
[CHANNEL_FEATURE.CHUNKING]: true,
|
|
130
|
+
[CHANNEL_FEATURE.FILES]: true,
|
|
131
|
+
[CHANNEL_FEATURE.CARDS]: false,
|
|
132
|
+
[CHANNEL_FEATURE.EDIT]: false,
|
|
133
|
+
[CHANNEL_FEATURE.ROOMS]: false,
|
|
134
|
+
...options.capabilities,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** window -> [{ text, at }]. */
|
|
138
|
+
const sent = [];
|
|
139
|
+
let state = CHANNEL_STATE.IDLE;
|
|
140
|
+
let handlers = null;
|
|
141
|
+
const log = [];
|
|
142
|
+
|
|
143
|
+
// Built by assignment rather than returned as an object literal: an object literal with
|
|
144
|
+
// `...spread` **evaluates** an accessor instead of copying it, so a `sent` getter written
|
|
145
|
+
// that way silently becomes a snapshot taken at construction.
|
|
146
|
+
const channel = {
|
|
147
|
+
name,
|
|
148
|
+
prefixes,
|
|
149
|
+
capabilities,
|
|
150
|
+
|
|
151
|
+
async start(inbound) {
|
|
152
|
+
if (typeof inbound !== 'function') throw new TypeError('start() needs an inbound handler');
|
|
153
|
+
handlers = inbound;
|
|
154
|
+
state = CHANNEL_STATE.READY;
|
|
155
|
+
log.push('start');
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async stop() {
|
|
159
|
+
state = CHANNEL_STATE.STOPPED;
|
|
160
|
+
handlers = null;
|
|
161
|
+
log.push('stop');
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async send({ window, text }) {
|
|
165
|
+
if (state !== CHANNEL_STATE.READY) {
|
|
166
|
+
throw new Error(`channel ${name} is ${state}, not ready`);
|
|
167
|
+
}
|
|
168
|
+
if (options.failWhen?.(text)) throw new Error('scripted send failure');
|
|
169
|
+
const record = { window, text, at: Date.now() };
|
|
170
|
+
sent.push(record);
|
|
171
|
+
options.onOutbound?.(record);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** Deliver a message as if it arrived from the platform. */
|
|
175
|
+
async receive(message) {
|
|
176
|
+
if (typeof handlers !== 'function') throw new Error(`channel ${name} was never started`);
|
|
177
|
+
return handlers(message);
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
setState(next) {
|
|
181
|
+
state = next;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
Object.defineProperty(channel, 'state', { get: () => state, enumerable: true });
|
|
186
|
+
// Read-only views for tests and for a dry run. Named so it is obvious they are not part of
|
|
187
|
+
// the contract a real channel implements.
|
|
188
|
+
Object.defineProperty(channel, 'sent', { get: () => sent, enumerable: false });
|
|
189
|
+
Object.defineProperty(channel, 'log', { get: () => log, enumerable: false });
|
|
190
|
+
|
|
191
|
+
return channel;
|
|
192
|
+
}
|