@nanhara/hara 0.124.0 → 0.124.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.
@@ -1,22 +1,17 @@
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.
1
+ // WeCom (企业微信 / Enterprise WeChat) adapter for `hara gateway`.
7
2
  //
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).
3
+ // Hara connects out to WeCom's AI Bot WebSocket gateway, so no public webhook is required. The transport
4
+ // deliberately stays small, but follows the same lifecycle as WeCom's official Node SDK: subscribe and wait
5
+ // for the auth ACK, monitor heartbeat ACKs, reconnect with bounded backoff, correlate every request, and stop
6
+ // reconnecting when `disconnected_event` says another connection has replaced this one.
13
7
  import { basename, extname } from "node:path";
14
- import { randomUUID, createDecipheriv, createHash } from "node:crypto";
15
- import { chunkText } from "./telegram.js";
16
- import { InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes } from "./media.js";
8
+ import { createDecipheriv, createHash, randomUUID } from "node:crypto";
9
+ import { chunkText, outboundTransferTimeoutMs, PerChatOutboundLane, withOutboundDeadline, } from "./telegram.js";
10
+ import { InboundMediaBudget, cleanupTransientMedia, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
17
11
  const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
18
12
  const WSImpl = globalThis.WebSocket;
19
- // app-level command verbs on the AI Bot gateway (mirror the Hermes constants)
13
+ const WS_CONNECTING = 0;
14
+ const WS_OPEN = 1;
20
15
  const CMD_SUBSCRIBE = "aibot_subscribe";
21
16
  const CMD_CALLBACK = "aibot_msg_callback";
22
17
  const CMD_LEGACY_CALLBACK = "aibot_callback";
@@ -28,18 +23,66 @@ const CMD_UPLOAD_CHUNK = "aibot_upload_media_chunk";
28
23
  const CMD_UPLOAD_FINISH = "aibot_upload_media_finish";
29
24
  const CALLBACK_CMDS = new Set([CMD_CALLBACK, CMD_LEGACY_CALLBACK]);
30
25
  const MAX_MESSAGE_LENGTH = 4000;
31
- const HEARTBEAT_MS = 30000;
32
- const REQUEST_TIMEOUT_MS = 15000;
33
- const UPLOAD_CHUNK_SIZE = 512 * 1024; // 512 KB chunks (WeCom upload protocol)
34
- const ABSOLUTE_MAX_BYTES = 20 * 1024 * 1024; // WeCom hard cap on any upload
35
- const sleep = (ms, signal) => new Promise((r) => {
26
+ const DEFAULT_HEARTBEAT_MS = 30_000;
27
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
28
+ const DEFAULT_RECONNECT_BASE_MS = 1_000;
29
+ const DEFAULT_RECONNECT_MAX_MS = 30_000;
30
+ const DEFAULT_MAX_AUTH_FAILURES = 5;
31
+ const MAX_MISSED_HEARTBEATS = 2;
32
+ const MAX_FRAME_BYTES = 4 * 1024 * 1024;
33
+ const UPLOAD_CHUNK_SIZE = 512 * 1024;
34
+ const MAX_UPLOAD_CHUNK_RETRIES = 2;
35
+ const ABSOLUTE_MAX_BYTES = 20 * 1024 * 1024;
36
+ function boundedInteger(value, fallback, min, max) {
37
+ if (value === undefined || !Number.isFinite(value))
38
+ return fallback;
39
+ return Math.max(min, Math.min(max, Math.trunc(value)));
40
+ }
41
+ function resolveTransportOptions(options = {}) {
42
+ const reconnectBaseMs = boundedInteger(options.reconnectBaseMs, DEFAULT_RECONNECT_BASE_MS, 10, 60_000);
43
+ return {
44
+ requestTimeoutMs: boundedInteger(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 50, 60_000),
45
+ heartbeatMs: boundedInteger(options.heartbeatMs, DEFAULT_HEARTBEAT_MS, 50, 300_000),
46
+ reconnectBaseMs,
47
+ reconnectMaxMs: Math.max(reconnectBaseMs, boundedInteger(options.reconnectMaxMs, DEFAULT_RECONNECT_MAX_MS, 10, 300_000)),
48
+ maxAuthFailures: boundedInteger(options.maxAuthFailures, DEFAULT_MAX_AUTH_FAILURES, 1, 20),
49
+ };
50
+ }
51
+ function signalError(signal, fallback) {
52
+ return signal?.reason instanceof Error ? signal.reason : new Error(fallback);
53
+ }
54
+ function throwIfAborted(signal, label) {
55
+ if (signal?.aborted)
56
+ throw signalError(signal, `${label} cancelled`);
57
+ }
58
+ function waitForDelay(ms, signal) {
36
59
  if (signal?.aborted)
37
- return r();
38
- const t = setTimeout(r, ms);
39
- signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
40
- });
60
+ return Promise.reject(signalError(signal, "WeCom operation cancelled"));
61
+ return new Promise((resolve, reject) => {
62
+ const finish = () => {
63
+ clearTimeout(timer);
64
+ signal?.removeEventListener("abort", onAbort);
65
+ resolve();
66
+ };
67
+ const onAbort = () => {
68
+ clearTimeout(timer);
69
+ signal?.removeEventListener("abort", onAbort);
70
+ reject(signalError(signal, "WeCom operation cancelled"));
71
+ };
72
+ const timer = setTimeout(finish, ms);
73
+ signal?.addEventListener("abort", onAbort, { once: true });
74
+ });
75
+ }
76
+ function reconnectDelay(attempt, options) {
77
+ return Math.min(options.reconnectMaxMs, options.reconnectBaseMs * (2 ** Math.max(0, attempt - 1)));
78
+ }
79
+ function protocolText(value, secret) {
80
+ let text = String(value ?? "unknown error").replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 240);
81
+ if (secret)
82
+ text = text.split(secret).join("[redacted]");
83
+ return text || "unknown error";
84
+ }
41
85
  const isImageName = (name) => /\.(png|jpe?g|gif|webp)$/i.test(name);
