@twinsearth/w2m-dsh-plugin 0.6.2 → 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.
- package/CHANGELOG.md +1057 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -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 +33 -22
- package/src/chatops/account.mjs +361 -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-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -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 +429 -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 +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
|
+
}
|