@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1030 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The WeChat (ClawBot / iLink) channel adapter (v0.7.1).
3
+ *
4
+ * ## What changed from v0.7.0, and why this file is trustworthy now
5
+ *
6
+ * v0.7.0's adapter was written by *reading* the audited plugin's TypeScript, and every field
7
+ * name in it was wrong — nine mismatches, none of which its 237 passing tests could see,
8
+ * because those tests asserted on the requests we built instead of the requests WeChat accepts.
9
+ *
10
+ * This version was written against live exchanges: a real QR scan, a real bot, real messages in
11
+ * both directions. `ilink-protocol.mjs` holds the measured constants and
12
+ * `test/chatops-ilink-contract.test.mjs` pins them with fixtures taken verbatim from real
13
+ * responses, so a drift fails a test rather than a scan.
14
+ *
15
+ * Three defects are worth naming, because each is the kind that survives review:
16
+ *
17
+ * 1. **`AuthorizationType: ilink_bot_token`.** Mandatory, undocumented, and absent from v0.7.0.
18
+ * Without it every authenticated call answers `-14 session timeout` — *even with no
19
+ * `Authorization` header at all*, because the server rejects on the missing type before it
20
+ * reads the credential. It reads exactly like an expiry bug and is not one.
21
+ * 2. **Business failures arrive as HTTP 200.** `{"errcode":-14,...}` is a failure on a 200
22
+ * response. v0.7.0 checked the status code only, so a refusal was reported as a delivery.
23
+ * 3. **The transport did not exist.** v0.7.0 required the host to inject one and shipped none,
24
+ * so no request could ever be sent. `ilink-transport.mjs` is now the default.
25
+ *
26
+ * ## What is deliberately unchanged
27
+ *
28
+ * The channel decisions v0.7.0 got right: a long-poll timeout is an idle beat rather than a
29
+ * failure, de-duplication is bounded, a non-text message is skipped rather than turned into an
30
+ * empty prompt, `DEGRADED` is distinct from `STOPPED`, and the adapter decides nothing about who
31
+ * may speak — it builds the window key and `policy.mjs` decides admission.
32
+ */
33
+
34
+ import { CHANNEL_STATE, CHANNEL_FEATURE } from './channel.mjs';
35
+ // Re-exported so a caller that knew the v0.7.0 surface can still import the protocol constants
36
+ // from here; the definitions live in ilink-protocol.mjs, which is what the contract suite pins.
37
+ export * from './ilink-protocol.mjs';
38
+ import { createTransport } from './ilink-transport.mjs';
39
+ import {
40
+ QR_BASE_URL,
41
+ ENDPOINTS,
42
+ BOT_TYPE,
43
+ LOGIN_STATUS,
44
+ MESSAGE,
45
+ baseInfo,
46
+ isAllowedApiUrl,
47
+ extractText,
48
+ messageId,
49
+ } from './ilink-protocol.mjs';
50
+
51
+ /** Long poll: the platform holds the request open this long, so the deadline must exceed it. */
52
+ const LONG_POLL_MS = 35_000;
53
+ const LONG_POLL_DEADLINE_MS = LONG_POLL_MS + 10_000;
54
+ const DEFAULT_SEEN_LIMIT = 200;
55
+
56
+ /**
57
+ * Create the adapter.
58
+ *
59
+ * @param {object} [options]
60
+ * @param {(request: object) => Promise<object>} [options.transport] Defaults to a real HTTP transport.
61
+ * @param {typeof fetch} [options.fetch] Only used when `transport` is absent.
62
+ * @param {string} [options.token]
63
+ * @param {string} [options.apiBase]
64
+ * @param {boolean} [options.drive] Start without running the poll loop, so a caller drives
65
+ * `pollOnce()` itself. Without it a detached loop races a test for the same response.
66
+ * @param {number} [options.idleDelayMs]
67
+ * @param {number} [options.seenLimit]
68
+ * @param {(event: string, detail?: object) => void} [options.audit]
69
+ */
70
+ export function createILinkChannel(options = {}) {
71
+ const audit = options.audit ?? (() => {});
72
+ const idleDelayMs = options.idleDelayMs ?? 400;
73
+ const seenLimit = options.seenLimit ?? DEFAULT_SEEN_LIMIT;
74
+ const drive = options.drive === true;
75
+ const agent = options.agent ?? 'DeepSeekHarness/w2m';
76
+
77
+ const request =
78
+ options.transport ??
79
+ // `fetch` is passed only when it is really there. Passing `{ fetch: undefined }` makes the
80
+ // property exist with an undefined value, which a presence check reads as "provided" — and
81
+ // the transport then refuses to build instead of falling back to the platform's own fetch.
82
+ // Found by a live smoke test: `apply()` threw on mount while every unit test passed, because
83
+ // the tests all inject a transport and so never exercise this default.
84
+ (typeof options.fetch === 'function'
85
+ ? createTransport({ fetch: options.fetch, audit })
86
+ : createTransport({ audit }));
87
+
88
+ let token = options.token ?? null;
89
+ let apiBase = options.apiBase ?? QR_BASE_URL;
90
+ let state = CHANNEL_STATE.IDLE;
91
+ let handlers = null;
92
+ let running = false;
93
+ let controller = null;
94
+ let loop = null;
95
+ /** Cursor for `getupdates`. Opaque to us; echoed back exactly. */
96
+ let cursor = '';
97
+ const seen = new Set();
98
+ const seenOrder = [];
99
+
100
+ function remember(id) {
101
+ if (id === null || seen.has(id)) return false;
102
+ seen.add(id);
103
+ seenOrder.push(id);
104
+ while (seenOrder.length > seenLimit) seen.delete(seenOrder.shift());
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * The base url an authenticated call may use.
110
+ *
111
+ * Checked on every call rather than once at login: the base comes from a *response*, and a
112
+ * response is the thing an allow-list exists to distrust.
113
+ */
114
+ function checkedBase() {
115
+ const verdict = isAllowedApiUrl(apiBase);
116
+ if (!verdict.ok) throw new Error(`refusing to send the bot token: ${verdict.reason}`);
117
+ return apiBase;
118
+ }
119
+
120
+ const url = (endpoint) => new URL(endpoint, QR_BASE_URL).toString();
121
+
122
+ /** One request, with the outcome returned as data. Never throws on a business refusal. */
123
+ async function call(endpoint, body, { base = null, authenticated = true, timeoutMs = 20_000, signal } = {}) {
124
+ const target = base ?? (authenticated ? checkedBase() : QR_BASE_URL);
125
+ if (authenticated && (token === null || token === '')) {
126
+ return { ok: false, http: 0, body: {}, ms: 0, errcode: null, errmsg: 'no bot token: the channel is not logged in' };
127
+ }
128
+ return request({
129
+ url: new URL(endpoint, target).toString(),
130
+ body: { ...body, base_info: baseInfo(agent) },
131
+ token: authenticated ? token : null,
132
+ timeoutMs,
133
+ signal,
134
+ });
135
+ }
136
+
137
+ /** Turn a failed call into a thrown Error, for the paths where silence would be a lie. */
138
+ function must(result, what) {
139
+ if (!result.ok) {
140
+ const reason = result.errmsg ?? 'unknown';
141
+ const code = result.errcode === null || result.errcode === undefined ? '' : ` (errcode ${result.errcode})`;
142
+ throw new Error(`${what} failed: ${reason}${code}`);
143
+ }
144
+ return result.body;
145
+ }
146
+
147
+ /** Deliver one inbound message, and map it onto the window key the registry declares. */
148
+ function deliver(inbound) {
149
+ if (typeof handlers !== 'function') return;
150
+ // Not awaited: a slow handler must not stall the poll, or the platform's queue backs up
151
+ // behind one conversation.
152
+ void Promise.resolve(handlers(inbound)).catch((error) => audit('ilink/handler-failed', { detail: String(error) }));
153
+ }
154
+
155
+ /** Map a `getupdates` body onto inbound messages, de-duplicated. Returned, so a test can assert it. */
156
+ function dispatch(body) {
157
+ const messages = Array.isArray(body?.msgs) ? body.msgs : [];
158
+ const delivered = [];
159
+ // The cursor advances even when nothing is delivered, and is stored verbatim.
160
+ if (typeof body?.get_updates_buf === 'string') cursor = body.get_updates_buf;
161
+
162
+ for (const message of messages) {
163
+ const id = messageId(message);
164
+ if (id !== null && !remember(id)) {
165
+ audit('ilink/duplicate', { id });
166
+ continue;
167
+ }
168
+ const text = extractText(message);
169
+ if (text === null) {
170
+ // A non-text message is skipped rather than turned into an empty prompt, which would be
171
+ // model work with no content.
172
+ audit('ilink/skipped-non-text', { id });
173
+ continue;
174
+ }
175
+ const from = message?.from_user_id;
176
+ if (typeof from !== 'string' || from === '') {
177
+ audit('ilink/skipped-no-sender', { id });
178
+ continue;
179
+ }
180
+ const inbound = { windowKey: `user:${from}`, talkerId: from, text, addressed: true, messageId: id };
181
+ delivered.push(inbound);
182
+ deliver(inbound);
183
+ }
184
+ return delivered;
185
+ }
186
+
187
+ const channel = {
188
+ name: 'ilink',
189
+ prefixes: ['user', 'contact'],
190
+ capabilities: {
191
+ [CHANNEL_FEATURE.CHUNKING]: true,
192
+ [CHANNEL_FEATURE.FILES]: true,
193
+ [CHANNEL_FEATURE.CARDS]: false,
194
+ [CHANNEL_FEATURE.EDIT]: false,
195
+ [CHANNEL_FEATURE.ROOMS]: false,
196
+ },
197
+
198
+ get state() {
199
+ return state;
200
+ },
201
+
202
+ /** The cursor, for a caller that persists it across restarts. */
203
+ get cursor() {
204
+ return cursor;
205
+ },
206
+
207
+ get seenCount() {
208
+ return seen.size;
209
+ },
210
+
211
+ async start(inbound) {
212
+ if (typeof inbound !== 'function') throw new TypeError('start() needs an inbound handler');
213
+ handlers = inbound;
214
+ if (token === null || token === '') {
215
+ // A normal state, not an error: an install that has not scanned the QR yet is
216
+ // `unconfigured`, which is a different answer from "offline" or "broken".
217
+ state = CHANNEL_STATE.UNCONFIGURED;
218
+ audit('ilink/unconfigured', {});
219
+ return;
220
+ }
221
+ // The controller must exist before `running` lets the loop read its signal.
222
+ controller = new AbortController();
223
+ running = true;
224
+ state = CHANNEL_STATE.READY;
225
+ audit('ilink/started', { host: new URL(apiBase).hostname });
226
+ // Best effort, and a failure does not stop the poll — measured: a live bot answers
227
+ // `{"ret":0}` to it, and an unbound one may not.
228
+ void channel.notifyStart().catch((error) => audit('ilink/notifystart-failed', { detail: String(error) }));
229
+ loop = drive ? null : pollLoop();
230
+ },
231
+
232
+ async stop() {
233
+ running = false;
234
+ state = CHANNEL_STATE.STOPPED;
235
+ controller?.abort();
236
+ try {
237
+ // Best effort: the bot goes offline politely if it can.
238
+ if (token) await channel.notifyStop();
239
+ } catch (error) {
240
+ audit('ilink/notifystop-failed', { detail: String(error) });
241
+ }
242
+ // Awaited, not merely signalled: a caller that stops and then tears down the process must
243
+ // not race a loop that is still mid-request.
244
+ try {
245
+ await loop;
246
+ } catch {
247
+ /* the loop reports its own failures; stopping is not the place to raise them */
248
+ }
249
+ loop = null;
250
+ controller = null;
251
+ handlers = null;
252
+ audit('ilink/stopped', {});
253
+ },
254
+
255
+ setState(next) {
256
+ state = next;
257
+ },
258
+
259
+ /** Build the outbound envelope, exactly as measured. */
260
+ buildMessage({ toUserId, text, contextToken }) {
261
+ return {
262
+ msg: {
263
+ from_user_id: '',
264
+ to_user_id: toUserId,
265
+ client_id: `w2m-${Date.now()}-${Math.floor(Math.random() * 1e6)}`,
266
+ message_type: MESSAGE.TYPE_OUTBOUND,
267
+ message_state: MESSAGE.STATE,
268
+ item_list: [{ type: MESSAGE.ITEM_TEXT, text_item: { text } }],
269
+ ...(typeof contextToken === 'string' && contextToken !== '' ? { context_token: contextToken } : {}),
270
+ },
271
+ };
272
+ },
273
+
274
+ /**
275
+ * Send one chunk.
276
+ *
277
+ * Rejects on a business failure, because the queue above counts what resolved — a
278
+ * `{"errcode":...}` on HTTP 200 that returned normally would be counted as delivered, which
279
+ * is the exact bug v0.7.0 shipped.
280
+ */
281
+ async send({ window, text, contextToken = null }) {
282
+ const cut = String(window).indexOf(':');
283
+ const toUserId = cut === -1 ? String(window) : String(window).slice(cut + 1);
284
+ const result = await call(ENDPOINTS.SEND, channel.buildMessage({ toUserId, text, contextToken }));
285
+ if (!result.ok) {
286
+ throw new Error(`sendmessage refused: ${result.errmsg ?? 'unknown'}${result.errcode != null ? ` (errcode ${result.errcode})` : ''}`);
287
+ }
288
+ audit('ilink/sent', { to: toUserId, bytes: Buffer.byteLength(text, 'utf8'), messageId: result.body?.message_id ?? null });
289
+ return result.body;
290
+ },
291
+
292
+ /** One poll. Returned so a test can assert the mapping without a handler. */
293
+ async pollOnce({ signal } = {}) {
294
+ const result = await call(
295
+ ENDPOINTS.UPDATES,
296
+ { get_updates_buf: cursor },
297
+ { timeoutMs: LONG_POLL_DEADLINE_MS, signal },
298
+ );
299
+ if (result.timedOut) {
300
+ // The platform holding the request open is how it tells us nothing happened. Treating it
301
+ // as a fault would raise an alarm every idle cycle.
302
+ audit('ilink/poll-idle', {});
303
+ return [];
304
+ }
305
+ if (!result.ok) throw new Error(`getupdates refused: ${result.errmsg ?? 'unknown'}`);
306
+ return dispatch(result.body);
307
+ },
308
+
309
+ async notifyStart({ signal } = {}) {
310
+ return must(await call(ENDPOINTS.NOTIFY_START, {}, { timeoutMs: 20_000, signal }), 'notifystart');
311
+ },
312
+
313
+ async notifyStop({ signal } = {}) {
314
+ return must(await call(ENDPOINTS.NOTIFY_STOP, {}, { timeoutMs: 5_000, signal }), 'notifystop');
315
+ },
316
+
317
+ /** Step 1 of login. Unauthenticated by design. */
318
+ async requestQrcode({ botType = BOT_TYPE } = {}) {
319
+ const body = must(
320
+ await call(`${ENDPOINTS.QRCODE}?bot_type=${encodeURIComponent(botType)}`, {}, { authenticated: false, timeoutMs: 20_000 }),
321
+ 'get_bot_qrcode',
322
+ );
323
+ const qrcode = typeof body.qrcode === 'string' && body.qrcode !== '' ? body.qrcode : null;
324
+ if (qrcode === null) throw new Error('get_bot_qrcode returned no qrcode token');
325
+ return {
326
+ qrcode,
327
+ // A JS-rendered landing page, not an image, despite the field's name. Handing this to an
328
+ // <img> is why v0.7.0's probe saved 1,362 bytes of HTML as `wechat-login-qr.unknown`.
329
+ qrcodeUrl: typeof body.qrcode_img_content === 'string' ? body.qrcode_img_content : null,
330
+ };
331
+ },
332
+
333
+ /** Step 2 of login. Unauthenticated; returns a status, not a token, until confirmed. */
334
+ async pollLogin({ qrcode, verifyCode = null } = {}) {
335
+ let endpoint = `${ENDPOINTS.QRCODE_STATUS}?qrcode=${encodeURIComponent(qrcode)}`;
336
+ if (typeof verifyCode === 'string' && verifyCode !== '') {
337
+ endpoint += `&verify_code=${encodeURIComponent(verifyCode)}`;
338
+ }
339
+ const result = await call(endpoint, {}, { authenticated: false, timeoutMs: 35_000 });
340
+ if (!result.ok) throw new Error(`get_qrcode_status refused: ${result.errmsg ?? 'unknown'}`);
341
+ const body = result.body;
342
+ const status = typeof body.status === 'string' ? body.status : null;
343
+ const login = {
344
+ status,
345
+ botToken: typeof body.bot_token === 'string' && body.bot_token !== '' ? body.bot_token : null,
346
+ // Measured names. v0.7.0 read `bot_id` and `owner_user_id`, both of which are undefined
347
+ // in a real response, so the login "succeeded" with empty identity fields.
348
+ botId: typeof body.ilink_bot_id === 'string' ? body.ilink_bot_id : null,
349
+ ownerUserId: typeof body.ilink_user_id === 'string' ? body.ilink_user_id : null,
350
+ baseUrl: typeof body.baseurl === 'string' && body.baseurl !== '' ? body.baseurl : null,
351
+ };
352
+ return login;
353
+ },
354
+
355
+ /**
356
+ * Adopt a confirmed login.
357
+ *
358
+ * The base url comes from the response and is therefore checked: a redirect status carries a
359
+ * new one, and accepting it unchecked is how a bot token ends up at a host of someone else's
360
+ * choosing.
361
+ */
362
+ adoptLogin({ botToken, baseUrl }) {
363
+ if (typeof botToken !== 'string' || botToken === '') throw new Error('adoptLogin needs a bot token');
364
+ if (typeof baseUrl === 'string' && baseUrl !== '') {
365
+ const verdict = isAllowedApiUrl(baseUrl);
366
+ if (!verdict.ok) throw new Error(`refusing the login response's base url: ${verdict.reason}`);
367
+ apiBase = baseUrl;
368
+ }
369
+ token = botToken;
370
+ state = CHANNEL_STATE.READY;
371
+ audit('ilink/login-adopted', { host: new URL(apiBase).hostname });
372
+ return { apiBase };
373
+ },
374
+
375
+ /** Seed the cursor from persisted state, so a restart does not replay. */
376
+ setCursor(next) {
377
+ cursor = typeof next === 'string' ? next : '';
378
+ },
379
+ };
380
+
381
+ async function pollLoop() {
382
+ let failures = 0;
383
+ while (running) {
384
+ try {
385
+ await channel.pollOnce({ signal: controller?.signal });
386
+ failures = 0;
387
+ if (state === CHANNEL_STATE.DEGRADED) state = CHANNEL_STATE.READY;
388
+ // A yield on the success path: a platform that answers immediately would otherwise be
389
+ // polled in a tight loop that starves the event loop. Measured — without this the suite
390
+ // took 131 seconds instead of 0.3.
391
+ if (running) await interruptibleSleep(idleDelayMs);
392
+ } catch (error) {
393
+ failures += 1;
394
+ state = CHANNEL_STATE.DEGRADED;
395
+ audit('ilink/poll-failed', { attempt: failures, detail: error instanceof Error ? error.message : String(error) });
396
+ if (!running) break;
397
+ await interruptibleSleep(Math.min(idleDelayMs * 2 ** Math.min(failures, 5), 30_000));
398
+ }
399
+ }
400
+ }
401
+
402
+ /** Sleep that `stop()` can cut short, so a stopped channel cannot hold the process open. */
403
+ function interruptibleSleep(ms) {
404
+ return new Promise((resolve) => {
405
+ const signal = controller?.signal;
406
+ if (!running || signal?.aborted) {
407
+ resolve();
408
+ return;
409
+ }
410
+ const timer = setTimeout(done, ms);
411
+ const onAbort = () => done();
412
+ function done() {
413
+ clearTimeout(timer);
414
+ signal?.removeEventListener('abort', onAbort);
415
+ resolve();
416
+ }
417
+ signal?.addEventListener('abort', onAbort, { once: true });
418
+ if (typeof timer.unref === 'function') timer.unref();
419
+ });
420
+ }
421
+
422
+ return channel;
423
+ }