@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.
- package/CHANGELOG.md +1030 -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,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
|
+
}
|