@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.3
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/assets/wx.svg +5 -0
- package/package.json +9 -4
- package/pf-plugin.toml +18 -0
- package/pi/config.mjs +26 -0
- package/pi/design-002.test.mjs +323 -0
- package/pi/fixtures/ec-golden.json +1 -0
- package/pi/fixtures/getupdates-real.json +37 -0
- package/pi/fixtures/qr-golden.json +1 -0
- package/pi/hook.mjs +231 -0
- package/pi/hook.test.mjs +351 -0
- package/pi/index.mjs +648 -0
- package/pi/index.test.mjs +685 -0
- package/pi/inject.mjs +41 -0
- package/pi/inject.test.mjs +55 -0
- package/pi/jsonx.mjs +64 -0
- package/pi/lock.mjs +171 -0
- package/pi/messages.mjs +160 -0
- package/pi/poller.mjs +364 -0
- package/pi/poller.test.mjs +191 -0
- package/pi/qr.mjs +454 -0
- package/pi/qr.test.mjs +101 -0
- package/pi/register.mjs +236 -0
- package/pi/register.test.mjs +232 -0
- package/pi/reply.mjs +49 -0
- package/pi/state.mjs +248 -0
- package/pi/state.test.mjs +138 -0
- package/pi/transport.mjs +335 -0
- package/pi/transport.test.mjs +327 -0
- package/pi/wire.mjs +219 -0
- package/pi/wire.test.mjs +29 -0
- package/plugin.wasm +0 -0
- package/README.md +0 -3
package/pi/wire.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// wire —— iLink HTTP 传输层,自 PF 仓 wxprobe src/wire.rs **逐条移植**(JS 版)。
|
|
2
|
+
// pi ext = Node 全权代码(契约 v2.4 pi_extensions),fetch/fs 直用,零 npm 依赖。
|
|
3
|
+
//
|
|
4
|
+
// 出站行为对齐 ZCode `packages/services/src/bots/providers/`:
|
|
5
|
+
// - 扫码接口:GET + `iLink-App-ClientVersion: 1`(weixinRegistration.ts:86-101)
|
|
6
|
+
// - 业务接口:POST + `AuthorizationType: ilink_bot_token` + `Bearer` +
|
|
7
|
+
// `X-WECHAT-UIN` + `base_info.channel_version`(weixinProvider.ts:104-155)
|
|
8
|
+
// - 三档超时 30s / 90s / 15s,分别对齐 ZCode 的三个常量
|
|
9
|
+
//
|
|
10
|
+
// **每个请求的 request 与 response 原样落盘**(dump_dir)—— 2026-10-23 真机
|
|
11
|
+
// -14 静默重试数小时的教训:没有原始报文就只能靠猜。
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parse } from "./jsonx.mjs";
|
|
17
|
+
|
|
18
|
+
export const ILINK_BASE = "https://ilinkai.weixin.qq.com";
|
|
19
|
+
export const API_PREFIX = "/ilink/bot";
|
|
20
|
+
export const CHANNEL_VERSION = "2.0.0";
|
|
21
|
+
|
|
22
|
+
/** 扫码接口超时(ZCode WEIXIN_LOGIN_REQUEST_TIMEOUT_MS)。 */
|
|
23
|
+
export const QR_TIMEOUT_MS = 30_000;
|
|
24
|
+
/** getupdates 长轮询超时(ZCode WEIXIN_GET_UPDATES_TIMEOUT_MS)。 */
|
|
25
|
+
export const POLL_TIMEOUT_MS = 90_000;
|
|
26
|
+
/** 普通业务接口超时(ZCode BOT_PROVIDER_REQUEST_TIMEOUT_MS)。 */
|
|
27
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
/** dump 目录只留最近这么多对文件,探针反复跑不会把磁盘写爆。 */
|
|
30
|
+
const DUMP_KEEP = 120;
|
|
31
|
+
|
|
32
|
+
export function sha256_hex(text) {
|
|
33
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class Wire {
|
|
37
|
+
/** @param {string|null} dumpDir 原始报文落盘目录;null = 不落盘(单测) */
|
|
38
|
+
constructor(dumpDir = null) {
|
|
39
|
+
this.dumpDir = dumpDir;
|
|
40
|
+
if (dumpDir) {
|
|
41
|
+
try { mkdirSync(dumpDir, { recursive: true }); } catch { /* 排障产物,尽力而为 */ }
|
|
42
|
+
this.#prune();
|
|
43
|
+
}
|
|
44
|
+
this.seq = this.#nextSeq();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 扫码:`GET /ilink/bot/<path>?<query>`,无鉴权(30s)。 */
|
|
48
|
+
async get_qr(tag, path, query = []) {
|
|
49
|
+
const url = new URL(API_PREFIX + path, ILINK_BASE);
|
|
50
|
+
for (const [k, v] of query) url.searchParams.set(k, v);
|
|
51
|
+
const seq = this.seq++;
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
headers: { "iLink-App-ClientVersion": "1" },
|
|
55
|
+
signal: AbortSignal.timeout(QR_TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
return await this.#consume(tag, seq, res, null);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(`GET ${path}: ${e.message ?? e}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 业务:`POST /ilink/bot/<path>`,带 bot_token(15s)。 */
|
|
64
|
+
async post(tag, path, token, body) {
|
|
65
|
+
return this.#postInner(DEFAULT_TIMEOUT_MS, tag, path, token, body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** getupdates 长轮询(90s)—— JS await 原生生态位:await 期间 send 并发照跑。 */
|
|
69
|
+
async post_poll(tag, path, token, body) {
|
|
70
|
+
return this.#postInner(POLL_TIMEOUT_MS, tag, path, token, body);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async #postInner(timeoutMs, tag, path, token, body) {
|
|
74
|
+
const url = ILINK_BASE + API_PREFIX + path;
|
|
75
|
+
const headers = {};
|
|
76
|
+
if (token) {
|
|
77
|
+
headers["content-type"] = "application/json";
|
|
78
|
+
headers["AuthorizationType"] = "ilink_bot_token";
|
|
79
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
80
|
+
headers["X-WECHAT-UIN"] = wechat_uin();
|
|
81
|
+
}
|
|
82
|
+
const payload = with_base_info(body);
|
|
83
|
+
const seq = this.seq++;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body: JSON.stringify(payload),
|
|
89
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
90
|
+
});
|
|
91
|
+
return await this.#consume(tag, seq, res, payload);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw new Error(`POST ${path}: ${e.message ?? e}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #consume(tag, seq, res, reqBody) {
|
|
98
|
+
const text = await res.text();
|
|
99
|
+
this.#dump(seq, tag, res.status, reqBody, text);
|
|
100
|
+
if (!(res.status >= 200 && res.status < 300)) {
|
|
101
|
+
throw new Error(`${tag}: HTTP ${res.status}: ${truncate(text, 400)}`);
|
|
102
|
+
}
|
|
103
|
+
let value;
|
|
104
|
+
try {
|
|
105
|
+
value = parse(text); // jsonx:message_id 等 19 位整数保精度
|
|
106
|
+
} catch (e) {
|
|
107
|
+
throw new Error(`${tag}: 非 JSON 响应 ${e.message}: ${truncate(text, 400)}`);
|
|
108
|
+
}
|
|
109
|
+
check_ret(value);
|
|
110
|
+
return unwrap_data(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#dump(seq, tag, status, req, resText) {
|
|
114
|
+
if (!this.dumpDir) return;
|
|
115
|
+
const base = join(this.dumpDir, `${String(seq).padStart(3, "0")}-${tag}`);
|
|
116
|
+
try {
|
|
117
|
+
if (req) writeFileSync(`${base}.req.json`, JSON.stringify(req, null, 2));
|
|
118
|
+
writeFileSync(`${base}.res.txt`, `HTTP ${status}\n${resText}`);
|
|
119
|
+
} catch { /* 排障产物,尽力而为 */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#nextSeq() {
|
|
123
|
+
if (!this.dumpDir) return 1;
|
|
124
|
+
try {
|
|
125
|
+
let max = 0;
|
|
126
|
+
for (const name of readdirSync(this.dumpDir)) {
|
|
127
|
+
const m = /^(\d+)-/.exec(name);
|
|
128
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
129
|
+
}
|
|
130
|
+
return max + 1;
|
|
131
|
+
} catch {
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 按 `.res.txt` 后缀识别响应文件(`Path::extension` 对它返回 "txt" 的坑,见 wire.rs)。 */
|
|
137
|
+
#prune() {
|
|
138
|
+
let resFiles;
|
|
139
|
+
try {
|
|
140
|
+
// 数值序:文件名以 seq 开头,字典序会把 "1000-…" 排在 "880-…" 前面,
|
|
141
|
+
// 剪掉的是**最新**的 dump
|
|
142
|
+
resFiles = readdirSync(this.dumpDir)
|
|
143
|
+
.filter((n) => n.endsWith(".res.txt"))
|
|
144
|
+
.sort((a, b) => (Number.parseInt(a, 10) || 0) - (Number.parseInt(b, 10) || 0));
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (resFiles.length <= DUMP_KEEP) return;
|
|
149
|
+
for (const name of resFiles.slice(0, resFiles.length - DUMP_KEEP)) {
|
|
150
|
+
const base = join(this.dumpDir, name.slice(0, -".res.txt".length));
|
|
151
|
+
try { rmSync(`${base}.res.txt`, { force: true }); } catch { /* 尽力而为 */ }
|
|
152
|
+
try { rmSync(`${base}.req.json`, { force: true }); } catch { /* 尽力而为 */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** ZCode `appendBaseInfo`:`{ base_info, ...body }`。 */
|
|
158
|
+
export function with_base_info(body) {
|
|
159
|
+
return { base_info: { channel_version: CHANNEL_VERSION }, ...body };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** ZCode `unwrapData`:`payload.data ?? payload`(扫码接口把字段包在 `data` 里)。 */
|
|
163
|
+
export function unwrap_data(value) {
|
|
164
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
165
|
+
if (isObj(value) && "data" in value && isObj(value.data)) {
|
|
166
|
+
const merged = { ...value };
|
|
167
|
+
delete merged.data;
|
|
168
|
+
for (const [k, v] of Object.entries(value.data)) {
|
|
169
|
+
if (!(k in merged)) merged[k] = v; // or_insert:顶层已有键优先
|
|
170
|
+
}
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** ZCode `requestWeixinJson` 的 ret/errcode 校验(weixinProvider.ts:141-150)。 */
|
|
177
|
+
export function check_ret(payload) {
|
|
178
|
+
const ret = num(payload, "ret");
|
|
179
|
+
const errcode = num(payload, "errcode");
|
|
180
|
+
if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
|
|
181
|
+
const msg = str_of(payload, "errmsg") ?? str_of(payload, "message") ?? "";
|
|
182
|
+
// 错误串形态与 Rust 对齐:is_session_timeout 靠「errcode=-14」与
|
|
183
|
+
// 服务端原文 "session timeout" 判过期(transport.is_session_timeout)
|
|
184
|
+
throw new Error(`iLink 业务错误 ret=${fmt_opt(ret)} errcode=${fmt_opt(errcode)} ${msg}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function num(v, key) {
|
|
189
|
+
const x = v?.[key];
|
|
190
|
+
return typeof x === "number" ? x : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function str_of(v, key) {
|
|
194
|
+
const x = v?.[key];
|
|
195
|
+
if (typeof x === "string") return x;
|
|
196
|
+
if (typeof x === "number") return String(x);
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fmt_opt(v) {
|
|
201
|
+
return v === undefined ? "None" : `Some(${v})`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `X-WECHAT-UIN` = base64(十进制 u32 的 UTF-8 字节),ZCode `buildRandomWechatUin`。 */
|
|
205
|
+
export function wechat_uin() {
|
|
206
|
+
const nanos = (Date.now() % 1_000_000) * 1000 || 0x5eed;
|
|
207
|
+
const mixed = (nanos ^ (process.pid * 2_654_435_761)) >>> 0;
|
|
208
|
+
return Buffer.from(String(mixed), "utf8").toString("base64");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function truncate(s, n) {
|
|
212
|
+
return Array.from(s).length <= n ? s : Array.from(s).slice(0, n).join("") + "…";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 供排障:dump 目录里最近一次 getupdates 响应原文(存在时)。 */
|
|
216
|
+
export function last_dump(dumpDir, tag) {
|
|
217
|
+
const p = join(dumpDir, `${tag}.res.txt`);
|
|
218
|
+
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
219
|
+
}
|
package/pi/wire.test.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// wire dump 剪枝单测 —— #prune 的排序口径。
|
|
2
|
+
// 跑法:node --test packages/pf-wxbot/pi/wire.test.mjs
|
|
3
|
+
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync, writeFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { Wire } from "./wire.mjs";
|
|
11
|
+
|
|
12
|
+
test("prune_keeps_newest_by_numeric_seq_not_lexicographic", () => {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "wxwire-"));
|
|
14
|
+
// 125 个 res:1..5(老)+ 1000..1119(新)。字典序会把 "1000-…" 排在
|
|
15
|
+
// "880-…"/"5-…" 前面 —— 旧实现剪的是**最新**的 dump(2026-10-29 排障时
|
|
16
|
+
// qr-status 证据文件就是这样被吃掉的)。数值序必须剪最老的 1..5。
|
|
17
|
+
for (let i = 1; i <= 5; i += 1) writeFileSync(join(dir, `${i}-getupdates.res.txt`), "x");
|
|
18
|
+
for (let i = 1000; i <= 1119; i += 1) writeFileSync(join(dir, `${i}-getupdates.res.txt`), "x");
|
|
19
|
+
|
|
20
|
+
new Wire(dir); // 构造即 prune(DUMP_KEEP = 120)
|
|
21
|
+
|
|
22
|
+
const left = new Set(readdirSync(dir));
|
|
23
|
+
for (let i = 1; i <= 5; i += 1) {
|
|
24
|
+
assert.ok(!left.has(`${i}-getupdates.res.txt`), `最老 dump ${i} 被剪`);
|
|
25
|
+
}
|
|
26
|
+
for (let i = 1000; i <= 1119; i += 1) {
|
|
27
|
+
assert.ok(left.has(`${i}-getupdates.res.txt`), `最新 dump ${i} 保留`);
|
|
28
|
+
}
|
|
29
|
+
});
|
package/plugin.wasm
ADDED
|
Binary file
|
package/README.md
DELETED