@0xmaxma/claude-gateway 1.8.13 → 1.8.14
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/README.md +8 -0
- package/dist/agent/runner.d.ts +17 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +187 -14
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +237 -1
- package/dist/api/router.js.map +1 -1
- package/dist/api/wechat-access.d.ts +45 -0
- package/dist/api/wechat-access.d.ts.map +1 -0
- package/dist/api/wechat-access.js +48 -0
- package/dist/api/wechat-access.js.map +1 -0
- package/dist/history/types.d.ts +10 -1
- package/dist/history/types.d.ts.map +1 -1
- package/dist/history/types.js +10 -1
- package/dist/history/types.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +1 -0
- package/dist/session/process.js.map +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/wechat/ilink-client.d.ts +194 -0
- package/dist/wechat/ilink-client.d.ts.map +1 -0
- package/dist/wechat/ilink-client.js +641 -0
- package/dist/wechat/ilink-client.js.map +1 -0
- package/dist/wechat/manager.d.ts +93 -0
- package/dist/wechat/manager.d.ts.map +1 -0
- package/dist/wechat/manager.js +409 -0
- package/dist/wechat/manager.js.map +1 -0
- package/mcp/server.ts +2 -0
- package/mcp/tools/wechat/client.ts +49 -0
- package/mcp/tools/wechat/module.ts +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.encodeClientVersion = encodeClientVersion;
|
|
37
|
+
exports.normalizeQrImage = normalizeQrImage;
|
|
38
|
+
exports.resolveWeixinImageRef = resolveWeixinImageRef;
|
|
39
|
+
exports.resolveWeixinFileRef = resolveWeixinFileRef;
|
|
40
|
+
exports.aes128EcbDecryptPermissive = aes128EcbDecryptPermissive;
|
|
41
|
+
exports.downloadWeixinImage = downloadWeixinImage;
|
|
42
|
+
exports.createILinkClient = createILinkClient;
|
|
43
|
+
exports.createFakeILinkClient = createFakeILinkClient;
|
|
44
|
+
exports.isWeChatILinkFakeEnabled = isWeChatILinkFakeEnabled;
|
|
45
|
+
/**
|
|
46
|
+
* HTTP client for Tencent's iLink Bot API ("WeChat ClawBot") — the real,
|
|
47
|
+
* documented, self-serve protocol behind both source projects this channel
|
|
48
|
+
* was researched from (Hermes-agent's Weixin adapter, OpenClaw's WeChat
|
|
49
|
+
* plugin). The canonical protocol reference is Tencent's own
|
|
50
|
+
* `Tencent/openclaw-weixin` repo (docs/protocol.md), an MIT-licensed,
|
|
51
|
+
* actively-maintained OpenClaw channel plugin published as
|
|
52
|
+
* `@tencent-weixin/openclaw-weixin` — not a third-party paid bridge. No
|
|
53
|
+
* account signup with any company is required: `openclaw channels login`
|
|
54
|
+
* scans a QR with the user's own WeChat app and stores the resulting
|
|
55
|
+
* credentials locally, which is exactly the shape `startLinking()` below
|
|
56
|
+
* mirrors.
|
|
57
|
+
*
|
|
58
|
+
* IMPORTANT caveats carried over from that protocol doc, not filled in here:
|
|
59
|
+
* - `qrcode_img_content` — confirmed against a real response (2026-09-10):
|
|
60
|
+
* it is a `liteapp.weixin.qq.com` URL (an `text/html` mini-program deep
|
|
61
|
+
* link, NOT an image — confirmed by fetching it directly and checking
|
|
62
|
+
* Content-Type), meant to be scanned as data, not displayed as a
|
|
63
|
+
* pre-rendered picture. `normalizeQrImage()` below QR-encodes it into an
|
|
64
|
+
* actual PNG data URI (via the `qrcode` package) so `WeChatStatus.qr`
|
|
65
|
+
* keeps the same "ready-to-`<img src>`" contract every other device-linked
|
|
66
|
+
* channel's status already uses (mirrors WhatsApp/Baileys, which does the
|
|
67
|
+
* same QR-image encoding for its own pairing string).
|
|
68
|
+
* - `need_verifycode`/`verify_code_blocked` (an extra verification-code
|
|
69
|
+
* step some accounts hit) has no UI path yet — those statuses currently
|
|
70
|
+
* fall back to "expired" (ask the user to retry) rather than prompting
|
|
71
|
+
* for a code. See `pollLinkStatus`'s doc comment.
|
|
72
|
+
* - Media: inbound IMAGES (item type 2) are downloaded + decrypted (see
|
|
73
|
+
* `resolveWeixinImageRef`/`downloadWeixinImage` below, confirmed live
|
|
74
|
+
* 2026-09-11 against protocol.md + `NousResearch/hermes-agent`'s working
|
|
75
|
+
* Python implementation). Voice/file/video (types 3/4/5) and ALL outbound
|
|
76
|
+
* media sending are still out of scope — only `text_item` is written.
|
|
77
|
+
*/
|
|
78
|
+
const crypto = __importStar(require("crypto"));
|
|
79
|
+
const QRCode = __importStar(require("qrcode"));
|
|
80
|
+
const DEFAULT_ILINK_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
81
|
+
/** `base_info.bot_agent` — a short ASCII observability tag, per protocol.md's
|
|
82
|
+
* "sanitized observability identifier... not used for authentication or
|
|
83
|
+
* routing" — analogous to `bot_agent: "OpenClaw"` in Tencent's own examples.
|
|
84
|
+
* This gateway has no single fixed downstream product identity, so this is
|
|
85
|
+
* only the fallback when a deployer doesn't set `AgentConfig.wechat.botAgent`
|
|
86
|
+
* — see that field's doc comment. Mirrors `channels.openclaw-weixin.botAgent`
|
|
87
|
+
* in `Tencent/openclaw-weixin`'s own config being per-deployment, not fixed. */
|
|
88
|
+
const DEFAULT_BOT_AGENT = 'claude-gateway';
|
|
89
|
+
/** `base_info.channel_version` — this integration's own version, not the
|
|
90
|
+
* gateway's. Bump when this file's request/response handling changes. */
|
|
91
|
+
const CHANNEL_VERSION = '1.0.0';
|
|
92
|
+
/** Random uint32 → decimal string → base64, per protocol.md's `X-WECHAT-UIN` spec. */
|
|
93
|
+
function randomWechatUin() {
|
|
94
|
+
const n = Math.floor(Math.random() * 0x100000000);
|
|
95
|
+
return Buffer.from(String(n), 'utf-8').toString('base64');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Plugin version as `0x00MMNNPP` (major/minor/patch, one byte each) rendered
|
|
99
|
+
* as a decimal string, per protocol.md's `iLink-App-ClientVersion` spec.
|
|
100
|
+
*/
|
|
101
|
+
function encodeClientVersion(version) {
|
|
102
|
+
const [major = 0, minor = 0, patch = 0] = version.split('.').map((n) => parseInt(n, 10) || 0);
|
|
103
|
+
const encoded = ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
104
|
+
return String(encoded);
|
|
105
|
+
}
|
|
106
|
+
const CLIENT_VERSION_HEADER = encodeClientVersion(CHANNEL_VERSION);
|
|
107
|
+
/**
|
|
108
|
+
* Turn whatever `qrcode_img_content` actually is into a ready-to-`<img src>`
|
|
109
|
+
* PNG data URI. Confirmed shape (see module doc comment): a
|
|
110
|
+
* `liteapp.weixin.qq.com` URL that must itself be QR-encoded — it is data to
|
|
111
|
+
* scan, not a picture to show as-is.
|
|
112
|
+
*/
|
|
113
|
+
async function normalizeQrImage(content) {
|
|
114
|
+
if (content.startsWith('data:'))
|
|
115
|
+
return content;
|
|
116
|
+
if (content.startsWith('http://') || content.startsWith('https://')) {
|
|
117
|
+
return QRCode.toDataURL(content, { margin: 1 });
|
|
118
|
+
}
|
|
119
|
+
// Fallback for a shape neither confirmed response nor the doc describes:
|
|
120
|
+
// assume base64-encoded PNG bytes already.
|
|
121
|
+
return `data:image/png;base64,${content}`;
|
|
122
|
+
}
|
|
123
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15000;
|
|
124
|
+
/**
|
|
125
|
+
* `redirect_host` (and `baseurl` on a `confirmed` response) — confirmed live
|
|
126
|
+
* (2026-09-10) to arrive as a bare host, e.g. `ilinkai2.weixin.qq.com`, NOT a
|
|
127
|
+
* full `https://...` URL as protocol.md's naming implies. `new URL(path,
|
|
128
|
+
* base)` throws "Invalid URL" on a schemeless base, which silently wedged
|
|
129
|
+
* every poll after a real scan forever (the retry loop in
|
|
130
|
+
* WeChatManager.startLinking keeps calling this same host, gets the same
|
|
131
|
+
* throw every time, and only the attempt's 2-minute deadline ever ends it).
|
|
132
|
+
*/
|
|
133
|
+
function normalizeIlinkHost(host) {
|
|
134
|
+
return /^https?:\/\//i.test(host) ? host : `https://${host}`;
|
|
135
|
+
}
|
|
136
|
+
async function ilinkFetch(opts) {
|
|
137
|
+
let url;
|
|
138
|
+
try {
|
|
139
|
+
url = new URL(opts.path, opts.baseUrl);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Bare `new URL()` errors ("Invalid URL") carry no context — surface
|
|
143
|
+
// which baseUrl actually failed, since this is exactly what a malformed
|
|
144
|
+
// `redirect_host`/`baseurl` from iLink looks like (see normalizeIlinkHost).
|
|
145
|
+
throw new Error(`iLink ${opts.method} ${opts.path}: invalid baseUrl "${opts.baseUrl}"`);
|
|
146
|
+
}
|
|
147
|
+
if (opts.query) {
|
|
148
|
+
for (const [k, v] of Object.entries(opts.query))
|
|
149
|
+
url.searchParams.set(k, v);
|
|
150
|
+
}
|
|
151
|
+
const headers = {
|
|
152
|
+
'iLink-App-Id': 'bot',
|
|
153
|
+
'iLink-App-ClientVersion': CLIENT_VERSION_HEADER,
|
|
154
|
+
// Force a fresh connection per request instead of reusing Node's
|
|
155
|
+
// undici keep-alive pool. Confirmed root cause, sourced from a second
|
|
156
|
+
// independent reference implementation (Hermes-agent's
|
|
157
|
+
// `gateway/platforms/weixin.py`, `_make_ssl_connector()`): "proxies like
|
|
158
|
+
// Cloudflare Warp leave peer-initiated FIN in CLOSE_WAIT" — a pooled
|
|
159
|
+
// keep-alive connection can go stale (closed server-side) without the
|
|
160
|
+
// client noticing, and a later request reusing it just hangs/fails.
|
|
161
|
+
// Hermes-agent works around this with a 2s keepalive_timeout; the
|
|
162
|
+
// simpler equivalent here is to never keep the connection alive at all.
|
|
163
|
+
Connection: 'close',
|
|
164
|
+
};
|
|
165
|
+
// Auth headers are sent for the QR POST too (it still identifies the
|
|
166
|
+
// client), but NOT for the unauthenticated GET status-poll — see
|
|
167
|
+
// protocol.md's "Before auth (QR polling)" header table.
|
|
168
|
+
if (opts.method === 'POST') {
|
|
169
|
+
headers['Content-Type'] = 'application/json';
|
|
170
|
+
headers['AuthorizationType'] = 'ilink_bot_token';
|
|
171
|
+
headers['X-WECHAT-UIN'] = randomWechatUin();
|
|
172
|
+
}
|
|
173
|
+
if (opts.botToken)
|
|
174
|
+
headers['Authorization'] = `Bearer ${opts.botToken}`;
|
|
175
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
178
|
+
let res;
|
|
179
|
+
try {
|
|
180
|
+
res = await fetch(url.toString(), {
|
|
181
|
+
method: opts.method,
|
|
182
|
+
headers,
|
|
183
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
184
|
+
signal: controller.signal,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (err.name === 'AbortError') {
|
|
189
|
+
throw new Error(`iLink ${opts.method} ${opts.path}: timed out after ${timeoutMs}ms`);
|
|
190
|
+
}
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
throw new Error(`iLink ${opts.method} ${opts.path}: HTTP ${res.status}`);
|
|
198
|
+
}
|
|
199
|
+
return (await res.json());
|
|
200
|
+
}
|
|
201
|
+
/** Extract the first text item's body — image items are handled separately by resolveWeixinImageRef; voice/file/video are still ignored. */
|
|
202
|
+
function textFromItems(items) {
|
|
203
|
+
return items?.find((i) => i.type === 1)?.text_item?.text;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Hosts Tencent actually serves inbound-media downloads from, per
|
|
207
|
+
* `NousResearch/hermes-agent`'s `gateway/platforms/weixin.py`
|
|
208
|
+
* (`_WEIXIN_CDN_ALLOWLIST`, confirmed live 2026-09-11). Only checked against
|
|
209
|
+
* a server-supplied `full_url` — a URL built from `encrypt_query_param`
|
|
210
|
+
* against `DEFAULT_CDN_BASE_URL` below doesn't need it (that host is our own
|
|
211
|
+
* trusted constant). This is an SSRF guard: without it, a malicious/buggy
|
|
212
|
+
* `full_url` could point this server's own outbound fetch at an arbitrary
|
|
213
|
+
* internal host.
|
|
214
|
+
*/
|
|
215
|
+
const WEIXIN_CDN_ALLOWLIST = new Set([
|
|
216
|
+
'novac2c.cdn.weixin.qq.com',
|
|
217
|
+
'ilinkai.weixin.qq.com',
|
|
218
|
+
'wx.qlogo.cn',
|
|
219
|
+
'thirdwx.qlogo.cn',
|
|
220
|
+
'res.wx.qq.com',
|
|
221
|
+
'mmbiz.qpic.cn',
|
|
222
|
+
'mmbiz.qlogo.cn',
|
|
223
|
+
// Confirmed live 2026-09-11: real `image_item.media.full_url` responses use
|
|
224
|
+
// the `.wechat.com` domain family, not `.weixin.qq.com` — same mismatch
|
|
225
|
+
// class as `redirect_host` earlier this session (docs/Hermes-agent's own
|
|
226
|
+
// allowlist assumed `.weixin.qq.com`; our own post-link `baseUrl` for
|
|
227
|
+
// authenticated calls is ALSO `ilinkai.wechat.com`, confirming this is the
|
|
228
|
+
// real production domain, not a one-off).
|
|
229
|
+
'novac2c.cdn.wechat.com',
|
|
230
|
+
'ilinkai.wechat.com',
|
|
231
|
+
]);
|
|
232
|
+
const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
233
|
+
/**
|
|
234
|
+
* Decode an inbound AES key from its `media.aes_key` (base64) encoding.
|
|
235
|
+
* protocol.md: "the download decoder accepts base64 of either 16 raw bytes
|
|
236
|
+
* or a 32-character hexadecimal key" — both shapes are used in practice.
|
|
237
|
+
*/
|
|
238
|
+
function parseAesKeyBase64(aesKeyB64) {
|
|
239
|
+
const decoded = Buffer.from(aesKeyB64, 'base64');
|
|
240
|
+
if (decoded.length === 16)
|
|
241
|
+
return decoded;
|
|
242
|
+
if (decoded.length === 32) {
|
|
243
|
+
const text = decoded.toString('ascii');
|
|
244
|
+
if (/^[0-9a-fA-F]{32}$/.test(text))
|
|
245
|
+
return Buffer.from(text, 'hex');
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`iLink image: unrecognized aes_key encoding (${decoded.length} bytes decoded)`);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Shared URL+key resolution for any media item's `media` block. `rawHexKey`
|
|
251
|
+
* is the item-type-specific top-level key field when one exists (only
|
|
252
|
+
* `image_item.aeskey` per protocol.md — files have no equivalent, so callers
|
|
253
|
+
* for other item types pass `undefined`).
|
|
254
|
+
*/
|
|
255
|
+
function resolveMediaRef(media, rawHexKey) {
|
|
256
|
+
let aesKey;
|
|
257
|
+
if (rawHexKey && /^[0-9a-fA-F]{32}$/.test(rawHexKey)) {
|
|
258
|
+
aesKey = Buffer.from(rawHexKey, 'hex');
|
|
259
|
+
}
|
|
260
|
+
else if (media?.aes_key) {
|
|
261
|
+
aesKey = parseAesKeyBase64(media.aes_key);
|
|
262
|
+
}
|
|
263
|
+
let url;
|
|
264
|
+
if (media?.full_url) {
|
|
265
|
+
const host = new URL(media.full_url).hostname;
|
|
266
|
+
if (!WEIXIN_CDN_ALLOWLIST.has(host)) {
|
|
267
|
+
throw new Error(`iLink media: refusing to download from non-allowlisted host "${host}"`);
|
|
268
|
+
}
|
|
269
|
+
url = media.full_url;
|
|
270
|
+
}
|
|
271
|
+
else if (media?.encrypt_query_param) {
|
|
272
|
+
url = `${DEFAULT_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}`;
|
|
273
|
+
}
|
|
274
|
+
return url ? { url, aesKey } : undefined;
|
|
275
|
+
}
|
|
276
|
+
function resolveWeixinImageRef(items) {
|
|
277
|
+
const item = items?.find((i) => i.type === 2 && i.image_item);
|
|
278
|
+
if (!item?.image_item)
|
|
279
|
+
return undefined;
|
|
280
|
+
return resolveMediaRef(item.image_item.media, item.image_item.aeskey);
|
|
281
|
+
}
|
|
282
|
+
/** Resolve an inbound file item (type=4) — protocol.md has no top-level aeskey for files, only media.aes_key. */
|
|
283
|
+
function resolveWeixinFileRef(items) {
|
|
284
|
+
const item = items?.find((i) => i.type === 4 && i.file_item);
|
|
285
|
+
if (!item?.file_item)
|
|
286
|
+
return undefined;
|
|
287
|
+
const ref = resolveMediaRef(item.file_item.media, undefined);
|
|
288
|
+
if (!ref)
|
|
289
|
+
return undefined;
|
|
290
|
+
const expectedLength = item.file_item.len ? parseInt(item.file_item.len, 10) : undefined;
|
|
291
|
+
return {
|
|
292
|
+
...ref,
|
|
293
|
+
fileName: item.file_item.file_name || 'file',
|
|
294
|
+
expectedLength: Number.isFinite(expectedLength) ? expectedLength : undefined,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* AES-128-ECB decrypt with PERMISSIVE PKCS#7 unpadding: if the trailing
|
|
299
|
+
* padding doesn't validate, return the padded bytes as-is instead of
|
|
300
|
+
* throwing. Mirrors `NousResearch/hermes-agent`'s `_aes128_ecb_decrypt`
|
|
301
|
+
* exactly (confirmed live 2026-09-11) — Node's built-in auto-unpad
|
|
302
|
+
* (`setAutoPadding(true)`) throws "bad decrypt" on the same real-world edge
|
|
303
|
+
* cases Hermes-agent's own implementation was written to tolerate.
|
|
304
|
+
*/
|
|
305
|
+
function aes128EcbDecryptPermissive(ciphertext, key) {
|
|
306
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
307
|
+
decipher.setAutoPadding(false);
|
|
308
|
+
const padded = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
309
|
+
if (padded.length === 0)
|
|
310
|
+
return padded;
|
|
311
|
+
const padLen = padded[padded.length - 1];
|
|
312
|
+
if (padLen >= 1 && padLen <= 16 && padded.length >= padLen) {
|
|
313
|
+
const tail = padded.subarray(padded.length - padLen);
|
|
314
|
+
if (tail.every((b) => b === padLen))
|
|
315
|
+
return padded.subarray(0, padded.length - padLen);
|
|
316
|
+
}
|
|
317
|
+
return padded;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Download (and decrypt, if `ref.aesKey` is set) an inbound image's bytes.
|
|
321
|
+
* No bot-token auth — protocol.md documents this as a plain CDN GET against
|
|
322
|
+
* a pre-signed/scoped URL.
|
|
323
|
+
*/
|
|
324
|
+
/** 20 MB — matches MediaStore.maxUploadBytes (not imported directly: ilink-client is a
|
|
325
|
+
* protocol-layer module and shouldn't depend on history/media-store's storage layer). */
|
|
326
|
+
const DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024;
|
|
327
|
+
async function downloadWeixinImage(ref, opts = {}) {
|
|
328
|
+
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
329
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES;
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
332
|
+
let res;
|
|
333
|
+
try {
|
|
334
|
+
// redirect: 'manual' — WEIXIN_CDN_ALLOWLIST only validates ref.url's OWN
|
|
335
|
+
// host; fetch()'s default 'follow' would silently chase a redirect to
|
|
336
|
+
// any other host, defeating that check as an SSRF guard. A redirect
|
|
337
|
+
// response here is treated as a hard failure rather than re-validated,
|
|
338
|
+
// since the legitimate CDN paths (both the trusted default base and an
|
|
339
|
+
// allowlisted full_url) are not expected to redirect at all.
|
|
340
|
+
res = await fetch(ref.url, { signal: controller.signal, redirect: 'manual' });
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
}
|
|
345
|
+
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
|
346
|
+
throw new Error('iLink media download: refusing to follow a redirect (SSRF guard)');
|
|
347
|
+
}
|
|
348
|
+
if (!res.ok) {
|
|
349
|
+
throw new Error(`iLink image download: HTTP ${res.status}`);
|
|
350
|
+
}
|
|
351
|
+
// Reject on the declared size before buffering the body when the server
|
|
352
|
+
// tells us upfront (LINE's webhook router enforces the same cap the same
|
|
353
|
+
// way — see MAX_MEDIA_BYTES's declaredSize check there).
|
|
354
|
+
const declaredLength = Number(res.headers?.get?.('content-length'));
|
|
355
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
356
|
+
throw new Error(`iLink media download: declared size ${declaredLength} exceeds ${maxBytes} byte cap`);
|
|
357
|
+
}
|
|
358
|
+
const raw = Buffer.from(await res.arrayBuffer());
|
|
359
|
+
if (raw.length > maxBytes) {
|
|
360
|
+
throw new Error(`iLink media download: ${raw.length} bytes exceeds ${maxBytes} byte cap`);
|
|
361
|
+
}
|
|
362
|
+
const decrypted = ref.aesKey ? aes128EcbDecryptPermissive(raw, ref.aesKey) : raw;
|
|
363
|
+
// See ILinkImageRef.expectedLength's doc comment — trims a leading prefix
|
|
364
|
+
// WeChat's file-upload path adds ahead of the real content.
|
|
365
|
+
if (ref.expectedLength !== undefined && decrypted.length > ref.expectedLength) {
|
|
366
|
+
return decrypted.subarray(decrypted.length - ref.expectedLength);
|
|
367
|
+
}
|
|
368
|
+
return decrypted;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Real implementation — talks to Tencent's iLink Bot API. See this module's
|
|
372
|
+
* doc comment for the two documented ambiguities (`qrcode_img_content`'s
|
|
373
|
+
* encoding, and the unhandled verification-code statuses) still to confirm
|
|
374
|
+
* against a real account.
|
|
375
|
+
*/
|
|
376
|
+
function createILinkClient(baseUrl, botAgent) {
|
|
377
|
+
const qrBaseUrl = baseUrl || DEFAULT_ILINK_BASE_URL;
|
|
378
|
+
const resolvedBotAgent = botAgent || DEFAULT_BOT_AGENT;
|
|
379
|
+
const clientBaseInfo = () => ({
|
|
380
|
+
channel_version: CHANNEL_VERSION,
|
|
381
|
+
bot_agent: resolvedBotAgent,
|
|
382
|
+
});
|
|
383
|
+
// getupdates' cursor must survive across polls — scoped per accountId so
|
|
384
|
+
// one client instance could in principle serve more than one credential
|
|
385
|
+
// set, even though v1 only ever uses it for a single linked account.
|
|
386
|
+
const updateCursors = new Map();
|
|
387
|
+
// Which host to poll get_qrcode_status on next, per in-flight login
|
|
388
|
+
// attempt — starts at qrBaseUrl, switches once `redirect_host` is seen
|
|
389
|
+
// (see GetQrcodeStatusResponse's doc comment on that field for why this
|
|
390
|
+
// exists at all).
|
|
391
|
+
const qrStatusHosts = new Map();
|
|
392
|
+
return {
|
|
393
|
+
async requestLinkQr() {
|
|
394
|
+
const res = await ilinkFetch({
|
|
395
|
+
method: 'POST',
|
|
396
|
+
path: '/ilink/bot/get_bot_qrcode',
|
|
397
|
+
query: { bot_type: '3' },
|
|
398
|
+
body: { local_token_list: [] },
|
|
399
|
+
baseUrl: qrBaseUrl,
|
|
400
|
+
});
|
|
401
|
+
return { qrDataUri: await normalizeQrImage(res.qrcode_img_content), loginSessionId: res.qrcode };
|
|
402
|
+
},
|
|
403
|
+
async pollLinkStatus(loginSessionId) {
|
|
404
|
+
const pollHost = qrStatusHosts.get(loginSessionId) ?? qrBaseUrl;
|
|
405
|
+
const res = await ilinkFetch({
|
|
406
|
+
method: 'GET',
|
|
407
|
+
path: '/ilink/bot/get_qrcode_status',
|
|
408
|
+
query: { qrcode: loginSessionId },
|
|
409
|
+
baseUrl: pollHost,
|
|
410
|
+
});
|
|
411
|
+
if (res.status === 'scaned_but_redirect' && res.redirect_host) {
|
|
412
|
+
qrStatusHosts.set(loginSessionId, normalizeIlinkHost(res.redirect_host));
|
|
413
|
+
}
|
|
414
|
+
if (res.status === 'confirmed' && res.bot_token && res.ilink_bot_id) {
|
|
415
|
+
qrStatusHosts.delete(loginSessionId);
|
|
416
|
+
return {
|
|
417
|
+
linked: true,
|
|
418
|
+
status: res.status,
|
|
419
|
+
credentials: {
|
|
420
|
+
accountId: res.ilink_bot_id,
|
|
421
|
+
token: res.bot_token,
|
|
422
|
+
baseUrl: res.baseurl ? normalizeIlinkHost(res.baseurl) : qrBaseUrl,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// `need_verifycode`/`verify_code_blocked` have no UI path yet (see
|
|
427
|
+
// module doc comment) — surface as "still not linked" so the caller's
|
|
428
|
+
// own attempt-timeout eventually reports back to the user, rather than
|
|
429
|
+
// silently hanging on a status this client can't act on.
|
|
430
|
+
return { linked: false, status: res.status };
|
|
431
|
+
},
|
|
432
|
+
async getUpdates(creds, _timeoutSeconds) {
|
|
433
|
+
const cursor = updateCursors.get(creds.accountId) ?? '';
|
|
434
|
+
const res = await ilinkFetch({
|
|
435
|
+
method: 'POST',
|
|
436
|
+
path: '/ilink/bot/getupdates',
|
|
437
|
+
body: { get_updates_buf: cursor, base_info: clientBaseInfo() },
|
|
438
|
+
botToken: creds.token,
|
|
439
|
+
baseUrl: creds.baseUrl,
|
|
440
|
+
// iLink's own long-poll legitimately blocks up to _timeoutSeconds —
|
|
441
|
+
// give it headroom above that instead of the 15s default meant for
|
|
442
|
+
// quick request/response calls.
|
|
443
|
+
timeoutMs: _timeoutSeconds * 1000 + 10000,
|
|
444
|
+
});
|
|
445
|
+
if (res.get_updates_buf !== undefined)
|
|
446
|
+
updateCursors.set(creds.accountId, res.get_updates_buf);
|
|
447
|
+
// `ret` is OMITTED entirely on a successful response (confirmed live
|
|
448
|
+
// 2026-09-10 — every real getupdates success we captured had no `ret`
|
|
449
|
+
// field at all, just `msgs`/`get_updates_buf`), matching protocol.md's
|
|
450
|
+
// explicit rule for sendMessage ("an absent ret does not trigger this
|
|
451
|
+
// check") which applies the same way here. The old `res.ret !== 0`
|
|
452
|
+
// check was true for `undefined` too, so it threw "ret=undefined" on
|
|
453
|
+
// every single successful call — the actual reason messages we KNEW
|
|
454
|
+
// existed (confirmed via a raw curl to the same account) never made it
|
|
455
|
+
// through this client.
|
|
456
|
+
if (res.ret !== undefined && res.ret !== 0) {
|
|
457
|
+
throw new Error(`iLink getupdates failed: ret=${res.ret} errmsg=${res.errmsg ?? 'unknown'}`);
|
|
458
|
+
}
|
|
459
|
+
return (res.msgs ?? [])
|
|
460
|
+
// message_type 2 = the bot's own messages echoed back — never a real
|
|
461
|
+
// inbound message, so treating them as one would make the agent
|
|
462
|
+
// reply to itself.
|
|
463
|
+
.filter((m) => m.message_type === 1)
|
|
464
|
+
.map((m) => {
|
|
465
|
+
let image;
|
|
466
|
+
let file;
|
|
467
|
+
try {
|
|
468
|
+
image = resolveWeixinImageRef(m.item_list);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// A malformed item or a non-allowlisted full_url (SSRF guard)
|
|
472
|
+
// must not drop the whole batch — this one message just arrives
|
|
473
|
+
// without its image, same as any other undeliverable-media case.
|
|
474
|
+
image = undefined;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
file = resolveWeixinFileRef(m.item_list);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
file = undefined;
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
id: m.message_id,
|
|
484
|
+
fromId: m.from_user_id,
|
|
485
|
+
text: textFromItems(m.item_list),
|
|
486
|
+
timestamp: m.create_time_ms,
|
|
487
|
+
contextToken: m.context_token,
|
|
488
|
+
image,
|
|
489
|
+
file,
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
async sendText(creds, toId, text, contextToken) {
|
|
494
|
+
const res = await ilinkFetch({
|
|
495
|
+
method: 'POST',
|
|
496
|
+
path: '/ilink/bot/sendmessage',
|
|
497
|
+
body: {
|
|
498
|
+
msg: {
|
|
499
|
+
from_user_id: '',
|
|
500
|
+
to_user_id: toId,
|
|
501
|
+
// A fixed prefix, not resolvedBotAgent: this is just a dedup/
|
|
502
|
+
// idempotency token, not user-facing, and a deployer's own
|
|
503
|
+
// botAgent value isn't guaranteed to be ID-safe (arbitrary
|
|
504
|
+
// characters, unlike the sanitized UA-style string protocol.md
|
|
505
|
+
// describes for bot_agent itself, which this gateway doesn't
|
|
506
|
+
// enforce on its own botAgent config field).
|
|
507
|
+
client_id: `${DEFAULT_BOT_AGENT}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
508
|
+
message_type: 2,
|
|
509
|
+
message_state: 2,
|
|
510
|
+
context_token: contextToken ?? '',
|
|
511
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
512
|
+
},
|
|
513
|
+
base_info: clientBaseInfo(),
|
|
514
|
+
},
|
|
515
|
+
botToken: creds.token,
|
|
516
|
+
baseUrl: creds.baseUrl,
|
|
517
|
+
});
|
|
518
|
+
// Same fix as getUpdates above — confirmed live: a successful
|
|
519
|
+
// sendmessage response is just `{"message_id": ...}`, no `ret` field
|
|
520
|
+
// at all. Per protocol.md: "A non-zero ret throws; an absent ret does
|
|
521
|
+
// not trigger this check."
|
|
522
|
+
if (res.ret !== undefined && res.ret !== 0) {
|
|
523
|
+
throw new Error(`iLink sendmessage failed: ret=${res.ret} errmsg=${res.errmsg ?? 'unknown'}`);
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async notifyStart(creds) {
|
|
527
|
+
await ilinkFetch({
|
|
528
|
+
method: 'POST',
|
|
529
|
+
path: '/ilink/bot/msg/notifystart',
|
|
530
|
+
body: { base_info: clientBaseInfo() },
|
|
531
|
+
botToken: creds.token,
|
|
532
|
+
baseUrl: creds.baseUrl,
|
|
533
|
+
timeoutMs: 10000,
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
async notifyStop(creds) {
|
|
537
|
+
await ilinkFetch({
|
|
538
|
+
method: 'POST',
|
|
539
|
+
path: '/ilink/bot/msg/notifystop',
|
|
540
|
+
body: { base_info: clientBaseInfo() },
|
|
541
|
+
botToken: creds.token,
|
|
542
|
+
baseUrl: creds.baseUrl,
|
|
543
|
+
timeoutMs: 10000,
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** A deterministic checkerboard SVG, styled to read as "a QR code" at a glance
|
|
549
|
+
* without a real QR-encoding dependency — this fake client never needs to be
|
|
550
|
+
* actually scanned. */
|
|
551
|
+
function fakeQrDataUri(seed) {
|
|
552
|
+
const size = 21;
|
|
553
|
+
let hash = 0;
|
|
554
|
+
for (let i = 0; i < seed.length; i++)
|
|
555
|
+
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
|
556
|
+
let cells = '';
|
|
557
|
+
for (let y = 0; y < size; y++) {
|
|
558
|
+
for (let x = 0; x < size; x++) {
|
|
559
|
+
// Corner "finder" squares, like a real QR code, plus a pseudo-random fill
|
|
560
|
+
// elsewhere so every fake session looks visually distinct.
|
|
561
|
+
const inFinder = (x < 7 && y < 7) || (x >= size - 7 && y < 7) || (x < 7 && y >= size - 7);
|
|
562
|
+
hash = (hash * 1103515245 + 12345) >>> 0;
|
|
563
|
+
const on = inFinder ? (x % 6 !== 3 && y % 6 !== 3) : hash % 2 === 0;
|
|
564
|
+
if (on)
|
|
565
|
+
cells += `<rect x="${x}" y="${y}" width="1" height="1"/>`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" fill="#000">` +
|
|
569
|
+
`<rect width="${size}" height="${size}" fill="#fff"/>${cells}</svg>`;
|
|
570
|
+
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Local-testing-only fake client — no network calls, no real iLink account
|
|
574
|
+
* needed. Simulates a QR scan completing after a few polls, then occasionally
|
|
575
|
+
* delivers one fake inbound message (carrying its own fake `contextToken`,
|
|
576
|
+
* exactly like the real protocol) so the pending-sender/access-control UI
|
|
577
|
+
* has something to show. Never wired up by default — see
|
|
578
|
+
* `isWeChatILinkFakeEnabled()` and its call site in AgentRunner.
|
|
579
|
+
*
|
|
580
|
+
* NOT a substitute for the real API contract — exists purely so the apps/web
|
|
581
|
+
* card's UI/UX (QR render, link→linked transition, DM allowlist, disconnect)
|
|
582
|
+
* can be manually verified end-to-end without a real WeChat account.
|
|
583
|
+
*/
|
|
584
|
+
function createFakeILinkClient() {
|
|
585
|
+
let pollCount = 0;
|
|
586
|
+
let updateCount = 0;
|
|
587
|
+
return {
|
|
588
|
+
async requestLinkQr() {
|
|
589
|
+
pollCount = 0;
|
|
590
|
+
const loginSessionId = `fake-session-${Date.now()}`;
|
|
591
|
+
return { qrDataUri: fakeQrDataUri(loginSessionId), loginSessionId };
|
|
592
|
+
},
|
|
593
|
+
async pollLinkStatus(loginSessionId) {
|
|
594
|
+
pollCount += 1;
|
|
595
|
+
// ~3 polls at the manager's 2s interval ⇒ linked after ~6s, long enough
|
|
596
|
+
// to see the QR/"Waiting for scan…" state before it resolves.
|
|
597
|
+
if (pollCount < 3)
|
|
598
|
+
return { linked: false };
|
|
599
|
+
return {
|
|
600
|
+
linked: true,
|
|
601
|
+
credentials: {
|
|
602
|
+
accountId: 'fake-account',
|
|
603
|
+
token: `fake-token-${loginSessionId}`,
|
|
604
|
+
baseUrl: 'fake://local-test',
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
},
|
|
608
|
+
async getUpdates(_creds, timeoutSeconds) {
|
|
609
|
+
// Real iLink blocks up to `timeoutSeconds`; a short fixed sleep here
|
|
610
|
+
// keeps manual testing responsive instead of waiting the full 35s.
|
|
611
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(timeoutSeconds, 4) * 1000));
|
|
612
|
+
updateCount += 1;
|
|
613
|
+
// One fake message shortly after linking, then quiet — enough to
|
|
614
|
+
// exercise the pending-sender/allowlist flow without spamming.
|
|
615
|
+
if (updateCount === 2) {
|
|
616
|
+
return [
|
|
617
|
+
{
|
|
618
|
+
id: `fake-msg-${Date.now()}`,
|
|
619
|
+
fromId: 'fake-tester',
|
|
620
|
+
text: 'Hi, this is a fake WeChat test message — approve me to keep chatting!',
|
|
621
|
+
displayName: 'Fake Tester',
|
|
622
|
+
timestamp: Date.now(),
|
|
623
|
+
contextToken: 'fake-context-token',
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
return [];
|
|
628
|
+
},
|
|
629
|
+
async sendText(_creds, toId, text, contextToken) {
|
|
630
|
+
// eslint-disable-next-line no-console -- local-testing-only visibility, not production logging
|
|
631
|
+
console.log(`[fake-ilink] would send to ${toId} (ctx=${contextToken ?? 'none'}): ${text}`);
|
|
632
|
+
},
|
|
633
|
+
async notifyStart() { },
|
|
634
|
+
async notifyStop() { },
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/** Opt-in only, local dev/manual-testing use — see createFakeILinkClient's doc comment. */
|
|
638
|
+
function isWeChatILinkFakeEnabled() {
|
|
639
|
+
return process.env.WECHAT_ILINK_FAKE === 'true';
|
|
640
|
+
}
|
|
641
|
+
//# sourceMappingURL=ilink-client.js.map
|