@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.
- package/CHANGELOG.md +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -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 +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -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-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -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 +440 -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 +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
|
+
}
|