@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,258 @@
1
+ /**
2
+ * Hand-off: what happens when the machine holding a task does not answer (v0.7.7).
3
+ *
4
+ * ## What this decides
5
+ *
6
+ * "The default PC did not reply, so another PC takes over" is a rule with a sharp edge that is
7
+ * easy to miss while implementing it, and the edge is this:
8
+ *
9
+ * > **Hand-off turns execution from at-most-once into at-least-once.**
10
+ *
11
+ * A command may now run on **two** machines. That is harmless for `node --test` and it is not
12
+ * harmless for anything that writes — a `write: true` task could apply its change twice, and the
13
+ * second application happens on a machine that has no idea the first one already ran. The design
14
+ * document forbids automatic hand-off for writing tasks until a fencing token exists, and this
15
+ * module **is** that prohibition: `write: true` plus `halt: 'now'` is refused outright rather than
16
+ * quietly allowed.
17
+ *
18
+ * ## The four cases, and why each needs its own answer
19
+ *
20
+ * | case | what it means | what happens |
21
+ * |---|---|---|
22
+ * | **done** | the machine answered | nothing; the aggregate is decided |
23
+ * | **silent** | connected but its heartbeat stopped | takes over only under `halt: 'now'` |
24
+ * | **offline** | the connection dropped | same as silent, but immediately — this is the main path |
25
+ * | **late** | the original reported *after* a takeover | recorded, and **never** overwrites the successor |
26
+ *
27
+ * The late case is the one worth stating: a result that arrives after its lease was reassigned is
28
+ * **evidence**, not an answer. Overwriting the successor's result with it would mean the verdict
29
+ * depends on which machine was slower to reply, which is not a property anyone asked for.
30
+ *
31
+ * ## Refusals are named
32
+ *
33
+ * "Why did nothing take over" has several different answers — the task was cancelled, the caller
34
+ * asked not to halt, the task writes, there is no other machine — and they have different fixes.
35
+ * Collapsing them into "no hand-off" is how an operator concludes the feature is broken.
36
+ */
37
+
38
+ /** Why a takeover did not happen. Every value is a thing an operator can act on. */
39
+ export const NO_HANDOFF = Object.freeze({
40
+ /** Nothing to do: the lease finished. */
41
+ DONE: 'done',
42
+ /** The caller asked the relay not to reassign (`halt: 'never'`). */
43
+ HALT_NEVER: 'halt_never',
44
+ /** The task writes, so a second run could apply the same change twice. */
45
+ WRITE_NOT_IDEMPOTENT: 'write_not_idempotent',
46
+ /** The task was cancelled; taking it over would resurrect it. */
47
+ CANCELLED: 'cancelled',
48
+ /** No other machine in this fleet is online. */
49
+ NO_ALTERNATE_MACHINE: 'no_alternate_machine',
50
+ /** Every other machine has already tried this slice. */
51
+ ALL_TRIED: 'all_tried',
52
+ /** The lease has not expired yet, so the holder is presumed to be working. */
53
+ NOT_YET_EXPIRED: 'not_yet_expired',
54
+ });
55
+
56
+ /** Which attempt a lease is on. Kept explicit so a late result can be recognised by number. */
57
+ export const LEASE_STATE = Object.freeze({
58
+ OFFERED: 'offered',
59
+ CLAIMED: 'claimed',
60
+ DONE: 'done',
61
+ EXPIRED: 'expired',
62
+ REFUSED: 'refused',
63
+ CANCELLED: 'cancelled',
64
+ });
65
+
66
+ /**
67
+ * Is this lease finished, one way or another?
68
+ *
69
+ * `expired` and `refused` are terminal for the *lease* but not for the *task* — the slice may
70
+ * still be handed to another machine, which is the whole point. `done` is terminal for both.
71
+ */
72
+ export function isLeaseTerminal(state) {
73
+ return state === LEASE_STATE.DONE || state === LEASE_STATE.CANCELLED;
74
+ }
75
+
76
+ /**
77
+ * Should this slice be handed to another machine?
78
+ *
79
+ * @param {object} input
80
+ * @param {{state: string, machine_id: string, attempt: number, lease_until_ms?: number}} input.lease
81
+ * @param {boolean} input.write Whether the task writes. Decides whether hand-off is safe at all.
82
+ * @param {'never'|'now'} input.halt The caller's stated policy.
83
+ * @param {boolean} input.cancelled
84
+ * @param {string[]} input.triedMachineIds Machines that have already held this slice.
85
+ * @param {Array<{machine_id: string, fleet_id?: string, online?: boolean, caps?: object}>} input.candidates
86
+ * Devices that may take over. Already fleet-filtered by the caller.
87
+ * @param {number} [input.now]
88
+ * @param {number} [input.takeoverGraceMs] Extra delay before a takeover, so a machine that is
89
+ * merely slow is not pre-empted. A slow machine is not a dead one.
90
+ */
91
+ export function shouldHandOff({
92
+ lease,
93
+ write,
94
+ halt,
95
+ cancelled,
96
+ triedMachineIds,
97
+ candidates,
98
+ now = Date.now(),
99
+ takeoverGraceMs = 0,
100
+ }) {
101
+ if (lease === null || typeof lease !== 'object') {
102
+ return { takeOver: false, reason: NO_HANDOFF.DONE, text: '没有租约。' };
103
+ }
104
+ if (cancelled === true) {
105
+ // Reassigning a cancelled task would resurrect it, and the operator cancelled it on purpose.
106
+ return { takeOver: false, reason: NO_HANDOFF.CANCELLED, text: '任务已取消,不会重派。' };
107
+ }
108
+ if (isLeaseTerminal(lease.state)) {
109
+ return { takeOver: false, reason: NO_HANDOFF.DONE, text: '这一片已经有结果了。' };
110
+ }
111
+ if (halt !== 'now') {
112
+ // The default. A stopped machine is *recorded* rather than replaced, because for anything
113
+ // that writes, a second runner is worse than a missing result.
114
+ return { takeOver: false, reason: NO_HANDOFF.HALT_NEVER, text: '这个任务没有开启接手(halt 不是 now)。' };
115
+ }
116
+ if (write === true) {
117
+ // The sharp edge. Refused, not warned about: a second application of the same write is a
118
+ // data-loss shape, and the caller cannot tell it happened from the verdict alone.
119
+ return {
120
+ takeOver: false,
121
+ reason: NO_HANDOFF.WRITE_NOT_IDEMPOTENT,
122
+ text: '这是写任务,自动接手会让同一条命令执行两次,因此拒绝。',
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A lease that has not expired is presumed to be progressing.
128
+ *
129
+ * Two separate questions, in this order — and the first version of this got the arithmetic
130
+ * backwards by adding the grace to the wrong side, which a test caught:
131
+ *
132
+ * 1. **Is the lease still valid?** If `now < lease_until`, the holder is within its window and
133
+ * nothing is taken over, whatever the grace says.
134
+ * 2. **Has the grace elapsed since it expired?** Only then is a takeover considered. A machine
135
+ * that is merely slow is not a machine that is gone.
136
+ *
137
+ * The boundary is inclusive on the decline side, so the outcome does not depend on which side of
138
+ * a millisecond the scheduler landed.
139
+ */
140
+ const until = Number(lease.lease_until_ms);
141
+ if (Number.isFinite(until)) {
142
+ if (now < until) {
143
+ return { takeOver: false, reason: NO_HANDOFF.NOT_YET_EXPIRED, text: '租约还没到期,先假定它在正常工作。' };
144
+ }
145
+ if (now < until + takeoverGraceMs) {
146
+ return { takeOver: false, reason: NO_HANDOFF.NOT_YET_EXPIRED, text: '租约刚到期,还在宽限期内。' };
147
+ }
148
+ }
149
+
150
+ const tried = new Set(Array.isArray(triedMachineIds) ? triedMachineIds : []);
151
+ // The current holder is "tried" whether or not the caller said so: handing the slice back to the
152
+ // machine that just went silent is the one reassignment guaranteed to be useless.
153
+ if (typeof lease.machine_id === 'string') tried.add(lease.machine_id);
154
+
155
+ // "Available" means untried **and online**. An offline machine is not an alternate, and counting
156
+ // it as one is what would make the two refusals below indistinguishable.
157
+ const available = (Array.isArray(candidates) ? candidates : []).filter(
158
+ (c) => c && typeof c.machine_id === 'string' && !tried.has(c.machine_id) && c.online !== false,
159
+ );
160
+ const alternate = available[0];
161
+
162
+ if (alternate === undefined) {
163
+ /**
164
+ * Two different situations, and the distinction is *how many machines exist*, not how many
165
+ * are untried.
166
+ *
167
+ * A test found the first version of this checking "is there an untried machine", which is
168
+ * always false in the all-tried case — so the two refusals were indistinguishable and the
169
+ * named reason was unreachable. The question an operator is actually asking is:
170
+ *
171
+ * * the fleet has other machines and every one of them has had a turn → `ALL_TRIED`
172
+ * (fix: wait, or make one succeed)
173
+ * * the fleet holds only this machine → `NO_ALTERNATE_MACHINE`
174
+ * (fix: add a machine to the fleet)
175
+ *
176
+ * The current holder is excluded from *availability* but not from *existence*: a fleet of one
177
+ * has no alternate and never will.
178
+ */
179
+ const others = (Array.isArray(candidates) ? candidates : []).filter(
180
+ (c) => c && typeof c.machine_id === 'string' && c.machine_id !== lease.machine_id,
181
+ );
182
+ return others.length > 0
183
+ ? { takeOver: false, reason: NO_HANDOFF.ALL_TRIED, text: '这条舰队里其他机器都已经试过这一片了。' }
184
+ : { takeOver: false, reason: NO_HANDOFF.NO_ALTERNATE_MACHINE, text: '这条舰队里没有其他机器可以接手。' };
185
+ }
186
+
187
+ return {
188
+ takeOver: true,
189
+ machineId: alternate.machine_id,
190
+ attempt: (Number.isFinite(lease.attempt) ? lease.attempt : 1) + 1,
191
+ text: `交给 ${alternate.machine_name ?? alternate.machine_id} 接手(第 ${(Number.isFinite(lease.attempt) ? lease.attempt : 1) + 1} 次尝试)。`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Which attempt's result counts, when more than one arrived.
197
+ *
198
+ * The rule is **highest attempt wins**, not "last to arrive" and not "first to arrive". Arrival
199
+ * order is a property of the network, and a verdict that depends on it is a verdict nobody can
200
+ * reason about. The attempt number is assigned by the relay, so it is the one ordering that does
201
+ * not depend on who was faster.
202
+ *
203
+ * Results from superseded attempts are **returned rather than dropped**: a late result from the
204
+ * machine that went silent is diagnostic — it says the machine was alive and slow, not dead —
205
+ * and discarding it turns a diagnosable situation into an unexplained one.
206
+ */
207
+ export function reconcileResults(results) {
208
+ const list = (Array.isArray(results) ? results : []).filter((r) => r && typeof r === 'object');
209
+ if (list.length === 0) return { winner: null, superseded: [], arrivedLate: [] };
210
+
211
+ // A missing attempt number is treated as **0**, not as infinity. A frame with no attempt is an
212
+ // old frame whose attempt was not recorded — treating it as the newest would let a stale result
213
+ // from a superseded lease win the aggregate, which is the exact inversion this function exists
214
+ // to prevent. A test asserts the direction.
215
+ const attemptOf = (r) => (Number.isFinite(r.attempt) ? r.attempt : 0);
216
+
217
+ let winner = list[0];
218
+ for (const result of list) {
219
+ const better = attemptOf(result);
220
+ const best = attemptOf(winner);
221
+ if (better > best) {
222
+ winner = result;
223
+ } else if (better === best) {
224
+ // Same attempt twice: only possible when one machine delivered twice. Either delivery is
225
+ // equivalent, so the first is kept — which makes the outcome stable across a replay of the
226
+ // ledger rather than dependent on ingest order.
227
+ continue;
228
+ }
229
+ }
230
+
231
+ const superseded = list.filter((r) => r !== winner && attemptOf(r) < attemptOf(winner));
232
+ const arrivedLate = superseded.filter((r) => r.arrivedAfterHandoff === true);
233
+ return { winner, superseded, arrivedLate };
234
+ }
235
+
236
+ /**
237
+ * The slice's story, in the order the relay saw it, for a report.
238
+ *
239
+ * Kept as a list rather than a summary because "which machine tried, in what order, and why the
240
+ * next one did or did not" is the question an operator actually asks, and a summarised answer
241
+ * cannot be interrogated.
242
+ */
243
+ export function handoffTrace(events) {
244
+ const list = Array.isArray(events) ? events : [];
245
+ return list
246
+ .filter((e) => e && typeof e === 'object')
247
+ .map((e) => {
248
+ const machine = e.machine_name ?? e.machine_id ?? '?';
249
+ if (e.event === 'offer') return `attempt ${e.attempt ?? '?'}: offered to ${machine}`;
250
+ if (e.event === 'done') return `attempt ${e.attempt ?? '?'}: ${machine} answered (${e.status ?? 'ok'})`;
251
+ if (e.event === 'expired') return `attempt ${e.attempt ?? '?'}: ${machine} went silent`;
252
+ if (e.event === 'refused') return `attempt ${e.attempt ?? '?'}: ${machine} refused (${e.reason ?? '?'})`;
253
+ if (e.event === 'handoff') return `attempt ${e.attempt ?? '?'}: handed to ${machine}`;
254
+ if (e.event === 'handoff-declined') return `no hand-off: ${e.reason ?? '?'}`;
255
+ if (e.event === 'late') return `attempt ${e.attempt ?? '?'}: ${machine} reported late, not counted`;
256
+ return `${e.event ?? '?'}: ${machine}`;
257
+ });
258
+ }
@@ -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
+ }