42
- /** Detect an image extension from magic bytes (pure) — used when WeCom doesn't give us a filename. */
43
86
  function imageExtFromBytes(data) {
44
87
  if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
45
88
  return ".png";
@@ -51,99 +94,114 @@ function imageExtFromBytes(data) {
51
94
  return ".webp";
52
95
  return ".jpg";
53
96
  }
54
- /** AES-256-CBC decrypt WeCom-encrypted media (pure). The 32-byte key is the base64-decoded `aeskey`; the IV is the
55
- * first 16 bytes of that key; padding is PKCS#7 (validated leniently, like the Hermes reference). */
97
+ /** AES-256-CBC + WeCom's PKCS#7 (block size 32) media decryption. Invalid padding fails closed. */
56
98
  export function decryptWecomMedia(ciphertext, aesKeyB64) {
57
- const key = Buffer.from(aesKeyB64, "base64");
99
+ if (!ciphertext.length)
100
+ throw new Error("WeCom encrypted media is empty");
101
+ if (ciphertext.length % 16 !== 0)
102
+ throw new Error("invalid WeCom encrypted media length");
103
+ const key = Buffer.from(String(aesKeyB64 ?? ""), "base64");
58
104
  if (key.length !== 32)
59
105
  throw new Error(`unexpected WeCom aes_key length: ${key.length} (expected 32)`);
60
- const d = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
61
- d.setAutoPadding(false); // strip PKCS#7 ourselves so a non-conformant tail doesn't throw
62
- const padded = Buffer.concat([d.update(ciphertext), d.final()]);
63
- if (!padded.length)
64
- return padded;
106
+ const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
107
+ decipher.setAutoPadding(false);
108
+ const padded = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
65
109
  const pad = padded[padded.length - 1];
66
- if (pad >= 1 && pad <= 32 && padded.length >= pad) {
67
- let ok = true;
68
- for (let i = padded.length - pad; i < padded.length; i++)
69
- if (padded[i] !== pad)
70
- ok = false;
71
- if (ok)
72
- return padded.subarray(0, padded.length - pad);
110
+ if (pad < 1 || pad > 32 || pad > padded.length)
111
+ throw new Error("invalid WeCom media padding");
112
+ for (let i = padded.length - pad; i < padded.length; i++) {
113
+ if (padded[i] !== pad)
114
+ throw new Error("invalid WeCom media padding");
73
115
  }
74
- return padded;
116
+ return padded.subarray(0, padded.length - pad);
75
117
  }
76
- /** Pull the plain text out of a WeCom callback body (pure). Handles `text`, `voice` (transcription), and `mixed`
77
- * (text + image runs) message types — joining all text runs with newlines. */
78
118
  function extractWecomText(body) {
79
119
  const parts = [];
80
120
  const msgtype = String(body?.msgtype ?? "").toLowerCase();
81
121
  if (msgtype === "mixed") {
82
122
  const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
83
- for (const it of items) {
84
- if (String(it?.msgtype ?? "").toLowerCase() === "text") {
85
- const c = String(it?.text?.content ?? "").trim();
86
- if (c)
87
- parts.push(c);
88
- }
123
+ for (const item of items) {
124
+ if (String(item?.msgtype ?? "").toLowerCase() !== "text")
125
+ continue;
126
+ const content = String(item?.text?.content ?? "").trim();
127
+ if (content)
128
+ parts.push(content);
89
129
  }
90
130
  }
91
131
  else {
92
- const c = String(body?.text?.content ?? "").trim();
93
- if (c)
94
- parts.push(c);
132
+ const content = String(body?.text?.content ?? "").trim();
133
+ if (content)
134
+ parts.push(content);
95
135
  if (msgtype === "voice") {
96
- const v = String(body?.voice?.content ?? "").trim(); // voice transcription, when present
97
- if (v)
98
- parts.push(v);
136
+ const transcript = String(body?.voice?.content ?? "").trim();
137
+ if (transcript)
138
+ parts.push(transcript);
99
139
  }
100
140
  }
101
141
  return parts.join("\n").trim();
102
142
  }
103
- /** Collect downloadable media refs from a WeCom callback body (pure). Covers top-level `image`/`file` and the image
104
- * runs inside a `mixed` message. */
105
143
  function extractWecomMedia(body) {
106
144
  const refs = [];
107
- const pushMedia = (kind, m) => {
108
- if (!m || typeof m !== "object")
145
+ const pushMedia = (kind, media) => {
146
+ if (!media || typeof media !== "object")
109
147
  return;
110
- refs.push({
148
+ const ref = {
111
149
  kind,
112
- url: typeof m.url === "string" ? m.url : undefined,
113
- base64: typeof m.base64 === "string" ? m.base64 : undefined,
114
- aesKeyB64: typeof m.aeskey === "string" ? m.aeskey : undefined,
115
- fileName: typeof m.filename === "string" ? m.filename : typeof m.name === "string" ? m.name : undefined,
116
- });
150
+ url: typeof media.url === "string" ? media.url : undefined,
151
+ base64: typeof media.base64 === "string" ? media.base64 : undefined,
152
+ aesKeyB64: typeof media.aeskey === "string" ? media.aeskey : undefined,
153
+ fileName: typeof media.filename === "string"
154
+ ? media.filename
155
+ : typeof media.name === "string"
156
+ ? media.name
157
+ : undefined,
158
+ };
159
+ // Do not admit metadata-only placeholders into the download budget.
160
+ if (ref.url || ref.base64)
161
+ refs.push(ref);
117
162
  };
118
163
  const msgtype = String(body?.msgtype ?? "").toLowerCase();
119
164
  if (msgtype === "mixed") {
120
165
  const items = Array.isArray(body?.mixed?.msg_item) ? body.mixed.msg_item : [];
121
- for (const it of items)
122
- if (String(it?.msgtype ?? "").toLowerCase() === "image")
123
- pushMedia("image", it.image);
166
+ for (const item of items) {
167
+ if (String(item?.msgtype ?? "").toLowerCase() === "image")
168
+ pushMedia("image", item.image);
169
+ }
124
170
  }
125
171
  else {
126
- if (body?.image)
127
- pushMedia("image", body.image);
128
- if (msgtype === "file" && body?.file)
129
- pushMedia("file", body.file);
172
+ if (msgtype === "image")
173
+ pushMedia("image", body?.image);
174
+ if (msgtype === "file")
175
+ pushMedia("file", body?.file);
176
+ if (msgtype === "video")
177
+ pushMedia("video", body?.video);
130
178
  }
131
179
  return refs;
132
180
  }
133
- /** Parse a WeCom `aibot_msg_callback` payload InboundMsg + its downloadable media refs (pure; the actual download
134
- * happens in start()). `selfBotId` filters out the bot's own echoes. null = ignore (own message / empty / no chat
135
- * id). chatId is the WeCom chatid (group) else the sender's userid (DM); userId/userName are the sender's userid.
136
- * Mirrors parseDiscordMessage. */
181
+ /** WeCom transports `create_time` as seconds today; tolerate millisecond fixtures and future protocol drift. */
182
+ export function wecomTimestampMs(value) {
183
+ const parsed = Number(value);
184
+ if (!Number.isFinite(parsed) || parsed <= 0)
185
+ return undefined;
186
+ return Math.trunc(parsed < 1_000_000_000_000 ? parsed * 1_000 : parsed);
187
+ }
188
+ function stableWecomId(value) {
189
+ if (typeof value === "string")
190
+ return value.trim() || undefined;
191
+ if (Number.isSafeInteger(value))
192
+ return String(value);
193
+ return undefined;
194
+ }
195
+ /** Parse a WeCom callback into the gateway's platform-neutral inbound message. */
137
196
  export function parseWecomMessage(payload, selfBotId) {
138
197
  const body = payload?.body;
139
198
  if (!body || typeof body !== "object")
140
199
  return null;
141
200
  const sender = typeof body.from === "object" && body.from ? body.from : {};
142
201
  const senderId = String(sender.userid ?? "").trim();
143
- // ignore our own messages (the bot speaking) so we never loop on our replies
144
202
  if (selfBotId && senderId && senderId === selfBotId)
145
203
  return null;
146
- if (selfBotId && String(body.bot_id ?? body.botid ?? "").trim() === selfBotId && !senderId)
204
+ if (selfBotId && String(body.aibotid ?? body.bot_id ?? body.botid ?? "").trim() === selfBotId && !senderId)
147
205
  return null;
148
206
  const chatId = String(body.chatid ?? senderId).trim();
149
207
  if (!chatId)
@@ -151,23 +209,27 @@ export function parseWecomMessage(payload, selfBotId) {
151
209
  const text = extractWecomText(body);
152
210
  const media = extractWecomMedia(body);
153
211
  if (!text && !media.length)
154
- return null; // unsupported type or empty
155
- // WeCom AI Bot callbacks use chattype="single" or "group". Only the explicit single value proves a DM;
156
- // missing/unknown types fail closed as group even when chatid happens to be absent.
157
- const chatType = String(body.chattype ?? "").trim().toLowerCase() === "single" ? "p2p" : "group";
212
+ return null;
213
+ const messageId = stableWecomId(body.msgid) ?? stableWecomId(payload?.headers?.req_id);
214
+ const createdAtMs = wecomTimestampMs(body.create_time);
215
+ const marker = media.some((ref) => ref.kind === "image")
216
+ ? "[图片]"
217
+ : media.some((ref) => ref.kind === "video")
218
+ ? "[视频]"
219
+ : "[附件]";
158
220
  return {
159
221
  msg: {
160
222
  chatId,
161
223
  userId: senderId || chatId,
162
224
  userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
163
- text: text || "[图片]",
164
- chatType,
225
+ text: text || marker,
226
+ ...(messageId ? { messageId } : {}),
227
+ ...(createdAtMs === undefined ? {} : { createdAtMs }),
228
+ chatType: String(body.chattype ?? "").trim().toLowerCase() === "single" ? "p2p" : "group",
165
229
  },
166
230
  media,
167
231
  };
168
232
  }
169
- /** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
170
- * failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
171
233
  async function downloadWecomMedia(ref, options) {
172
234
  try {
173
235
  let data = null;
@@ -175,23 +237,25 @@ async function downloadWecomMedia(ref, options) {
175
237
  data = decodeBase64Media(ref.base64, options.maxBytes);
176
238
  }
177
239
  else if (ref.url) {
178
- const r = await fetch(ref.url, { signal: options.signal });
179
- if (!r.ok)
240
+ const response = await fetch(ref.url, { signal: options.signal });
241
+ if (!response.ok)
180
242
  return null;
181
- data = await readResponseBytesLimited(r, options.maxBytes, options.signal);
243
+ data = await readResponseBytesLimited(response, options.maxBytes, options.signal);
182
244
  }
183
- if (!data || !data.length)
245
+ if (!data?.length)
184
246
  return null;
185
247
  if (ref.aesKeyB64)
186
248
  data = decryptWecomMedia(data, ref.aesKeyB64);
187
249
  if (!data.length || data.length > options.maxBytes)
188
250
  return null;
189
251
  let name = ref.fileName ? basename(ref.fileName) : "";
190
- if (!name || (ref.kind === "image" && !extname(name)))
252
+ if (ref.kind === "image" && (!name || !extname(name)))
191
253
  name = `image${imageExtFromBytes(data)}`;
254
+ if (!name)
255
+ name = ref.kind === "video" ? "video.mp4" : "file.bin";
192
256
  return await savePrivateMediaBytes(data, {
193
257
  platform: "wecom",
194
- filenameHint: name || "file.bin",
258
+ filenameHint: name,
195
259
  ...options,
196
260
  });
197
261
  }
@@ -199,192 +263,417 @@ async function downloadWecomMedia(ref, options) {
199
263
  return null;
200
264
  }
201
265
  }
202
- export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
203
- // per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
204
- // it publishes through `conn`.
266
+ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL, transportOptions = {}) {
267
+ if (!botId.trim() || !secret)
268
+ throw new Error("WeCom bot id and secret are required");
269
+ const options = resolveTransportOptions(transportOptions);
205
270
  const conn = { send: null };
206
- // Upload already-verified bytes to WeCom via the 3-step chunked protocol → its media_id.
207
- async function uploadMedia(file, mediaType) {
208
- if (!conn.send)
209
- throw new Error("WeCom socket not connected");
271
+ const outbound = new PerChatOutboundLane("wecom", botId);
272
+ async function uploadMedia(request, file, mediaType, signal) {
210
273
  const data = file.bytes;
274
+ if (!data.length)
275
+ throw new Error("cannot upload an empty file to WeCom");
211
276
  if (data.length > ABSOLUTE_MAX_BYTES)
212
277
  throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
213
- const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
214
- const init = await conn.send(CMD_UPLOAD_INIT, {
278
+ const totalChunks = Math.ceil(data.length / UPLOAD_CHUNK_SIZE);
279
+ const init = await request(CMD_UPLOAD_INIT, {
215
280
  type: mediaType,
216
- filename: file.safeName,
281
+ filename: basename(file.safeName) || "file.bin",
217
282
  total_size: data.length,
218
283
  total_chunks: totalChunks,
219
284
  md5: createHash("md5").update(data).digest("hex"),
220
- });
285
+ }, signal);
221
286
  const uploadId = String(init?.body?.upload_id ?? "").trim();
222
287
  if (!uploadId)
223
- throw new Error(`media upload init returned no upload_id (${JSON.stringify(init?.errmsg ?? init)})`);
224
- for (let i = 0, start = 0; start < data.length || i === 0; i++, start += UPLOAD_CHUNK_SIZE) {
225
- const chunk = data.subarray(start, start + UPLOAD_CHUNK_SIZE);
226
- await conn.send(CMD_UPLOAD_CHUNK, { upload_id: uploadId, chunk_index: i, base64_data: chunk.toString("base64") });
227
- if (start + UPLOAD_CHUNK_SIZE >= data.length)
228
- break;
288
+ throw new Error("WeCom media upload init returned no upload_id");
289
+ for (let index = 0; index < totalChunks; index++) {
290
+ const start = index * UPLOAD_CHUNK_SIZE;
291
+ const chunk = data.subarray(start, Math.min(start + UPLOAD_CHUNK_SIZE, data.length));
292
+ let uploaded = false;
293
+ let lastError;
294
+ for (let attempt = 0; attempt <= MAX_UPLOAD_CHUNK_RETRIES; attempt++) {
295
+ throwIfAborted(signal, "WeCom upload");
296
+ try {
297
+ await request(CMD_UPLOAD_CHUNK, {
298
+ upload_id: uploadId,
299
+ chunk_index: index,
300
+ base64_data: chunk.toString("base64"),
301
+ }, signal);
302
+ uploaded = true;
303
+ break;
304
+ }
305
+ catch (error) {
306
+ lastError = error;
307
+ if (attempt < MAX_UPLOAD_CHUNK_RETRIES)
308
+ await waitForDelay(250 * (attempt + 1), signal);
309
+ }
310
+ }
311
+ if (!uploaded) {
312
+ const reason = lastError instanceof Error ? lastError.message : "unknown transport error";
313
+ throw new Error(`WeCom media chunk ${index} failed after ${MAX_UPLOAD_CHUNK_RETRIES + 1} attempts: ${reason}`);
314
+ }
229
315
  }
230
- const fin = await conn.send(CMD_UPLOAD_FINISH, { upload_id: uploadId });
231
- const mediaId = String(fin?.body?.media_id ?? "").trim();
316
+ const finish = await request(CMD_UPLOAD_FINISH, { upload_id: uploadId }, signal);
317
+ const mediaId = String(finish?.body?.media_id ?? "").trim();
232
318
  if (!mediaId)
233
- throw new Error(`media upload finish returned no media_id (${JSON.stringify(fin?.errmsg ?? fin)})`);
319
+ throw new Error("WeCom media upload finish returned no media_id");
234
320
  return mediaId;
235
321
  }
236
322
  return {
237
323
  name: "wecom",
238
- async send(chatId, text) {
239
- if (!conn.send)
240
- return; // no live socket yet → nothing to reply through
241
- for (const part of chunkText(text || "(empty)", MAX_MESSAGE_LENGTH)) {
242
- await conn
243
- .send(CMD_SEND, { chatid: String(chatId), msgtype: "markdown", markdown: { content: part } })
244
- .catch(() => { });
245
- }
324
+ async send(chatId, text, signal) {
325
+ await withOutboundDeadline("WeCom send", signal, outboundTransferTimeoutMs("text"), async (transferSignal) => {
326
+ return outbound.run(chatId, async () => {
327
+ const request = conn.send;
328
+ if (!request)
329
+ throw new Error("WeCom socket is not authenticated");
330
+ for (const part of chunkText(text || "(empty)", MAX_MESSAGE_LENGTH)) {
331
+ await request(CMD_SEND, {
332
+ chatid: String(chatId),
333
+ msgtype: "markdown",
334
+ markdown: { content: part },
335
+ }, transferSignal);
336
+ }
337
+ });
338
+ });
246
339
  },
247
- async sendFile(chatId, file) {
248
- if (!conn.send)
249
- return;
250
- const mediaType = isImageName(file.safeName) ? "image" : "file";
251
- try {
252
- const mediaId = await uploadMedia(file, mediaType);
253
- await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
254
- }
255
- catch {
256
- /* upload/send failed — caller surfaces a generic failure; nothing else to do */
257
- }
340
+ async sendFile(chatId, file, signal) {
341
+ await withOutboundDeadline("WeCom upload", signal, outboundTransferTimeoutMs("file"), async (transferSignal) => {
342
+ return outbound.run(chatId, async () => {
343
+ const request = conn.send;
344
+ if (!request)
345
+ throw new Error("WeCom socket is not authenticated");
346
+ const mediaType = isImageName(file.safeName) ? "image" : "file";
347
+ const mediaId = await uploadMedia(request, file, mediaType, transferSignal);
348
+ await request(CMD_SEND, {
349
+ chatid: String(chatId),
350
+ msgtype: mediaType,
351
+ [mediaType]: { media_id: mediaId },
352
+ }, transferSignal);
353
+ });
354
+ });
258
355
  },
259
356
  async start(onMessage, signal, shouldDownload) {
260
- if (!WSImpl) {
261
- console.error("hara gateway: WeCom needs Node 22 (global WebSocket). Upgrade Node.");
262
- return;
263
- }
357
+ if (!WSImpl)
358
+ throw new Error("WeCom gateway requires Node 22.12 or newer; upgrade Node and restart Hara");
359
+ let authFailures = 0;
360
+ let reconnectAttempts = 0;
264
361
  while (!signal.aborted) {
265
- await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload);
266
- if (!signal.aborted)
267
- await sleep(3000, signal); // reconnect backoff
362
+ const outcome = await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload, options);
363
+ if (signal.aborted || outcome.reason === "aborted")
364
+ return;
365
+ if (outcome.reason === "superseded") {
366
+ throw new Error("WeCom connection was replaced by another active bot connection; stopped to avoid a reconnect loop");
367
+ }
368
+ let attempt;
369
+ if (outcome.reason === "auth-failed") {
370
+ authFailures++;
371
+ reconnectAttempts = 0;
372
+ if (authFailures >= options.maxAuthFailures) {
373
+ throw new Error(`WeCom authentication failed ${authFailures} time(s); check HARA_WECOM_BOT_ID and HARA_WECOM_SECRET`);
374
+ }
375
+ attempt = authFailures;
376
+ console.error(`hara wecom: authentication failed (attempt ${authFailures}/${options.maxAuthFailures}); reconnecting.`);
377
+ }
378
+ else {
379
+ authFailures = 0;
380
+ reconnectAttempts = outcome.authenticated ? 1 : reconnectAttempts + 1;
381
+ attempt = reconnectAttempts;
382
+ console.error(`hara wecom: ${outcome.detail ?? "connection closed"}; reconnecting.`);
383
+ }
384
+ await waitForDelay(reconnectDelay(attempt, options), signal).catch(() => undefined);
268
385
  }
269
386
  },
270
387
  };
271
388
  }
