@nanhara/hara 0.70.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,590 @@
1
+ // WeChat (personal) adapter for `hara gateway`, via Tencent's official iLink bot API
2
+ // (ilinkai.weixin.qq.com). Text-DM happy path: QR login → long-poll getupdates → sendmessage.
3
+ // Ported from the documented iLink wire protocol. Built-in fetch; QR rendering uses the optional
4
+ // `qrcode-terminal` dep (graceful fallback to printing the URL). No encryption is needed on the text path
5
+ // (iLink only uses crypto for media upload/download, which v1 doesn't do).
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { basename } from "node:path";
10
+ import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
11
+ import { chunkText } from "./telegram.js";
12
+ const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
13
+ const CHANNEL_VERSION = "2.2.0";
14
+ const ILINK_APP_ID = "bot";
15
+ const ILINK_APP_CLIENT_VERSION = String((2 << 16) | (2 << 8) | 0); // 131584
16
+ const WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
17
+ const EP = {
18
+ getUpdates: "ilink/bot/getupdates",
19
+ sendMessage: "ilink/bot/sendmessage",
20
+ getBotQr: "ilink/bot/get_bot_qrcode",
21
+ getQrStatus: "ilink/bot/get_qrcode_status",
22
+ getUploadUrl: "ilink/bot/getuploadurl",
23
+ };
24
+ const LONG_POLL_TIMEOUT_MS = 35_000;
25
+ const API_TIMEOUT_MS = 15_000;
26
+ const QR_TIMEOUT_MS = 35_000;
27
+ const SESSION_EXPIRED = -14;
28
+ const RATE_LIMIT = -2;
29
+ const MSG_TYPE_BOT = 2;
30
+ const MSG_STATE_FINISH = 2;
31
+ const ITEM_TEXT = 1;
32
+ const ITEM_IMAGE = 2;
33
+ const ITEM_VOICE = 3;
34
+ const ITEM_FILE = 4; // item.type for a file attachment (audio is sent as a file — iLink voice bubbles unreliable)
35
+ const MEDIA_IMAGE = 1; // media_type for getuploadurl when sending an image (inline)
36
+ const MEDIA_FILE = 3; // media_type for getuploadurl when sending a file/audio
37
+ const WEIXIN_CDN_ALLOWLIST = new Set([
38
+ "novac2c.cdn.weixin.qq.com",
39
+ "ilinkai.weixin.qq.com",
40
+ "wx.qlogo.cn",
41
+ "thirdwx.qlogo.cn",
42
+ "res.wx.qq.com",
43
+ "mmbiz.qpic.cn",
44
+ "mmbiz.qlogo.cn",
45
+ ]);
46
+ const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
47
+ const INBOUND_MEDIA_MAX = 128 * 1024 * 1024; // 128 MiB cap, per the reference
48
+ const num = (v) => (typeof v === "number" ? v : 0);
49
+ const str = (v) => (v == null ? "" : String(v));
50
+ // ── pure protocol helpers (unit-tested) ──────────────────────────────────────
51
+ /** X-WECHAT-UIN: base64 of the decimal string of a random uint32 (regenerated per request). */
52
+ export function randomWechatUin() {
53
+ const v = randomBytes(4).readUInt32BE(0);
54
+ return Buffer.from(String(v), "utf-8").toString("base64");
55
+ }
56
+ function ilinkHeaders(token, body) {
57
+ const h = {
58
+ "Content-Type": "application/json",
59
+ AuthorizationType: "ilink_bot_token",
60
+ "Content-Length": String(Buffer.byteLength(body, "utf-8")),
61
+ "X-WECHAT-UIN": randomWechatUin(),
62
+ "iLink-App-Id": ILINK_APP_ID,
63
+ "iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION,
64
+ };
65
+ if (token)
66
+ h.Authorization = `Bearer ${token}`;
67
+ return h;
68
+ }
69
+ /** Every POST body is compact JSON with base_info merged in. */
70
+ export function envelope(payload) {
71
+ return JSON.stringify({ ...payload, base_info: { channel_version: CHANNEL_VERSION } });
72
+ }
73
+ /** The `sendmessage` body for a text DM (context_token omitted when falsy). */
74
+ export function buildSendBody(to, text, contextToken, clientId) {
75
+ const msg = {
76
+ from_user_id: "",
77
+ to_user_id: to,
78
+ client_id: clientId,
79
+ message_type: MSG_TYPE_BOT,
80
+ message_state: MSG_STATE_FINISH,
81
+ item_list: [{ type: ITEM_TEXT, text_item: { text } }],
82
+ };
83
+ if (contextToken)
84
+ msg.context_token = contextToken;
85
+ return { msg };
86
+ }
87
+ /** iLink wants the AES key as base64 of the key's HEX-string ASCII bytes — NOT base64 of the raw key bytes.
88
+ * (Getting this wrong = the receiver can't decrypt → unplayable/grey media.) */
89
+ export function apiAesKey(keyHex) {
90
+ return Buffer.from(keyHex, "ascii").toString("base64");
91
+ }
92
+ /** The sendmessage item for a file attachment (audio/zip/pdf/doc/… — anything not shown inline). */
93
+ export function audioFileItem(encryptQueryParam, aesKeyForApi, rawsize, filename) {
94
+ return {
95
+ type: ITEM_FILE,
96
+ file_item: {
97
+ media: { encrypt_query_param: encryptQueryParam, aes_key: aesKeyForApi, encrypt_type: 1 },
98
+ file_name: filename,
99
+ len: String(rawsize), // plaintext size, as a STRING (a file_item quirk — image uses ciphertext size as int)
100
+ },
101
+ };
102
+ }
103
+ /** The sendmessage item for an inline image (shows in the chat, not as a file). mid_size = CIPHERTEXT size (int). */
104
+ export function imageInlineItem(encryptQueryParam, aesKeyForApi, ciphertextSize) {
105
+ return {
106
+ type: ITEM_IMAGE,
107
+ image_item: {
108
+ media: { encrypt_query_param: encryptQueryParam, aes_key: aesKeyForApi, encrypt_type: 1 },
109
+ mid_size: ciphertextSize,
110
+ },
111
+ };
112
+ }
113
+ /** First text item's text (iLink puts text under item_list[].text_item.text; voice falls back to type 3). */
114
+ export function extractText(itemList) {
115
+ if (!Array.isArray(itemList))
116
+ return "";
117
+ for (const item of itemList) {
118
+ if (item?.type === ITEM_TEXT)
119
+ return str(item?.text_item?.text);
120
+ if (item?.type === 3)
121
+ return str(item?.voice_item?.text);
122
+ }
123
+ return "";
124
+ }
125
+ /** DM vs group: a room id (or a non-self to_user with msg_type 1) means group; else DM keyed by from_user_id. */
126
+ export function guessChatType(msg, accountId) {
127
+ const roomId = str(msg?.room_id ?? msg?.chat_room_id).trim();
128
+ const toUserId = str(msg?.to_user_id).trim();
129
+ const isGroup = !!roomId || (!!toUserId && !!accountId && toUserId !== accountId && msg?.msg_type === 1);
130
+ if (isGroup)
131
+ return { kind: "group", id: roomId || toUserId || str(msg?.from_user_id) };
132
+ return { kind: "dm", id: str(msg?.from_user_id) };
133
+ }
134
+ /** Parse one inbound iLink message → InboundMsg + its context_token. null = skip (own echo / group / non-text). */
135
+ export function parseWeixinMessage(msg, accountId) {
136
+ const from = str(msg?.from_user_id).trim();
137
+ if (!from || from === accountId)
138
+ return null; // missing sender, or our own echo
139
+ if (guessChatType(msg, accountId).kind !== "dm")
140
+ return null; // v1 = text DM only
141
+ const items = Array.isArray(msg?.item_list) ? msg.item_list : [];
142
+ const text = extractText(items);
143
+ if (!text)
144
+ return null;
145
+ // A voice message arrives as iLink's server-side transcription (voice_item.text). Prefix an explicit note —
146
+ // NOT a bare "[voice message]" label, which hara misreads as raw audio it must process (and then disclaims).
147
+ // This tells it the text is already transcribed so it just answers.
148
+ const isVoice = !items.some((i) => i?.type === ITEM_TEXT) && items.some((i) => i?.type === 3);
149
+ const tagged = isVoice ? `(The user sent this as a voice message; it is already transcribed to text below — just reply to it normally, you don't have or need the audio.)\n\n${text}` : text;
150
+ return { inbound: { chatId: from, userId: from, userName: from, text: tagged }, contextToken: str(msg?.context_token).trim() };
151
+ }
152
+ /** iLink signals expiry via -14, or -2 + errmsg "unknown error" (a stale-session masquerading as rate-limit). */
153
+ export function isSessionExpired(ret, errcode, errmsg) {
154
+ if (ret === SESSION_EXPIRED || errcode === SESSION_EXPIRED)
155
+ return true;
156
+ if (ret !== RATE_LIMIT && errcode !== RATE_LIMIT)
157
+ return false;
158
+ return (errmsg || "").toLowerCase() === "unknown error";
159
+ }
160
+ // ── HTTP + state ──────────────────────────────────────────────────────────────
161
+ const sleep = (ms, signal) => new Promise((res) => {
162
+ if (signal?.aborted)
163
+ return res();
164
+ const t = setTimeout(() => res(), ms);
165
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); res(); }, { once: true });
166
+ });
167
+ function combineSignals(timeoutMs, external) {
168
+ const timeout = AbortSignal.timeout(timeoutMs);
169
+ const anyFn = AbortSignal.any;
170
+ return external && typeof anyFn === "function" ? anyFn([timeout, external]) : timeout;
171
+ }
172
+ async function apiPost(baseUrl, endpoint, payload, token, timeoutMs, external) {
173
+ const body = envelope(payload);
174
+ const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
175
+ const res = await fetch(url, { method: "POST", body, headers: ilinkHeaders(token, body), signal: combineSignals(timeoutMs, external) });
176
+ const raw = await res.text();
177
+ if (!res.ok)
178
+ throw new Error(`iLink POST ${endpoint} HTTP ${res.status}: ${raw.slice(0, 200)}`);
179
+ return JSON.parse(raw);
180
+ }
181
+ async function apiGet(baseUrl, endpoint, timeoutMs) {
182
+ const url = `${baseUrl.replace(/\/+$/, "")}/${endpoint}`;
183
+ const headers = { "iLink-App-Id": ILINK_APP_ID, "iLink-App-ClientVersion": ILINK_APP_CLIENT_VERSION };
184
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
185
+ const raw = await res.text();
186
+ if (!res.ok)
187
+ throw new Error(`iLink GET ${endpoint} HTTP ${res.status}: ${raw.slice(0, 200)}`);
188
+ return JSON.parse(raw);
189
+ }
190
+ const weixinDir = () => join(homedir(), ".hara", "weixin");
191
+ const credsFile = () => join(weixinDir(), "creds.json");
192
+ export function loadWeixinCreds() {
193
+ try {
194
+ return existsSync(credsFile()) ? JSON.parse(readFileSync(credsFile(), "utf8")) : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ function saveWeixinCreds(c) {
201
+ mkdirSync(weixinDir(), { recursive: true });
202
+ writeFileSync(credsFile(), JSON.stringify(c, null, 2), { mode: 0o600 });
203
+ }
204
+ // get_updates_buf cursor — persisted so a restart resumes the message stream where it left off.
205
+ function loadCursor(accountId) {
206
+ try {
207
+ const f = join(weixinDir(), `${accountId}.cursor`);
208
+ return existsSync(f) ? readFileSync(f, "utf8") : "";
209
+ }
210
+ catch {
211
+ return "";
212
+ }
213
+ }
214
+ function saveCursor(accountId, buf) {
215
+ try {
216
+ mkdirSync(weixinDir(), { recursive: true });
217
+ writeFileSync(join(weixinDir(), `${accountId}.cursor`), buf);
218
+ }
219
+ catch {
220
+ /* best-effort */
221
+ }
222
+ }
223
+ // Per-peer context_token: every reply must echo the latest token iLink sent for that peer.
224
+ class TokenStore {
225
+ accountId;
226
+ cache = {};
227
+ constructor(accountId) {
228
+ this.accountId = accountId;
229
+ try {
230
+ if (existsSync(this.file()))
231
+ this.cache = JSON.parse(readFileSync(this.file(), "utf8"));
232
+ }
233
+ catch {
234
+ this.cache = {};
235
+ }
236
+ }
237
+ file() {
238
+ return join(weixinDir(), `${this.accountId}.context-tokens.json`);
239
+ }
240
+ persist() {
241
+ try {
242
+ mkdirSync(weixinDir(), { recursive: true });
243
+ writeFileSync(this.file(), JSON.stringify(this.cache));
244
+ }
245
+ catch {
246
+ /* best-effort */
247
+ }
248
+ }
249
+ get(peer) {
250
+ return this.cache[peer] || undefined;
251
+ }
252
+ set(peer, token) {
253
+ if (token) {
254
+ this.cache[peer] = token;
255
+ this.persist();
256
+ }
257
+ }
258
+ del(peer) {
259
+ delete this.cache[peer];
260
+ this.persist();
261
+ }
262
+ }
263
+ async function renderQr(data) {
264
+ try {
265
+ const spec = "qrcode-terminal"; // optional dep; variable specifier so tsc doesn't require it at build time
266
+ const mod = (await import(spec));
267
+ const qr = mod.default ?? mod;
268
+ console.error("\nScan this QR with WeChat (Me → … or Discover → Scan):\n");
269
+ await new Promise((res) => qr.generate(data, { small: true }, (s) => { console.error(s); res(); }));
270
+ }
271
+ catch {
272
+ console.error(`\nScan this as a QR with WeChat — install 'qrcode-terminal' to render it inline, or paste this URL into any QR generator:\n${data}\n`);
273
+ }
274
+ }
275
+ /** Interactive QR login → saves {account_id, token, base_url, user_id} to ~/.hara/weixin/creds.json. */
276
+ export async function weixinLogin(timeoutSeconds = 480) {
277
+ let qr = await apiGet(ILINK_BASE_URL, `${EP.getBotQr}?bot_type=3`, QR_TIMEOUT_MS);
278
+ let qrcodeValue = str(qr.qrcode);
279
+ await renderQr(str(qr.qrcode_img_content) || qrcodeValue);
280
+ const deadline = Date.now() + timeoutSeconds * 1000;
281
+ let baseUrl = ILINK_BASE_URL;
282
+ let refreshes = 0;
283
+ let lastStatus = "";
284
+ while (Date.now() < deadline) {
285
+ let st;
286
+ try {
287
+ st = await apiGet(baseUrl, `${EP.getQrStatus}?qrcode=${encodeURIComponent(qrcodeValue)}`, QR_TIMEOUT_MS);
288
+ }
289
+ catch {
290
+ await sleep(1000);
291
+ continue;
292
+ }
293
+ const status = str(st.status) || "wait";
294
+ if (status !== lastStatus && status === "scaned")
295
+ console.error("weixin login: scanned — confirm on your phone…");
296
+ lastStatus = status;
297
+ if (status === "confirmed") {
298
+ const creds = {
299
+ account_id: str(st.ilink_bot_id),
300
+ token: str(st.bot_token),
301
+ base_url: str(st.baseurl) || ILINK_BASE_URL,
302
+ user_id: str(st.ilink_user_id),
303
+ };
304
+ if (!creds.account_id || !creds.token) {
305
+ console.error("weixin login: confirmed but missing account_id/token");
306
+ return null;
307
+ }
308
+ saveWeixinCreds(creds);
309
+ console.error(`weixin login: ✓ logged in as ${creds.account_id} (base ${creds.base_url})`);
310
+ return creds;
311
+ }
312
+ if (status === "scaned_but_redirect") {
313
+ const host = str(st.redirect_host);
314
+ if (host)
315
+ baseUrl = `https://${host}`; // subsequent calls use the redirected host
316
+ }
317
+ else if (status === "expired") {
318
+ if (++refreshes > 3) {
319
+ console.error("weixin login: QR expired too many times — aborting");
320
+ return null;
321
+ }
322
+ qr = await apiGet(ILINK_BASE_URL, `${EP.getBotQr}?bot_type=3`, QR_TIMEOUT_MS);
323
+ qrcodeValue = str(qr.qrcode);
324
+ baseUrl = ILINK_BASE_URL;
325
+ await renderQr(str(qr.qrcode_img_content) || qrcodeValue);
326
+ }
327
+ await sleep(1000);
328
+ }
329
+ console.error("weixin login: timed out");
330
+ return null;
331
+ }
332
+ async function sendChunk(creds, tokenStore, peer, text) {
333
+ const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
334
+ const post = (ctx) => apiPost(creds.base_url, EP.sendMessage, buildSendBody(peer, text, ctx, clientId), creds.token, API_TIMEOUT_MS).catch((e) => ({ ret: 1, errmsg: String(e?.message ?? e) }));
335
+ let resp = await post(tokenStore.get(peer));
336
+ let [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
337
+ if (isSessionExpired(ret, errcode, errmsg)) {
338
+ tokenStore.del(peer); // stale → drop it and retry once tokenless (iLink accepts that, degraded)
339
+ resp = await post(undefined);
340
+ [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
341
+ }
342
+ if (ret !== 0 || errcode !== 0)
343
+ console.error(`weixin send: ret=${ret} errcode=${errcode} errmsg=${errmsg}`);
344
+ }
345
+ function aes128EcbEncrypt(plaintext, key) {
346
+ const c = createCipheriv("aes-128-ecb", key, null); // PKCS7 auto-padding (Node default) matches iLink
347
+ return Buffer.concat([c.update(plaintext), c.final()]);
348
+ }
349
+ // ── inbound media (receive a file/image/voice the user sent) ──────────────────
350
+ /** Parse an inbound `media.aes_key`: base64 → 16 raw bytes, OR base64 → 32-char ascii hex → hex-decode. */
351
+ export function parseAesKey(aesKeyB64) {
352
+ const decoded = Buffer.from(aesKeyB64, "base64");
353
+ if (decoded.length === 16)
354
+ return decoded;
355
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii")))
356
+ return Buffer.from(decoded.toString("ascii"), "hex");
357
+ throw new Error(`unexpected aes_key format (${decoded.length} bytes)`);
358
+ }
359
+ function aes128EcbDecrypt(ciphertext, key) {
360
+ const d = createDecipheriv("aes-128-ecb", key, null);
361
+ d.setAutoPadding(false); // iLink's strip is conditional (tolerates non-PKCS7) — replicate it ourselves
362
+ const padded = Buffer.concat([d.update(ciphertext), d.final()]);
363
+ if (!padded.length)
364
+ return padded;
365
+ const pad = padded[padded.length - 1];
366
+ if (pad >= 1 && pad <= 16 && padded.length >= pad) {
367
+ let ok = true;
368
+ for (let i = padded.length - pad; i < padded.length; i++)
369
+ if (padded[i] !== pad)
370
+ ok = false;
371
+ if (ok)
372
+ return padded.subarray(0, padded.length - pad);
373
+ }
374
+ return padded;
375
+ }
376
+ /** Downloadable media items in an inbound message (pure). Skips voice that already carries a transcription. */
377
+ export function inboundMediaRefs(itemList) {
378
+ const items = Array.isArray(itemList) ? itemList : [];
379
+ const out = [];
380
+ for (const it of items) {
381
+ if (it?.type === ITEM_IMAGE) {
382
+ const media = it.image_item?.media ?? {};
383
+ // image key hack: prefer image_item.aeskey (hex) re-encoded as base64(ascii(hex)); else media.aes_key
384
+ const hex = str(it.image_item?.aeskey);
385
+ const aesKeyB64 = (hex && Buffer.from(hex, "ascii").toString("base64")) || str(media.aes_key) || undefined;
386
+ out.push({ kind: "image", encryptQueryParam: str(media.encrypt_query_param) || undefined, fullUrl: str(media.full_url) || undefined, aesKeyB64 });
387
+ }
388
+ else if (it?.type === ITEM_FILE) {
389
+ const media = it.file_item?.media ?? {};
390
+ out.push({ kind: "file", encryptQueryParam: str(media.encrypt_query_param) || undefined, fullUrl: str(media.full_url) || undefined, aesKeyB64: str(media.aes_key) || undefined, fileName: str(it.file_item?.file_name) || "document.bin" });
391
+ }
392
+ else if (it?.type === ITEM_VOICE && !str(it.voice_item?.text)) {
393
+ const media = it.voice_item?.media ?? {};
394
+ out.push({ kind: "voice", encryptQueryParam: str(media.encrypt_query_param) || undefined, fullUrl: str(media.full_url) || undefined, aesKeyB64: str(media.aes_key) || undefined });
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+ const cdnDownloadUrl = (param) => `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(param)}`;
400
+ const mediaDir = () => join(weixinDir(), "media");
401
+ const sanitizeName = (name) => name.replace(/[^\w.\- ]+/g, "_").slice(-80) || "file.bin";
402
+ function assertCdnHost(url) {
403
+ let u;
404
+ try {
405
+ u = new URL(url);
406
+ }
407
+ catch {
408
+ throw new Error(`unparseable media URL: ${url}`);
409
+ }
410
+ if (u.protocol !== "http:" && u.protocol !== "https:")
411
+ throw new Error(`disallowed scheme ${u.protocol}`);
412
+ if (!WEIXIN_CDN_ALLOWLIST.has(u.hostname))
413
+ throw new Error(`host ${u.hostname} not in WeChat CDN allowlist (SSRF guard)`);
414
+ }
415
+ /** Download (+ AES-decrypt if keyed) an inbound media item to a local file. Returns its path + mime, or null. */
416
+ export async function downloadInboundMedia(ref) {
417
+ try {
418
+ let url;
419
+ if (ref.encryptQueryParam)
420
+ url = cdnDownloadUrl(ref.encryptQueryParam);
421
+ else if (ref.fullUrl) {
422
+ assertCdnHost(ref.fullUrl);
423
+ url = ref.fullUrl;
424
+ }
425
+ else
426
+ return null;
427
+ const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
428
+ if (!res.ok)
429
+ throw new Error(`media download HTTP ${res.status}`);
430
+ const ab = await res.arrayBuffer();
431
+ if (ab.byteLength > INBOUND_MEDIA_MAX)
432
+ throw new Error("media exceeds size cap");
433
+ let buf = Buffer.from(ab);
434
+ if (ref.aesKeyB64)
435
+ buf = aes128EcbDecrypt(buf, parseAesKey(ref.aesKeyB64));
436
+ if (!buf.length)
437
+ return null;
438
+ mkdirSync(mediaDir(), { recursive: true });
439
+ const tag = randomUUID().slice(0, 8);
440
+ const name = ref.kind === "image" ? `img_${tag}.jpg` : ref.kind === "voice" ? `audio_${tag}.silk` : `${tag}_${sanitizeName(ref.fileName || "document.bin")}`;
441
+ const path = join(mediaDir(), name);
442
+ writeFileSync(path, buf);
443
+ return { path, mime: ref.kind === "image" ? "image/jpeg" : ref.kind === "voice" ? "audio/silk" : "application/octet-stream" };
444
+ }
445
+ catch (e) {
446
+ console.error(`weixin inbound media (${ref.kind}): ${e?.message ?? e}`);
447
+ return null;
448
+ }
449
+ }
450
+ async function cdnUpload(uploadUrl, ciphertext) {
451
+ const res = await fetch(uploadUrl, { method: "POST", body: new Uint8Array(ciphertext), headers: { "Content-Type": "application/octet-stream" }, signal: AbortSignal.timeout(120_000) });
452
+ await res.arrayBuffer().catch(() => undefined); // drain body
453
+ if (!res.ok)
454
+ throw new Error(`CDN upload HTTP ${res.status}`);
455
+ const ep = res.headers.get("x-encrypted-param");
456
+ if (!ep)
457
+ throw new Error("CDN upload missing x-encrypted-param header");
458
+ return ep;
459
+ }
460
+ /** Send a local file to a peer. Images go inline (image_item); everything else (audio/zip/pdf/doc/…) goes as a
461
+ * file attachment (file_item) carrying the filename. Ported byte-exact from iLink's media protocol:
462
+ * getuploadurl → AES-128-ECB encrypt → CDN POST → sendmessage(item). */
463
+ export async function sendMediaFile(creds, tokenStore, peer, filePath) {
464
+ try {
465
+ const plaintext = readFileSync(filePath);
466
+ const rawsize = plaintext.length;
467
+ const isImage = IMAGE_EXTS.has((filePath.split(".").pop() || "").toLowerCase());
468
+ const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
469
+ const filekey = randomBytes(16).toString("hex");
470
+ const key = randomBytes(16);
471
+ const keyHex = key.toString("hex");
472
+ const filesize = Math.ceil((rawsize + 1) / 16) * 16; // AES-padded size == ciphertext length
473
+ const up = await apiPost(creds.base_url, EP.getUploadUrl, { filekey, media_type: isImage ? MEDIA_IMAGE : MEDIA_FILE, to_user_id: peer, rawsize, rawfilemd5, filesize, no_need_thumb: true, aeskey: keyHex }, creds.token, API_TIMEOUT_MS);
474
+ const ciphertext = aes128EcbEncrypt(plaintext, key);
475
+ const fullUrl = str(up.upload_full_url);
476
+ const param = str(up.upload_param);
477
+ const uploadUrl = fullUrl || (param ? `${WEIXIN_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(param)}&filekey=${encodeURIComponent(filekey)}` : "");
478
+ if (!uploadUrl)
479
+ throw new Error("getuploadurl returned neither upload_full_url nor upload_param");
480
+ const encryptedParam = await cdnUpload(uploadUrl, ciphertext);
481
+ const item = isImage
482
+ ? imageInlineItem(encryptedParam, apiAesKey(keyHex), ciphertext.length)
483
+ : audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, basename(filePath));
484
+ const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
485
+ const send = (ctx) => {
486
+ const msg = { from_user_id: "", to_user_id: peer, client_id: clientId, message_type: MSG_TYPE_BOT, message_state: MSG_STATE_FINISH, item_list: [item] };
487
+ if (ctx)
488
+ msg.context_token = ctx;
489
+ return apiPost(creds.base_url, EP.sendMessage, { msg }, creds.token, API_TIMEOUT_MS);
490
+ };
491
+ let res = await send(tokenStore.get(peer));
492
+ let [ret, errcode, errmsg] = [num(res.ret), num(res.errcode), str(res.errmsg ?? res.msg)];
493
+ if (isSessionExpired(ret, errcode, errmsg)) {
494
+ tokenStore.del(peer);
495
+ res = await send(undefined); // tokenless retry (degraded fallback iLink accepts)
496
+ [ret, errcode, errmsg] = [num(res.ret), num(res.errcode), str(res.errmsg ?? res.msg)];
497
+ }
498
+ if (ret !== 0 || errcode !== 0) {
499
+ console.error(`weixin sendMedia: ret=${ret} errcode=${errcode} errmsg=${errmsg}`);
500
+ return false;
501
+ }
502
+ return true;
503
+ }
504
+ catch (e) {
505
+ console.error(`weixin sendMedia: ${e?.message ?? e}`);
506
+ return false;
507
+ }
508
+ }
509
+ export function weixinAdapter(creds) {
510
+ const tokenStore = new TokenStore(creds.account_id);
511
+ // Reuse parseWeixinMessage for text/voice-transcription, then download any image/file/voice media and append
512
+ // a `[kind: localpath]` reference so hara can read the file. Handles media-only messages (no text) too.
513
+ const buildInbound = async (msg) => {
514
+ const parsed = parseWeixinMessage(msg, creds.account_id);
515
+ const from = str(msg?.from_user_id).trim();
516
+ if (!from || from === creds.account_id)
517
+ return null;
518
+ if (guessChatType(msg, creds.account_id).kind !== "dm")
519
+ return null;
520
+ let text = parsed?.inbound.text ?? "";
521
+ const images = [];
522
+ for (const ref of inboundMediaRefs(msg?.item_list)) {
523
+ const dl = await downloadInboundMedia(ref);
524
+ if (!dl)
525
+ continue;
526
+ if (ref.kind === "image") {
527
+ // images go through as real attachments (seen/described downstream); leave only a light marker in text
528
+ images.push(dl.path);
529
+ text += `${text ? "\n" : ""}[图片]`;
530
+ }
531
+ else {
532
+ const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
533
+ text += `${text ? "\n" : ""}[${label}: ${dl.path}]`;
534
+ }
535
+ }
536
+ text = text.trim();
537
+ if (!text && !images.length)
538
+ return null;
539
+ tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
540
+ return { chatId: from, userId: from, userName: from, text: text || "[图片]", images: images.length ? images : undefined };
541
+ };
542
+ return {
543
+ name: "weixin",
544
+ async send(chatId, text) {
545
+ const peer = String(chatId);
546
+ for (const part of chunkText(text || "(empty)"))
547
+ await sendChunk(creds, tokenStore, peer, part);
548
+ },
549
+ async sendFile(chatId, filePath) {
550
+ await sendMediaFile(creds, tokenStore, String(chatId), filePath);
551
+ },
552
+ async start(onMessage, signal) {
553
+ let buf = loadCursor(creds.account_id);
554
+ let pollMs = LONG_POLL_TIMEOUT_MS;
555
+ while (!signal.aborted) {
556
+ let resp;
557
+ try {
558
+ // client timeout is a touch longer than the server long-poll so an empty batch returns before we abort
559
+ resp = await apiPost(creds.base_url, EP.getUpdates, { get_updates_buf: buf }, creds.token, pollMs + 5_000, signal);
560
+ }
561
+ catch {
562
+ if (signal.aborted)
563
+ break;
564
+ await sleep(2000, signal); // timeout (normal) or network blip → re-poll
565
+ continue;
566
+ }
567
+ const ret = num(resp.ret);
568
+ const errcode = num(resp.errcode);
569
+ if (isSessionExpired(ret, errcode, str(resp.errmsg))) {
570
+ console.error("weixin: session expired — re-login with `hara gateway --platform weixin --login`. backing off 600s.");
571
+ await sleep(600_000, signal);
572
+ continue;
573
+ }
574
+ if (ret !== 0 || errcode !== 0) {
575
+ await sleep(2000, signal);
576
+ continue;
577
+ }
578
+ buf = str(resp.get_updates_buf) || buf;
579
+ saveCursor(creds.account_id, buf);
580
+ for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
581
+ const inbound = await buildInbound(msg);
582
+ if (inbound)
583
+ await onMessage(inbound).catch(() => { });
584
+ }
585
+ const lp = num(resp.longpolling_timeout_ms);
586
+ pollMs = lp > 0 ? lp : LONG_POLL_TIMEOUT_MS;
587
+ }
588
+ },
589
+ };
590
+ }