@nanhara/hara 0.89.0 → 0.95.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ // tmux reply routing — lets a chat reply (WeChat) be injected back into an already-running tmux session
2
+ // (e.g. a Claude Code / codex / hara you started yourself), so "ping me on WeChat → I reply from outside →
3
+ // that session continues" works WITHOUT the daemon owning the process. The asking session registers its tmux
4
+ // pane (via the wechat-send `--ask` flow); the gateway daemon (sole WeChat receiver) injects the owner's reply
5
+ // into the oldest live registered pane with `tmux send-keys`. Borrows the ccgram keystroke-injection pattern.
6
+ //
7
+ // Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
8
+ // ONLY injects into panes that opted in by registering — never an arbitrary pane.
9
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ function dir() {
14
+ return join(homedir(), ".hara", "gateway");
15
+ }
16
+ function storePath() {
17
+ return join(dir(), "tmux-routes.json");
18
+ }
19
+ function load() {
20
+ try {
21
+ const j = JSON.parse(readFileSync(storePath(), "utf8"));
22
+ return Array.isArray(j?.routes) ? j.routes : [];
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ function save(routes) {
29
+ mkdirSync(dir(), { recursive: true });
30
+ writeFileSync(storePath(), JSON.stringify({ routes }, null, 2));
31
+ }
32
+ /** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
33
+ * reply; "bind" = persistent (every reply injects until unbound). */
34
+ export function registerTmuxRoute(pane, peer, cwd, mode = "once", now = Date.now()) {
35
+ const routes = load().filter((r) => r.pane !== pane);
36
+ routes.push({ pane, peer, cwd, ts: now, mode });
37
+ save(routes);
38
+ }
39
+ /** Remove a pane's route(s). Returns how many were removed. */
40
+ export function unbindPane(pane) {
41
+ const before = load();
42
+ const after = before.filter((r) => r.pane !== pane);
43
+ save(after);
44
+ return before.length - after.length;
45
+ }
46
+ /** All current routes (for `hara remote status`). */
47
+ export function listRoutes() {
48
+ return load();
49
+ }
50
+ /** Remove all persistent "bind" routes (the chat `/detach` command). Returns how many were removed. */
51
+ export function unbindBinds() {
52
+ const before = load();
53
+ const after = before.filter((r) => r.mode === "bind" ? false : true);
54
+ save(after);
55
+ return before.length - after.length;
56
+ }
57
+ /** Pure: pick the OLDEST live registered pane (FIFO — the longest-waiting ask answers first); return it plus the
58
+ * routes to keep. A "once" route is consumed after use; a "bind" route persists. Dead panes are always pruned. */
59
+ export function pickRoute(routes, isAlive) {
60
+ const live = routes.filter((r) => isAlive(r.pane)).sort((a, b) => a.ts - b.ts);
61
+ const chosen = live[0] ?? null;
62
+ const remaining = chosen && chosen.mode !== "bind" ? live.filter((r) => r.pane !== chosen.pane) : live;
63
+ return { chosen, remaining };
64
+ }
65
+ /** Is this tmux pane still alive? Checks membership in `list-panes -a` (display-message -t is too lenient and
66
+ * falls back to the active pane for a bogus target). false if tmux isn't running or the pane is gone. */
67
+ export function paneAlive(pane) {
68
+ try {
69
+ const out = execFileSync("tmux", ["list-panes", "-a", "-F", "#{pane_id}"], { encoding: "utf8", timeout: 3000 });
70
+ return out.split("\n").map((s) => s.trim()).includes(pane);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Type `text` into a tmux pane as if the user typed it, then press Enter (submits the line / sends the turn). */
77
+ export function injectTmux(pane, text) {
78
+ execFileSync("tmux", ["send-keys", "-t", pane, "-l", "--", text], { timeout: 3000 });
79
+ execFileSync("tmux", ["send-keys", "-t", pane, "Enter"], { timeout: 3000 });
80
+ }
81
+ /** Persistent ("bind") routes only — the panes whose OUTPUT we relay back to chat (two-way remote terminal). */
82
+ export function boundRoutes() {
83
+ return load().filter((r) => r.mode === "bind");
84
+ }
85
+ /** Capture a tmux pane's visible text (plain, no ANSI). null if unavailable. */
86
+ export function capturePane(pane) {
87
+ try {
88
+ return execFileSync("tmux", ["capture-pane", "-p", "-t", pane], { encoding: "utf8", timeout: 3000 });
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** Pure: the NEW output to relay, given what we last sent and the current pane capture. "" = nothing new.
95
+ * Handles the common append case, anchors on the last sent line when the pane has scrolled, and falls back to
96
+ * the tail when it can't re-anchor. */
97
+ export function outputDelta(lastSent, current) {
98
+ if (current === lastSent)
99
+ return "";
100
+ if (!lastSent)
101
+ return current; // caller decides whether to baseline (skip) or send on first sight
102
+ if (current.startsWith(lastSent))
103
+ return current.slice(lastSent.length);
104
+ const lines = lastSent.split("\n").filter((l) => l.trim());
105
+ const anchor = lines[lines.length - 1];
106
+ if (anchor) {
107
+ const idx = current.lastIndexOf(anchor);
108
+ if (idx >= 0)
109
+ return current.slice(idx + anchor.length);
110
+ }
111
+ return current.split("\n").slice(-20).join("\n"); // scrolled past our anchor → send the tail
112
+ }
113
+ /** Pick (and consume per mode) the oldest live registered pane WITHOUT injecting — so the caller can capture the
114
+ * pane before/after injecting and relay just the new output. Returns the pane id, or null if none pending. */
115
+ export function pickPaneForReply() {
116
+ const { chosen, remaining } = pickRoute(load(), paneAlive);
117
+ save(remaining);
118
+ return chosen?.pane ?? null;
119
+ }
120
+ /** Daemon entrypoint: deliver an inbound reply to the oldest live registered pane. Returns the pane id injected
121
+ * into, or null if there was no pending route (→ caller treats the message as a normal task). One-shot: the
122
+ * chosen route is consumed and dead panes are pruned. */
123
+ export function deliverToTmux(text) {
124
+ const { chosen, remaining } = pickRoute(load(), paneAlive);
125
+ save(remaining);
126
+ if (!chosen)
127
+ return null;
128
+ try {
129
+ injectTmux(chosen.pane, text);
130
+ return chosen.pane;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
@@ -0,0 +1,383 @@
1
+ // WeCom (企业微信 / Enterprise WeChat) adapter for `hara gateway` — connects to WeCom's AI Bot WebSocket gateway
2
+ // (wss://openws.work.weixin.qq.com) over Node's native global WebSocket (zero new dep on Node ≥ 22) so the LOCAL
3
+ // daemon dials OUT: NO public webhook / callback endpoint is required, exactly like Discord/DingTalk/Feishu's
4
+ // long-connection mode. Creds from HARA_WECOM_BOT_ID / HARA_WECOM_SECRET (the AI Bot's id + secret from the WeCom
5
+ // admin console). Same ChatAdapter shape as Telegram/Discord, so all the cross-platform gateway plumbing (system
6
+ // context, stuck-guard, allowlist, send_file, voice) works unchanged.
7
+ //
8
+ // Protocol (ported from the Hermes Python adapter): authenticate with an `aibot_subscribe` frame → receive
9
+ // `aibot_msg_callback` events → reply with `aibot_send_msg` → upload media via the 3-step `aibot_upload_media_*`
10
+ // chunked protocol → AES-256-CBC decrypt inbound media that carries an `aeskey`. Every request frame carries a
11
+ // `headers.req_id` and the server echoes it back so request/response are correlated. v1 limitations are documented
12
+ // at the bottom of this file (the public spec is thin — fields are best-effort and tolerant of shape drift).
13
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { join, basename, extname } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { randomUUID, createDecipheriv, createHash } from "node:crypto";
17
+ import { chunkText } from "./telegram.js";
18
+ const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
19
+ const WSImpl = globalThis.WebSocket;
20
+ // app-level command verbs on the AI Bot gateway (mirror the Hermes constants)
21
+ const CMD_SUBSCRIBE = "aibot_subscribe";
22
+ const CMD_CALLBACK = "aibot_msg_callback";
23
+ const CMD_LEGACY_CALLBACK = "aibot_callback";
24
+ const CMD_EVENT_CALLBACK = "aibot_event_callback";
25
+ const CMD_SEND = "aibot_send_msg";
26
+ const CMD_PING = "ping";
27
+ const CMD_UPLOAD_INIT = "aibot_upload_media_init";
28
+ const CMD_UPLOAD_CHUNK = "aibot_upload_media_chunk";
29
+ const CMD_UPLOAD_FINISH = "aibot_upload_media_finish";
30
+ const CALLBACK_CMDS = new Set([CMD_CALLBACK, CMD_LEGACY_CALLBACK]);
31
+ const MAX_MESSAGE_LENGTH = 4000;
32
+ const HEARTBEAT_MS = 30000;
33
+ const REQUEST_TIMEOUT_MS = 15000;
34
+ const UPLOAD_CHUNK_SIZE = 512 * 1024; // 512 KB chunks (WeCom upload protocol)
35
+ const ABSOLUTE_MAX_BYTES = 20 * 1024 * 1024; // WeCom hard cap on any upload
36
+ const sleep = (ms, signal) => new Promise((r) => {
37
+ if (signal?.aborted)
38
+ return r();
39
+ const t = setTimeout(r, ms);
40
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
41
+ });
42
+ const isImageName = (name) => /\.(png|jpe?g|gif|webp)$/i.test(name);
43
+ /** Detect an image extension from magic bytes (pure) — used when WeCom doesn't give us a filename. */
44
+ function imageExtFromBytes(data) {
45
+ if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
46
+ return ".png";
47
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
48
+ return ".jpg";
49
+ if (data.length >= 6 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46)
50
+ return ".gif";
51
+ if (data.length >= 12 && data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP")
52
+ return ".webp";
53
+ return ".jpg";
54
+ }
55
+ /** AES-256-CBC decrypt WeCom-encrypted media (pure). The 32-byte key is the base64-decoded `aeskey`; the IV is the
56
+ * first 16 bytes of that key; padding is PKCS#7 (validated leniently, like the Hermes reference). */
57
+ export function decryptWecomMedia(ciphertext, aesKeyB64) {
58
+ const key = Buffer.from(aesKeyB64, "base64");
59
+ if (key.length !== 32)
60
+ throw new Error(`unexpected WeCom aes_key length: ${key.length} (expected 32)`);
61
+ const d = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
62
+ d.setAutoPadding(false); // strip PKCS#7 ourselves so a non-conformant tail doesn't throw
63
+ const padded = Buffer.concat([d.update(ciphertext), d.final()]);
64
+ if (!padded.length)
65
+ return padded;
66
+ const pad = padded[padded.length - 1];
67
+ if (pad >= 1 && pad <= 32 && padded.length >= pad) {
68
+ let ok = true;
69
+ for (let i = padded.length - pad; i < padded.length; i++)
70
+ if (padded[i] !== pad)
71
+ ok = false;
72
+ if (ok)
73
+ return padded.subarray(0, padded.length - pad);
74
+ }
75
+ return padded;
76
+ }
77
+ /** Pull the plain text out of a WeCom callback body (pure). Handles `text`, `voice` (transcription), and `mixed`
78
+ * (text + image runs) message types — joining all text runs with newlines. */
79
+ function extractWecomText(body) {
80
+ const parts = [];
81
+ const msgtype = String(body?.msgtype ?? "").toLowerCase();
82
+ if (msgtype === "mixed") {
83
+ const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
84
+ for (const it of items) {
85
+ if (String(it?.msgtype ?? "").toLowerCase() === "text") {
86
+ const c = String(it?.text?.content ?? "").trim();
87
+ if (c)
88
+ parts.push(c);
89
+ }
90
+ }
91
+ }
92
+ else {
93
+ const c = String(body?.text?.content ?? "").trim();
94
+ if (c)
95
+ parts.push(c);
96
+ if (msgtype === "voice") {
97
+ const v = String(body?.voice?.content ?? "").trim(); // voice transcription, when present
98
+ if (v)
99
+ parts.push(v);
100
+ }
101
+ }
102
+ return parts.join("\n").trim();
103
+ }
104
+ /** Collect downloadable media refs from a WeCom callback body (pure). Covers top-level `image`/`file` and the image
105
+ * runs inside a `mixed` message. */
106
+ function extractWecomMedia(body) {
107
+ const refs = [];
108
+ const pushMedia = (kind, m) => {
109
+ if (!m || typeof m !== "object")
110
+ return;
111
+ refs.push({
112
+ kind,
113
+ url: typeof m.url === "string" ? m.url : undefined,
114
+ base64: typeof m.base64 === "string" ? m.base64 : undefined,
115
+ aesKeyB64: typeof m.aeskey === "string" ? m.aeskey : undefined,
116
+ fileName: typeof m.filename === "string" ? m.filename : typeof m.name === "string" ? m.name : undefined,
117
+ });
118
+ };
119
+ const msgtype = String(body?.msgtype ?? "").toLowerCase();
120
+ if (msgtype === "mixed") {
121
+ const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
122
+ for (const it of items)
123
+ if (String(it?.msgtype ?? "").toLowerCase() === "image")
124
+ pushMedia("image", it.image);
125
+ }
126
+ else {
127
+ if (body?.image)
128
+ pushMedia("image", body.image);
129
+ if (msgtype === "file" && body?.file)
130
+ pushMedia("file", body.file);
131
+ }
132
+ return refs;
133
+ }
134
+ /** Parse a WeCom `aibot_msg_callback` payload → InboundMsg + its downloadable media refs (pure; the actual download
135
+ * happens in start()). `selfBotId` filters out the bot's own echoes. null = ignore (own message / empty / no chat
136
+ * id). chatId is the WeCom chatid (group) else the sender's userid (DM); userId/userName are the sender's userid.
137
+ * Mirrors parseDiscordMessage. */
138
+ export function parseWecomMessage(payload, selfBotId) {
139
+ const body = payload?.body;
140
+ if (!body || typeof body !== "object")
141
+ return null;
142
+ const sender = typeof body.from === "object" && body.from ? body.from : {};
143
+ const senderId = String(sender.userid ?? "").trim();
144
+ // ignore our own messages (the bot speaking) so we never loop on our replies
145
+ if (selfBotId && senderId && senderId === selfBotId)
146
+ return null;
147
+ if (selfBotId && String(body.bot_id ?? body.botid ?? "").trim() === selfBotId && !senderId)
148
+ return null;
149
+ const chatId = String(body.chatid ?? senderId).trim();
150
+ if (!chatId)
151
+ return null;
152
+ const text = extractWecomText(body);
153
+ const media = extractWecomMedia(body);
154
+ if (!text && !media.length)
155
+ return null; // unsupported type or empty
156
+ return {
157
+ msg: {
158
+ chatId,
159
+ userId: senderId || chatId,
160
+ userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
161
+ text: text || "[图片]",
162
+ },
163
+ media,
164
+ };
165
+ }
166
+ /** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
167
+ * failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
168
+ async function downloadWecomMedia(ref) {
169
+ try {
170
+ let data = null;
171
+ if (ref.base64) {
172
+ data = Buffer.from(ref.base64.split(",").pop().trim(), "base64");
173
+ }
174
+ else if (ref.url) {
175
+ const r = await fetch(ref.url);
176
+ if (!r.ok)
177
+ return null;
178
+ let buf = Buffer.from(await r.arrayBuffer());
179
+ if (buf.length > ABSOLUTE_MAX_BYTES)
180
+ return null;
181
+ if (ref.aesKeyB64)
182
+ buf = decryptWecomMedia(buf, ref.aesKeyB64);
183
+ data = buf;
184
+ }
185
+ if (!data || !data.length)
186
+ return null;
187
+ const dir = join(homedir(), ".hara", "wecom", "media");
188
+ mkdirSync(dir, { recursive: true });
189
+ let name = ref.fileName ? basename(ref.fileName) : "";
190
+ if (!name || (ref.kind === "image" && !extname(name)))
191
+ name = `image${imageExtFromBytes(data)}`;
192
+ const path = join(dir, `wc_${Date.now()}_${name || "file.bin"}`);
193
+ writeFileSync(path, data);
194
+ return path;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
201
+ // per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
202
+ // it publishes through `conn`.
203
+ const conn = { send: null };
204
+ // Upload a local file to WeCom via the 3-step chunked protocol → its media_id. Throws on any step error.
205
+ async function uploadMedia(filePath, mediaType) {
206
+ if (!conn.send)
207
+ throw new Error("WeCom socket not connected");
208
+ const data = readFileSync(filePath);
209
+ if (data.length > ABSOLUTE_MAX_BYTES)
210
+ throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
211
+ const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
212
+ const init = await conn.send(CMD_UPLOAD_INIT, {
213
+ type: mediaType,
214
+ filename: basename(filePath),
215
+ total_size: data.length,
216
+ total_chunks: totalChunks,
217
+ md5: createHash("md5").update(data).digest("hex"),
218
+ });
219
+ const uploadId = String(init?.body?.upload_id ?? "").trim();
220
+ if (!uploadId)
221
+ throw new Error(`media upload init returned no upload_id (${JSON.stringify(init?.errmsg ?? init)})`);
222
+ for (let i = 0, start = 0; start < data.length || i === 0; i++, start += UPLOAD_CHUNK_SIZE) {
223
+ const chunk = data.subarray(start, start + UPLOAD_CHUNK_SIZE);
224
+ await conn.send(CMD_UPLOAD_CHUNK, { upload_id: uploadId, chunk_index: i, base64_data: chunk.toString("base64") });
225
+ if (start + UPLOAD_CHUNK_SIZE >= data.length)
226
+ break;
227
+ }
228
+ const fin = await conn.send(CMD_UPLOAD_FINISH, { upload_id: uploadId });
229
+ const mediaId = String(fin?.body?.media_id ?? "").trim();
230
+ if (!mediaId)
231
+ throw new Error(`media upload finish returned no media_id (${JSON.stringify(fin?.errmsg ?? fin)})`);
232
+ return mediaId;
233
+ }
234
+ return {
235
+ name: "wecom",
236
+ async send(chatId, text) {
237
+ if (!conn.send)
238
+ return; // no live socket yet → nothing to reply through
239
+ for (const part of chunkText(text || "(empty)", MAX_MESSAGE_LENGTH)) {
240
+ await conn
241
+ .send(CMD_SEND, { chatid: String(chatId), msgtype: "markdown", markdown: { content: part } })
242
+ .catch(() => { });
243
+ }
244
+ },
245
+ async sendFile(chatId, filePath) {
246
+ if (!conn.send)
247
+ return;
248
+ const mediaType = isImageName(filePath) ? "image" : "file";
249
+ try {
250
+ const mediaId = await uploadMedia(filePath, mediaType);
251
+ await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
252
+ }
253
+ catch {
254
+ /* upload/send failed — caller surfaces a generic failure; nothing else to do */
255
+ }
256
+ },
257
+ async start(onMessage, signal) {
258
+ if (!WSImpl) {
259
+ console.error("hara gateway: WeCom needs Node ≥ 22 (global WebSocket). Upgrade Node.");
260
+ return;
261
+ }
262
+ while (!signal.aborted) {
263
+ await connectOnce(botId, secret, wsUrl, conn, onMessage, signal);
264
+ if (!signal.aborted)
265
+ await sleep(3000, signal); // reconnect backoff
266
+ }
267
+ },
268
+ };
269
+ }
270
+ /** One gateway connection: open WS → `aibot_subscribe` auth → correlate request/response by req_id, dispatch
271
+ * `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
272
+ * subscribe each time — v1 keeps it simple, no resume). */
273
+ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
274
+ return new Promise((resolve) => {
275
+ const ws = new WSImpl(wsUrl);
276
+ let hb = null;
277
+ const pending = new Map();
278
+ const seen = new Set(); // msgid dedup within this connection (network hiccups can re-deliver)
279
+ const sendRaw = (frame) => {
280
+ try {
281
+ ws.send(JSON.stringify(frame));
282
+ }
283
+ catch {
284
+ /* socket gone — close handler resolves */
285
+ }
286
+ };
287
+ // Send a request and await the frame whose headers.req_id matches; times out so callers never hang.
288
+ const request = (cmd, body) => new Promise((res) => {
289
+ const reqId = `${cmd}-${randomUUID()}`;
290
+ const timer = setTimeout(() => {
291
+ pending.delete(reqId);
292
+ res({ errcode: -1, errmsg: "timeout" });
293
+ }, REQUEST_TIMEOUT_MS);
294
+ pending.set(reqId, { resolve: res, timer });
295
+ sendRaw({ cmd, headers: { req_id: reqId }, body });
296
+ });
297
+ const cleanup = () => {
298
+ if (hb)
299
+ clearInterval(hb);
300
+ signal.removeEventListener("abort", stop);
301
+ for (const { resolve: r, timer } of pending.values()) {
302
+ clearTimeout(timer);
303
+ r({ errcode: -1, errmsg: "connection closed" });
304
+ }
305
+ pending.clear();
306
+ if (conn.send === request)
307
+ conn.send = null;
308
+ };
309
+ const stop = () => {
310
+ cleanup();
311
+ try {
312
+ ws.close();
313
+ }
314
+ catch {
315
+ /* already closing */
316
+ }
317
+ resolve();
318
+ };
319
+ signal.addEventListener("abort", stop, { once: true });
320
+ ws.addEventListener("open", () => {
321
+ // Authenticate, then publish this socket's request() so send/sendFile can use it; heartbeat keeps it alive.
322
+ sendRaw({ cmd: CMD_SUBSCRIBE, headers: { req_id: `subscribe-${randomUUID()}` }, body: { bot_id: botId, secret } });
323
+ conn.send = request;
324
+ hb = setInterval(() => sendRaw({ cmd: CMD_PING, headers: { req_id: `ping-${randomUUID()}` }, body: {} }), HEARTBEAT_MS);
325
+ });
326
+ ws.addEventListener("close", () => {
327
+ cleanup();
328
+ resolve();
329
+ });
330
+ ws.addEventListener("error", () => { });
331
+ ws.addEventListener("message", async (ev) => {
332
+ // Native WebSocket may hand us a string, an ArrayBuffer, or a Blob — normalize to text first.
333
+ let raw;
334
+ const d = ev?.data;
335
+ if (typeof d === "string")
336
+ raw = d;
337
+ else if (d instanceof ArrayBuffer)
338
+ raw = Buffer.from(d).toString("utf8");
339
+ else if (typeof d?.arrayBuffer === "function")
340
+ raw = Buffer.from(await d.arrayBuffer()).toString("utf8");
341
+ else
342
+ raw = String(d ?? "");
343
+ let p;
344
+ try {
345
+ p = JSON.parse(raw);
346
+ }
347
+ catch {
348
+ return;
349
+ }
350
+ const cmd = String(p?.cmd ?? "");
351
+ const reqId = String(p?.headers?.req_id ?? "");
352
+ // A correlated response to one of our requests (and not itself a callback) → resolve the waiter.
353
+ if (reqId && pending.has(reqId) && !CALLBACK_CMDS.has(cmd)) {
354
+ const waiter = pending.get(reqId);
355
+ clearTimeout(waiter.timer);
356
+ pending.delete(reqId);
357
+ waiter.resolve(p);
358
+ return;
359
+ }
360
+ if (cmd === CMD_PING || cmd === CMD_EVENT_CALLBACK)
361
+ return; // server ping / event ack → ignore
362
+ if (!CALLBACK_CMDS.has(cmd))
363
+ return; // pre-auth / unknown frame → ignore
364
+ const msgId = String(p?.body?.msgid ?? reqId ?? "");
365
+ if (msgId) {
366
+ if (seen.has(msgId))
367
+ return; // duplicate delivery → drop
368
+ seen.add(msgId);
369
+ if (seen.size > 1000)
370
+ seen.clear();
371
+ }
372
+ const parsed = parseWecomMessage(p, botId);
373
+ if (parsed) {
374
+ for (const ref of parsed.media) {
375
+ const path = await downloadWecomMedia(ref);
376
+ if (path)
377
+ (parsed.msg.images ??= []).push(path);
378
+ }
379
+ await onMessage(parsed.msg).catch(() => { });
380
+ }
381
+ });
382
+ });
383
+ }