@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The bridge: a WeChat message becomes a fleet task, and the verdict goes back (v0.7.12).
3
+ *
4
+ * ## Every field name here was measured, not guessed
5
+ *
6
+ * Four separate wrong guesses about this protocol made a working feature look broken — including a
7
+ * false conclusion that the login token lived only 30 seconds. So the wire shapes are recorded with
8
+ * where they came from, and a test asserts them rather than the documentation:
9
+ *
10
+ * | shape | measured value |
11
+ * |---|---|
12
+ * | poll response | `{ msgs, sync_buf, get_updates_buf }` — the cursor is `get_updates_buf`, **not** `buf` |
13
+ * | message text | `item_list[0].text_item.text` — **not** `content` |
14
+ * | sender | `from_user_id` |
15
+ * | reply success | `{ message_id }` with **no** `ret` and **no** `errcode` |
16
+ * | login confirmation | status `confirmed`, **not** `scaned` |
17
+ *
18
+ * ## What this module does not do
19
+ *
20
+ * It does not decide authorisation. `parseInvocation` reports which **capability** a form needs, and
21
+ * the caller supplies an `authorize` function — because the rule "authorisation is checked before
22
+ * the handler runs, never inside it" is what stops a handler becoming reachable by forgetting its
23
+ * own guard, and a bridge that checked its own permission would be exactly that mistake.
24
+ */
25
+
26
+ import { parseInvocation, renderVerdict, renderDispatchFailure } from './run-command.mjs';
27
+
28
+ /** The header every authenticated call needs; undocumented and mandatory. */
29
+ export const AUTH_TYPE = 'ilink_bot_token';
30
+ export const APP_ID = 'bot';
31
+ /** 2.4.6, as the reference client sends. */
32
+ export const CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6);
33
+
34
+ /** Message item types. Only text is dispatched; the rest are acknowledged and ignored. */
35
+ export const ITEM_TYPE = Object.freeze({ TEXT: 1 });
36
+ /** `2` is BOT-originated, which is what our replies are. */
37
+ export const MESSAGE_TYPE = Object.freeze({ USER: 1, BOT: 2 });
38
+ /** `2` is FINISH — a complete message rather than a streaming fragment. */
39
+ export const MESSAGE_STATE_FINISH = 2;
40
+
41
+ /**
42
+ * Build the request headers.
43
+ *
44
+ * `X-WECHAT-UIN` is a fresh random uint32 as a decimal string, base64-encoded, on **every** request.
45
+ * It is not a session id and must not be cached.
46
+ *
47
+ * @param {object} input
48
+ * @param {string} [input.token]
49
+ * @param {() => string} [input.makeUin]
50
+ */
51
+ export function buildHeaders({ token, makeUin }) {
52
+ const headers = {
53
+ 'content-type': 'application/json',
54
+ 'iLink-App-Id': APP_ID,
55
+ 'iLink-App-ClientVersion': CLIENT_VERSION,
56
+ AuthorizationType: AUTH_TYPE,
57
+ };
58
+ if (typeof makeUin === 'function') headers['X-WECHAT-UIN'] = makeUin();
59
+ if (typeof token === 'string' && token !== '') headers.authorization = `Bearer ${token}`;
60
+ return headers;
61
+ }
62
+
63
+ /** The text of a message, or null when it carries none. */
64
+ export function textOf(message) {
65
+ const items = Array.isArray(message?.item_list) ? message.item_list : [];
66
+ for (const item of items) {
67
+ if (item?.type === ITEM_TYPE.TEXT) {
68
+ const text = item?.text_item?.text;
69
+ if (typeof text === 'string') return text;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * The reply envelope.
77
+ *
78
+ * `context_token` is **mandatory and comes from the inbound message**: without it the reply is not
79
+ * associated with the conversation. `message_state: 2` is FINISH — a first version omitted it and
80
+ * the endpoint answered `ret: -2, invalid arguments` for eight different payload shapes before the
81
+ * documented envelope was found.
82
+ */
83
+ export function buildReply({ toUserId, contextToken, text }) {
84
+ return {
85
+ msg: {
86
+ to_user_id: toUserId,
87
+ message_type: MESSAGE_TYPE.BOT,
88
+ message_state: MESSAGE_STATE_FINISH,
89
+ context_token: contextToken,
90
+ item_list: [{ type: ITEM_TYPE.TEXT, text_item: { text } }],
91
+ },
92
+ };
93
+ }
94
+
95
+ /** Whether a send succeeded. Success carries a `message_id` and **no** `ret`. */
96
+ export function sendSucceeded(response) {
97
+ if (response === null || typeof response !== 'object') return false;
98
+ if (typeof response.message_id !== 'undefined' && response.message_id !== null) return true;
99
+ return response.ret === 0 || response.errcode === 0;
100
+ }
101
+
102
+ /**
103
+ * Decide what to do with one inbound message.
104
+ *
105
+ * Returns a **plan** rather than performing it, so the decision is testable without a relay, a
106
+ * socket, or a clock — and so the caller can see authorisation checked in one place.
107
+ *
108
+ * @param {object} input
109
+ * @param {object} input.message One `msgs[]` entry.
110
+ * @param {(capability: string) => boolean} input.authorize
111
+ * @returns {{act: 'ignore', why: string}
112
+ * | {act: 'reply', to: string, contextToken: string, text: string}
113
+ * | {act: 'dispatch', to: string, contextToken: string, argv: string[], flags: object, ack: string}}
114
+ */
115
+ export function planMessage({ message, authorize }) {
116
+ const from = message?.from_user_id;
117
+ const contextToken = message?.context_token;
118
+ const text = textOf(message);
119
+
120
+ if (typeof from !== 'string' || from === '') return { act: 'ignore', why: 'no sender' };
121
+ if (typeof contextToken !== 'string' || contextToken === '') return { act: 'ignore', why: 'no context_token' };
122
+ if (text === null) return { act: 'ignore', why: 'not a text message' };
123
+
124
+ const trimmed = text.trim();
125
+ // Only the exact `/run` prefix is an action. Everything else is conversation, which is what keeps
126
+ // a typo from becoming a fleet execution.
127
+ if (!/^\/run(\s|$)/i.test(trimmed)) return { act: 'ignore', why: 'not a /run line' };
128
+
129
+ const body = trimmed.replace(/^\/run\s*/i, '');
130
+ const invocation = parseInvocation(body);
131
+
132
+ if (invocation.kind === 'refused') {
133
+ return { act: 'reply', to: from, contextToken, text: invocation.text };
134
+ }
135
+
136
+ const capability = invocation.capability;
137
+ if (typeof authorize === 'function' && authorize(capability) !== true) {
138
+ return {
139
+ act: 'reply',
140
+ to: from,
141
+ contextToken,
142
+ text: `这个窗口没有 ${capability} 权限,不能执行。`,
143
+ };
144
+ }
145
+
146
+ if (invocation.kind === 'ask') {
147
+ /**
148
+ * The beginner form is answered, not executed.
149
+ *
150
+ * It does **not** reach the fleet from here: the request is a sentence, and turning a sentence
151
+ * into a command inside the bridge would reintroduce exactly the inference `/run --ask` exists
152
+ * to avoid. The reply says what to send instead, so the low-privilege path stays a path to a
153
+ * *proposal* rather than to an execution.
154
+ */
155
+ return {
156
+ act: 'reply',
157
+ to: from,
158
+ contextToken,
159
+ text:
160
+ `收到你的请求:${invocation.text}\n\n`
161
+ + '这是低权限的「提问」模式,我不会直接执行。\n'
162
+ + '如果要真的运行,请把确切的命令发给我,例如:\n'
163
+ + ` /run ${invocation.text.split(/\s+/).slice(0, 3).join(' ')}`,
164
+ };
165
+ }
166
+
167
+ return {
168
+ act: 'dispatch',
169
+ to: from,
170
+ contextToken,
171
+ argv: invocation.argv,
172
+ flags: invocation.flags,
173
+ // Acknowledged before dispatch: a fleet run can take a minute, and silence reads as "broken".
174
+ ack: `已派发:${invocation.argv.join(' ')}\n模式:${invocation.flags.mode}\n等我回报结果…`,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Render a completed task for the chat window.
180
+ *
181
+ * Reuses `renderVerdict`, which already caps the machine list and states the truncation — a list
182
+ * that silently stops at ten reads as "ten machines" rather than "ten shown".
183
+ */
184
+ export function renderTaskOutcome({ taskId, body }) {
185
+ const verdict = body?.aggregate?.status ?? body?.status ?? body?.verdict ?? 'unknown';
186
+ const rows = body?.machines ?? body?.leases ?? body?.results ?? [];
187
+ return `${renderVerdict({ taskId, verdict, machines: rows }).text}`;
188
+ }
189
+
190
+ /** Render a dispatch that never became a task. */
191
+ export function renderFailure(error) {
192
+ return renderDispatchFailure({
193
+ code: error?.code ?? error?.error?.code,
194
+ reason: error?.detail?.reason ?? error?.error?.detail?.reason,
195
+ detail: error?.detail?.hint ?? error?.error?.detail?.hint ?? error?.message,
196
+ });
197
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The WeChat (ClawBot / iLink) wire protocol, as measured (v0.7.1).
3
+ *
4
+ * ## Why this file exists
5
+ *
6
+ * v0.7.0 shipped an `ilink.mjs` built from *reading* the audited plugin's source, and every
7
+ * field name in it was wrong. Nine separate mismatches, all of which passed 237 tests, because
8
+ * those tests drove an injected transport and asserted on the requests we *constructed*
9
+ * rather than the requests WeChat *accepts*. A self-consistent suite cannot falsify its own
10
+ * assumptions.
11
+ *
12
+ * Everything below came from a live exchange — a real QR scan, a real bot, a real message in
13
+ * both directions. The comments name the response each constant was read from, so a future
14
+ * change can be checked against something rather than against a memory.
15
+ *
16
+ * ## The two things that cost the most time
17
+ *
18
+ * **`AuthorizationType: ilink_bot_token` is mandatory and undocumented.** Without it every
19
+ * authenticated endpoint answers `{"errcode":-14,"errmsg":"session timeout"}` — *including a
20
+ * request with no `Authorization` header at all*, because the server rejects on the missing
21
+ * type header before it ever looks at the credential. That is why the error reads as a dead
22
+ * session and sends you looking for an expiry bug that does not exist.
23
+ *
24
+ * **Business failures arrive as HTTP 200.** A success answers `{"ret":0,…}`; a failure answers
25
+ * `{"errcode":-14,"errmsg":"session timeout"}` with the same 200. Checking the status code is
26
+ * therefore not enough, and a client that does has a silent-failure bug in the one place it
27
+ * matters most.
28
+ */
29
+
30
+ /** Where login happens. The only host reachable without a token. */
31
+ export const QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
32
+
33
+ /**
34
+ * Hosts an authenticated request may be sent to.
35
+ *
36
+ * Measured: the login response returns exactly `https://ilinkai.weixin.qq.com`. v0.7.0 also
37
+ * allowed `weixin.qq.com` and `api.weixin.qq.com` on a guess, which weakened the one guarantee
38
+ * that matters here — that the bot token only ever reaches WeChat's bot endpoint. An
39
+ * allow-list wider than the measurement is not caution, it is the absence of the check.
40
+ */
41
+ export const ALLOWED_API_HOSTS = Object.freeze(['ilinkai.weixin.qq.com']);
42
+
43
+ /** Sent on every request, authenticated or not. `iLink-App-Id: bot` is not the bot's id. */
44
+ export const APP_ID = 'bot';
45
+ /** `(2 << 16) | (4 << 8) | 6` — the reference client reports 2.4.6 as a packed integer. */
46
+ export const CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6); // '132102'
47
+ /** `channel_version` inside `base_info`. */
48
+ export const PROTOCOL_VERSION = '2.4.6';
49
+ /** `bot_type` for the QR request. */
50
+ export const BOT_TYPE = '3';
51
+ /** `AuthorizationType`. Omitting this is the defect described at the top of this file. */
52
+ export const AUTH_TYPE = 'ilink_bot_token';
53
+
54
+ export const ENDPOINTS = Object.freeze({
55
+ QRCODE: 'ilink/bot/get_bot_qrcode',
56
+ QRCODE_STATUS: 'ilink/bot/get_qrcode_status',
57
+ UPDATES: 'ilink/bot/getupdates',
58
+ SEND: 'ilink/bot/sendmessage',
59
+ NOTIFY_START: 'ilink/bot/msg/notifystart',
60
+ NOTIFY_STOP: 'ilink/bot/msg/notifystop',
61
+ });
62
+
63
+ /** `base_info`, required in every request body. Measured in every accepted call. */
64
+ export function baseInfo(agent = 'DeepSeekHarness/w2m') {
65
+ return { channel_version: PROTOCOL_VERSION, bot_agent: agent };
66
+ }
67
+
68
+ /** Login states, as strings. `scaned` is WeChat's spelling, not a typo here. */
69
+ export const LOGIN_STATUS = Object.freeze({
70
+ WAIT: 'wait',
71
+ SCANED: 'scaned',
72
+ CONFIRMED: 'confirmed',
73
+ EXPIRED: 'expired',
74
+ SCANED_BUT_REDIRECT: 'scaned_but_redirect',
75
+ NEED_VERIFYCODE: 'need_verifycode',
76
+ VERIFY_CODE_BLOCKED: 'verify_code_blocked',
77
+ BINDED_REDIRECT: 'binded_redirect',
78
+ });
79
+
80
+ /** Message types and states, as measured. Inbound is 1; our outbound replies are 2. */
81
+ export const MESSAGE = Object.freeze({
82
+ TYPE_INBOUND: 1,
83
+ TYPE_OUTBOUND: 2,
84
+ STATE: 2,
85
+ ITEM_TEXT: 1,
86
+ });
87
+
88
+ /**
89
+ * Is this response a success?
90
+ *
91
+ * Three shapes are accepted, because the server uses three and only one of them is documented
92
+ * by its own field names:
93
+ *
94
+ * * `{"ret":0, ...}` — the QR, status, notifystart and getupdates calls.
95
+ * * `{"message_id":...}` — `sendmessage`. **Measured**: a delivered reply came back with a
96
+ * `message_id` and *no* `ret` at all, so a strict `ret === 0` check reports a successful
97
+ * send as a failure. That was caught by this suite, which is the point of it.
98
+ * * `{"errcode":0}` — an explicit zero, which some WeChat endpoints use.
99
+ *
100
+ * A body with none of these is **not** a success: an unknown shape is an unknown outcome, and
101
+ * guessing in the permissive direction is how a refusal gets counted as a delivery.
102
+ */
103
+ export function isOk(body) {
104
+ if (body === null || typeof body !== 'object') return false;
105
+ if (body.errcode !== undefined && body.errcode !== 0) return false;
106
+ if (body.ret !== undefined) return body.ret === 0;
107
+ if (body.errcode === 0) return true;
108
+ // `sendmessage`'s success shape, measured.
109
+ return body.message_id !== undefined && body.message_id !== null;
110
+ }
111
+
112
+ /** The reason a response carries, for a log or an error. Never invented. */
113
+ export function failureOf(body) {
114
+ if (body === null || typeof body !== 'object') return { errcode: null, errmsg: 'the response was not an object' };
115
+ return {
116
+ errcode: body.errcode ?? null,
117
+ errmsg: body.errmsg ?? body.err_msg ?? body.message ?? null,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Is this a URL the bot token may be sent to?
123
+ *
124
+ * Checked on the parsed hostname, not a string prefix: `ilinkai.weixin.qq.com.attacker.example`
125
+ * ends with a plausible string and is not WeChat.
126
+ */
127
+ export function isAllowedApiUrl(url) {
128
+ let parsed;
129
+ try {
130
+ parsed = new URL(url);
131
+ } catch {
132
+ return { ok: false, reason: 'not a url' };
133
+ }
134
+ if (parsed.protocol !== 'https:') {
135
+ return { ok: false, reason: `the base url is ${parsed.protocol}//, and a bearer token goes over https only` };
136
+ }
137
+ const host = parsed.hostname.toLowerCase();
138
+ if (!ALLOWED_API_HOSTS.includes(host)) {
139
+ return { ok: false, reason: `${host} is not a host this channel will send a token to` };
140
+ }
141
+ return { ok: true };
142
+ }
143
+
144
+ /**
145
+ * Text out of an inbound message, using the shape a live response actually has:
146
+ *
147
+ * item_list: [{ type: 1, text_item: { text: "..." }, ... }]
148
+ */
149
+ export function extractText(message) {
150
+ const items = Array.isArray(message?.item_list) ? message.item_list : [];
151
+ const parts = [];
152
+ for (const item of items) {
153
+ const text = item?.text_item?.text;
154
+ if (typeof text === 'string' && text !== '') parts.push(text);
155
+ }
156
+ return parts.length === 0 ? null : parts.join('\n');
157
+ }
158
+
159
+ /** The platform's id for a message. Measured as a number in a live response, so it is stringified. */
160
+ export function messageId(message) {
161
+ const id = message?.message_id ?? message?.msg_id ?? null;
162
+ if (id === null || id === undefined) return null;
163
+ const text = String(id);
164
+ return text === '' ? null : text;
165
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The real HTTP transport for the WeChat channel (v0.7.1).
3
+ *
4
+ * v0.7.0 required the host to inject a transport and shipped none, so nothing could ever send
5
+ * a request. That was a design gap rather than a typo: an adapter with no default client is an
6
+ * adapter that cannot run. This is the default, and it is still injectable, because the
7
+ * contract tests need to drive it without a network.
8
+ *
9
+ * ## What it does that a bare `fetch` does not
10
+ *
11
+ * * **A per-request deadline**, because `getupdates` is a deliberate long poll and `notifystart`
12
+ * is not; one timeout for both would either cut the poll short or hang the handshake.
13
+ * * **`undici`'s `cause` unwrapping.** A bare `fetch` failure is `TypeError: fetch failed` with
14
+ * the actual reason (`ECONNRESET`, `ENOTFOUND`, `CERT_*`) on `error.cause`. Reporting only the
15
+ * TypeError is how a network problem looks like a protocol problem.
16
+ * * **The `ret` / `errcode` distinction returned as data.** It does not throw on a business
17
+ * failure: `{"errcode":-14}` arrives with HTTP 200 and is a *value*, and a transport that
18
+ * turned it into an exception would make the channel's own error handling unreachable.
19
+ */
20
+
21
+ import { randomBytes } from 'node:crypto';
22
+ import { APP_ID, CLIENT_VERSION, AUTH_TYPE, isOk, failureOf } from './ilink-protocol.mjs';
23
+
24
+ /** `X-WECHAT-UIN`: base64 of a random uint32 rendered as decimal text. Measured. */
25
+ export function makeUin() {
26
+ return Buffer.from(String(randomBytes(4).readUInt32BE(0)), 'utf8').toString('base64');
27
+ }
28
+
29
+ /**
30
+ * Headers for one request.
31
+ *
32
+ * `AuthorizationType` is the one that is easy to leave out and impossible to debug: without it
33
+ * every authenticated endpoint answers `-14 session timeout`, including calls with no
34
+ * credential at all, because the server rejects before it reads the token.
35
+ */
36
+ export function buildHeaders({ token, uin = makeUin() } = {}) {
37
+ const headers = {
38
+ 'iLink-App-Id': APP_ID,
39
+ 'iLink-App-ClientVersion': CLIENT_VERSION,
40
+ 'content-type': 'application/json',
41
+ AuthorizationType: AUTH_TYPE,
42
+ 'X-WECHAT-UIN': uin,
43
+ };
44
+ if (token) headers.Authorization = `Bearer ${token}`;
45
+ return headers;
46
+ }
47
+
48
+ /**
49
+ * Return a copy with rounded `message_id` numbers replaced by the exact digits the server sent.
50
+ *
51
+ * **Returns** rather than mutates. The first version edited in place, so a caller holding the
52
+ * body it had passed in saw it change underneath — and in the test suite, where a fixture
53
+ * constant is shared between cases, the first case silently rewrote it for every case after.
54
+ * A function whose name says "repair" should not be able to surprise its caller that way, and
55
+ * the symptom (a later test failing on data it never touched) is expensive to trace.
56
+ *
57
+ * Walked in document order: `JSON.parse` preserves key order, and the regex that collected the
58
+ * ids read them in the same order, so the nth `message_id` encountered is the nth id captured.
59
+ * Matching by value instead would be circular — the whole problem is that the value is wrong.
60
+ */
61
+ export function repairMessageIds(node, exactIds) {
62
+ const remaining = [...exactIds];
63
+ function walk(current) {
64
+ if (current === null || typeof current !== 'object') return current;
65
+ if (Array.isArray(current)) return current.map(walk);
66
+ const copy = {};
67
+ for (const [key, value] of Object.entries(current)) {
68
+ if (key === 'message_id' && typeof value === 'number' && remaining.length > 0) {
69
+ copy[key] = remaining.shift();
70
+ } else {
71
+ copy[key] = walk(value);
72
+ }
73
+ }
74
+ return copy;
75
+ }
76
+ return walk(node);
77
+ }
78
+
79
+ /**
80
+ * Create a transport.
81
+ *
82
+ * @param {object} [options]
83
+ * @param {typeof fetch} [options.fetch] Injected in tests; the platform's own otherwise.
84
+ * @param {() => string} [options.mintUin]
85
+ * @param {(event: string, detail?: object) => void} [options.audit]
86
+ * @returns {(request: {url: string, body: unknown, token?: string|null, timeoutMs?: number, signal?: AbortSignal}) => Promise<{ok: boolean, http: number, body: object, ms: number, errcode?: number|null, errmsg?: string|null}>}
87
+ */
88
+ export function createTransport(options = {}) {
89
+ // `??` would treat an explicit `null` as "not provided" and quietly fall back to the global
90
+ // fetch, so a test asking for no fetch would silently get the network instead. Presence is
91
+ // checked rather than nullishness.
92
+ const hasFetch = Object.prototype.hasOwnProperty.call(options, 'fetch');
93
+ const doFetch = hasFetch ? options.fetch : globalThis.fetch;
94
+ if (typeof doFetch !== 'function') {
95
+ // Named rather than left to fail at the first request with "doFetch is not a function".
96
+ throw new TypeError('createTransport needs a fetch implementation; Node 20.19+ provides one, or inject options.fetch');
97
+ }
98
+ const mintUin = options.mintUin ?? makeUin;
99
+ const audit = options.audit ?? (() => {});
100
+
101
+ return async function request({ url, body, token = null, timeoutMs = 20_000, signal: outer } = {}) {
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
104
+ // Both the caller's cancellation and the deadline apply, so a channel being stopped cuts a
105
+ // long poll short instead of waiting out its 45 seconds.
106
+ const signal = outer ? AbortSignal.any([outer, controller.signal]) : controller.signal;
107
+ const started = Date.now();
108
+
109
+ let response;
110
+ try {
111
+ response = await doFetch(url, {
112
+ method: 'POST',
113
+ headers: buildHeaders({ token, uin: mintUin() }),
114
+ body: JSON.stringify(body ?? {}),
115
+ signal,
116
+ });
117
+ } catch (error) {
118
+ clearTimeout(timer);
119
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
120
+ return { ok: false, http: 0, body: {}, ms: Date.now() - started, errcode: null, errmsg: `timed out after ${timeoutMs}ms`, timedOut: true };
121
+ }
122
+ // `fetch failed` hides the reason on `cause`; without this a network fault reads as a
123
+ // protocol fault and sends you debugging field names.
124
+ const cause = error?.cause;
125
+ const detail = cause ? `${cause.code ?? ''}${cause.code ? ' ' : ''}${cause.message ?? cause}`.trim() : 'no-cause';
126
+ audit('transport/network-error', { url, detail });
127
+ return { ok: false, http: 0, body: {}, ms: Date.now() - started, errcode: null, errmsg: `${error?.message ?? error} [${detail}]` };
128
+ }
129
+ clearTimeout(timer);
130
+
131
+ const text = await response.text();
132
+ let json = null;
133
+ try {
134
+ json = JSON.parse(text);
135
+ } catch {
136
+ // Some endpoints answer with an opaque string; the caller sees it in `errmsg`.
137
+ json = null;
138
+ }
139
+ const ms = Date.now() - started;
140
+
141
+ if (json === null) {
142
+ return { ok: false, http: response.status, body: {}, ms, errcode: null, errmsg: `non-JSON response: ${text.slice(0, 200)}` };
143
+ }
144
+
145
+ // 64-bit ids, recovered as text.
146
+ //
147
+ // A live `message_id` is 7515840255213248776 — nineteen digits, and past
148
+ // `Number.MAX_SAFE_INTEGER` (9007199254740991). `JSON.parse` therefore rounds it, and the
149
+ // value we hold is *not* the value the server sent. Measured: the constant
150
+ // `7515840255213248776` came back out of a parse as `7515840255213249000`.
151
+ //
152
+ // For de-duplication the rounding is mostly harmless — a re-delivery carries the same
153
+ // rounded number, so it is still caught. It is not harmless if an id is ever sent back to
154
+ // the platform or persisted as a key, so the exact digits are recovered here rather than
155
+ // left to a comment.
156
+ const exactIds = [...text.matchAll(/"message_id"\s*:\s*(\d{16,})/g)].map((m) => m[1]);
157
+ if (exactIds.length > 0) json = repairMessageIds(json, exactIds);
158
+
159
+ const ok = response.ok && isOk(json);
160
+ if (!ok) {
161
+ const { errcode, errmsg } = failureOf(json);
162
+ audit('transport/refused', { url, http: response.status, errcode, errmsg });
163
+ return { ok: false, http: response.status, body: json, ms, errcode, errmsg: errmsg ?? `HTTP ${response.status}` };
164
+ }
165
+ audit('transport/ok', { url, http: response.status, ms });
166
+ return { ok: true, http: response.status, body: json, ms, errcode: null, errmsg: null };
167
+ };
168
+ }