272
- /** One gateway connection: open WS `aibot_subscribe` auth → correlate request/response by req_id, dispatch
273
- * `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
274
- * subscribe each time — v1 keeps it simple, no resume). */
275
- function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload) {
389
+ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload, options) {
276
390
  return new Promise((resolve) => {
277
391
  const ws = new WSImpl(wsUrl);
278
- let hb = null;
279
392
  const pending = new Map();
280
- const seen = new Set(); // msgid dedup within this connection (network hiccups can re-deliver)
281
- const sendRaw = (frame) => {
393
+ const heartbeatIds = new Set();
394
+ let heartbeatTimer = null;
395
+ let authTimer = null;
396
+ let authReqId = "";
397
+ let missedHeartbeats = 0;
398
+ let authenticated = false;
399
+ let settled = false;
400
+ const closeSocket = () => {
282
401
  try {
283
- ws.send(JSON.stringify(frame));
402
+ if (ws.readyState === WS_CONNECTING || ws.readyState === WS_OPEN)
403
+ ws.close();
284
404
  }
285
405
  catch {
286
- /* socket gone — close handler resolves */
406
+ /* already closed */
287
407
  }
288
408
  };
289
- // Send a request and await the frame whose headers.req_id matches; times out so callers never hang.
290
- const request = (cmd, body) => new Promise((res) => {
291
- const reqId = `${cmd}-${randomUUID()}`;
292
- const timer = setTimeout(() => {
293
- pending.delete(reqId);
294
- res({ errcode: -1, errmsg: "timeout" });
295
- }, REQUEST_TIMEOUT_MS);
296
- pending.set(reqId, { resolve: res, timer });
297
- sendRaw({ cmd, headers: { req_id: reqId }, body });
298
- });
299
- const cleanup = () => {
300
- if (hb)
301
- clearInterval(hb);
409
+ const detachPending = (entry) => {
410
+ clearTimeout(entry.timer);
411
+ if (entry.signal && entry.onAbort)
412
+ entry.signal.removeEventListener("abort", entry.onAbort);
413
+ };
414
+ const cleanup = (reason) => {
415
+ if (heartbeatTimer)
416
+ clearInterval(heartbeatTimer);
417
+ if (authTimer)
418
+ clearTimeout(authTimer);
419
+ heartbeatTimer = null;
420
+ authTimer = null;
421
+ heartbeatIds.clear();
302
422
  signal.removeEventListener("abort", stop);
303
- for (const { resolve: r, timer } of pending.values()) {
304
- clearTimeout(timer);
305
- r({ errcode: -1, errmsg: "connection closed" });
423
+ for (const entry of pending.values()) {
424
+ detachPending(entry);
425
+ entry.reject(reason);
306
426
  }
307
427
  pending.clear();
308
428
  if (conn.send === request)
309
429
  conn.send = null;
310
430
  };
431
+ const finish = (reason, detail) => {
432
+ if (settled)
433
+ return;
434
+ settled = true;
435
+ cleanup(new Error(detail ?? "WeCom connection closed"));
436
+ if (reason !== "closed")
437
+ closeSocket();
438
+ resolve({ reason, authenticated, ...(detail ? { detail } : {}) });
439
+ };
311
440
  const stop = () => {
312
- cleanup();
441
+ finish("aborted", "WeCom gateway stopped");
442
+ closeSocket();
443
+ };
444
+ const sendRaw = (frame) => {
445
+ if (settled || ws.readyState !== WS_OPEN)
446
+ throw new Error("WeCom socket is not open");
447
+ ws.send(JSON.stringify(frame));
448
+ };
449
+ const request = (command, body, requestSignal) => {
450
+ if (settled || !authenticated || ws.readyState !== WS_OPEN) {
451
+ return Promise.reject(new Error("WeCom socket is not authenticated"));
452
+ }
453
+ if (requestSignal?.aborted)
454
+ return Promise.reject(signalError(requestSignal, `WeCom ${command} cancelled`));
455
+ const reqId = `${command}-${randomUUID()}`;
456
+ return new Promise((resolveRequest, rejectRequest) => {
457
+ const onAbort = () => {
458
+ const entry = pending.get(reqId);
459
+ if (!entry)
460
+ return;
461
+ pending.delete(reqId);
462
+ detachPending(entry);
463
+ rejectRequest(signalError(requestSignal, `WeCom ${command} cancelled`));
464
+ };
465
+ const timer = setTimeout(() => {
466
+ const entry = pending.get(reqId);
467
+ if (!entry)
468
+ return;
469
+ pending.delete(reqId);
470
+ detachPending(entry);
471
+ rejectRequest(new Error(`WeCom ${command} timed out after ${options.requestTimeoutMs}ms`));
472
+ }, options.requestTimeoutMs);
473
+ const entry = {
474
+ command,
475
+ resolve: resolveRequest,
476
+ reject: rejectRequest,
477
+ timer,
478
+ signal: requestSignal,
479
+ onAbort,
480
+ };
481
+ pending.set(reqId, entry);
482
+ requestSignal?.addEventListener("abort", onAbort, { once: true });
483
+ try {
484
+ sendRaw({ cmd: command, headers: { req_id: reqId }, body });
485
+ }
486
+ catch (error) {
487
+ pending.delete(reqId);
488
+ detachPending(entry);
489
+ rejectRequest(error instanceof Error ? error : new Error("WeCom request send failed"));
490
+ }
491
+ });
492
+ };
493
+ const settleRequest = (reqId, frame) => {
494
+ const entry = pending.get(reqId);
495
+ if (!entry)
496
+ return false;
497
+ pending.delete(reqId);
498
+ detachPending(entry);
499
+ const code = Number(frame?.errcode);
500
+ if (code !== 0) {
501
+ entry.reject(new Error(`WeCom ${entry.command} failed (code ${Number.isFinite(code) ? code : "invalid"}): ${protocolText(frame?.errmsg, secret)}`));
502
+ }
503
+ else {
504
+ entry.resolve(frame);
505
+ }
506
+ return true;
507
+ };
508
+ const handleCallback = async (frame) => {
509
+ if (signal.aborted || !authenticated)
510
+ return;
511
+ const parsed = parseWecomMessage(frame, botId);
512
+ if (!parsed)
513
+ return;
514
+ const transientFiles = [];
515
+ let handedOff = false;
313
516
  try {
314
- ws.close();
517
+ let text = parsed.msg.text;
518
+ const images = [];
519
+ if (shouldDownload?.(parsed.msg) === true && parsed.media.length) {
520
+ const budget = new InboundMediaBudget("wecom", signal);
521
+ for (const ref of parsed.media) {
522
+ const path = await budget.download((downloadOptions) => downloadWecomMedia(ref, downloadOptions));
523
+ if (!path)
524
+ continue;
525
+ transientFiles.push(path);
526
+ if (ref.kind === "image") {
527
+ images.push(path);
528
+ if (text !== "[图片]" && !text.includes("[图片]"))
529
+ text += `${text ? "\n" : ""}[图片]`;
530
+ }
531
+ else {
532
+ const filename = ref.fileName ? basename(ref.fileName) : "";
533
+ const label = ref.kind === "video"
534
+ ? `视频${filename ? ` ${filename}` : ""}`
535
+ : `文件${filename ? ` ${filename}` : ""}`;
536
+ const marker = `[${label}: ${path}]`;
537
+ text = text === "[附件]" || text === "[视频]" ? marker : `${text}${text ? "\n" : ""}${marker}`;
538
+ }
539
+ }
540
+ }
541
+ if (signal.aborted)
542
+ return;
543
+ const inbound = {
544
+ ...parsed.msg,
545
+ text: text.trim() || parsed.msg.text,
546
+ images: images.length ? images : undefined,
547
+ transientFiles: transientFiles.length ? transientFiles : undefined,
548
+ };
549
+ handedOff = true;
550
+ await onMessage(inbound);
315
551
  }
316
- catch {
317
- /* already closing */
552
+ finally {
553
+ if (!handedOff && transientFiles.length)
554
+ await cleanupTransientMedia("wecom", transientFiles);
318
555
  }
319
- resolve();
320
556
  };
321
- signal.addEventListener("abort", stop, { once: true });
322
- ws.addEventListener("open", () => {
323
- // Authenticate, then publish this socket's request() so send/sendFile can use it; heartbeat keeps it alive.
324
- sendRaw({ cmd: CMD_SUBSCRIBE, headers: { req_id: `subscribe-${randomUUID()}` }, body: { bot_id: botId, secret } });
325
- conn.send = request;
326
- hb = setInterval(() => sendRaw({ cmd: CMD_PING, headers: { req_id: `ping-${randomUUID()}` }, body: {} }), HEARTBEAT_MS);
327
- });
328
- ws.addEventListener("close", () => {
329
- cleanup();
330
- resolve();
331
- });
332
- ws.addEventListener("error", () => { });
333
- ws.addEventListener("message", async (ev) => {
334
- // Native WebSocket may hand us a string, an ArrayBuffer, or a Blob — normalize to text first.
557
+ const handleMessage = async (event) => {
558
+ const data = event?.data;
559
+ if (typeof data === "string" && Buffer.byteLength(data, "utf8") > MAX_FRAME_BYTES) {
560
+ finish("closed", "received an oversized WeCom frame");
561
+ closeSocket();
562
+ return;
563
+ }
564
+ if (data instanceof ArrayBuffer && data.byteLength > MAX_FRAME_BYTES) {
565
+ finish("closed", "received an oversized WeCom frame");
566
+ closeSocket();
567
+ return;
568
+ }
569
+ if (typeof data?.size === "number" && data.size > MAX_FRAME_BYTES) {
570
+ finish("closed", "received an oversized WeCom frame");
571
+ closeSocket();
572
+ return;
573
+ }
335
574
  let raw;
336
- const d = ev?.data;
337
- if (typeof d === "string")
338
- raw = d;
339
- else if (d instanceof ArrayBuffer)
340
- raw = Buffer.from(d).toString("utf8");
341
- else if (typeof d?.arrayBuffer === "function")
342
- raw = Buffer.from(await d.arrayBuffer()).toString("utf8");
575
+ if (typeof data === "string")
576
+ raw = data;
577
+ else if (data instanceof ArrayBuffer)
578
+ raw = Buffer.from(data).toString("utf8");
579
+ else if (typeof data?.arrayBuffer === "function")
580
+ raw = Buffer.from(await data.arrayBuffer()).toString("utf8");
343
581
  else
344
- raw = String(d ?? "");
345
- let p;
582
+ raw = String(data ?? "");
583
+ let frame;
346
584
  try {
347
- p = JSON.parse(raw);
585
+ frame = JSON.parse(raw);
348
586
  }
349
587
  catch {
350
588
  return;
351
589
  }
352
- const cmd = String(p?.cmd ?? "");
353
- const reqId = String(p?.headers?.req_id ?? "");
354
- // A correlated response to one of our requests (and not itself a callback) → resolve the waiter.
355
- if (reqId && pending.has(reqId) && !CALLBACK_CMDS.has(cmd)) {
356
- const waiter = pending.get(reqId);
357
- clearTimeout(waiter.timer);
358
- pending.delete(reqId);
359
- waiter.resolve(p);
590
+ const cmd = String(frame?.cmd ?? "");
591
+ const reqId = String(frame?.headers?.req_id ?? "");
592
+ if (reqId && reqId === authReqId) {
593
+ if (authenticated)
594
+ return; // a duplicate auth ACK must not install a second heartbeat timer
595
+ if (authTimer)
596
+ clearTimeout(authTimer);
597
+ authTimer = null;
598
+ if (Number(frame?.errcode) !== 0) {
599
+ finish("auth-failed", `WeCom authentication failed (code ${Number.isFinite(Number(frame?.errcode)) ? Number(frame.errcode) : "invalid"}): ${protocolText(frame?.errmsg, secret)}`);
600
+ return;
601
+ }
602
+ authenticated = true;
603
+ missedHeartbeats = 0;
604
+ conn.send = request;
605
+ console.error("hara wecom: authenticated.");
606
+ heartbeatTimer = setInterval(() => {
607
+ if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
608
+ finish("closed", `heartbeat ACK timed out after ${missedHeartbeats} missed ping(s)`);
609
+ closeSocket();
610
+ return;
611
+ }
612
+ const heartbeatId = `${CMD_PING}-${randomUUID()}`;
613
+ missedHeartbeats++;
614
+ heartbeatIds.add(heartbeatId);
615
+ try {
616
+ sendRaw({ cmd: CMD_PING, headers: { req_id: heartbeatId }, body: {} });
617
+ }
618
+ catch {
619
+ finish("closed", "heartbeat send failed");
620
+ closeSocket();
621
+ }
622
+ }, options.heartbeatMs);
623
+ return;
624
+ }
625
+ if (reqId && heartbeatIds.has(reqId)) {
626
+ heartbeatIds.delete(reqId);
627
+ if (Number(frame?.errcode) === 0) {
628
+ missedHeartbeats = 0;
629
+ heartbeatIds.clear();
630
+ }
360
631
  return;
361
632
  }
362
- if (cmd === CMD_PING || cmd === CMD_EVENT_CALLBACK)
363
- return; // server ping / event ack → ignore
633
+ if (reqId && !CALLBACK_CMDS.has(cmd) && cmd !== CMD_EVENT_CALLBACK && settleRequest(reqId, frame))
634
+ return;
635
+ if (!authenticated)
636
+ return;
637
+ if (cmd === CMD_EVENT_CALLBACK) {
638
+ if (String(frame?.body?.event?.eventtype ?? "") === "disconnected_event") {
639
+ finish("superseded", "another active WeCom connection replaced this one");
640
+ }
641
+ return;
642
+ }
643
+ if (cmd === CMD_PING)
644
+ return;
364
645
  if (!CALLBACK_CMDS.has(cmd))
365
- return; // pre-auth / unknown frame → ignore
366
- const msgId = String(p?.body?.msgid ?? reqId ?? "");
367
- if (msgId) {
368
- if (seen.has(msgId))
369
- return; // duplicate delivery → drop
370
- seen.add(msgId);
371
- if (seen.size > 1000)
372
- seen.clear();
646
+ return;
647
+ await handleCallback(frame);
648
+ };
649
+ signal.addEventListener("abort", stop, { once: true });
650
+ ws.addEventListener("open", () => {
651
+ authReqId = `${CMD_SUBSCRIBE}-${randomUUID()}`;
652
+ authTimer = setTimeout(() => {
653
+ finish("auth-failed", `WeCom authentication ACK timed out after ${options.requestTimeoutMs}ms`);
654
+ closeSocket();
655
+ }, options.requestTimeoutMs);
656
+ try {
657
+ sendRaw({
658
+ cmd: CMD_SUBSCRIBE,
659
+ headers: { req_id: authReqId },
660
+ body: { bot_id: botId, secret },
661
+ });
373
662
  }
374
- const parsed = parseWecomMessage(p, botId);
375
- if (parsed) {
376
- if (shouldDownload?.(parsed.msg) === true) {
377
- const budget = new InboundMediaBudget("wecom", signal);
378
- for (const ref of parsed.media) {
379
- const path = await budget.download((options) => downloadWecomMedia(ref, options));
380
- if (path) {
381
- (parsed.msg.images ??= []).push(path);
382
- (parsed.msg.transientFiles ??= []).push(path);
383
- }
384
- }
385
- }
386
- await onMessage(parsed.msg).catch(() => { });
663
+ catch {
664
+ finish("closed", "could not send the WeCom authentication frame");
665
+ closeSocket();
387
666
  }
388
667
  });
668
+ ws.addEventListener("message", (event) => {
669
+ void handleMessage(event).catch((error) => {
670
+ console.error(`hara wecom: callback handling failed — ${protocolText(error instanceof Error ? error.message : error, secret)}`);
671
+ });
672
+ });
673
+ ws.addEventListener("close", () => finish("closed", "connection closed"));
674
+ ws.addEventListener("error", () => {
675
+ finish("closed", "WebSocket transport error");
676
+ closeSocket();
677
+ });
389
678
  });
390
679
  }