@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.
@@ -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