@nanhara/hara 0.121.0 → 0.122.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -11,11 +11,11 @@
11
11
  // EXTERNAL DEPENDENCY: signal-cli is NOT bundled — it's a separate program the user installs and runs (it is the
12
12
  // only way to speak Signal's protocol; there is no official cloud API). The adapter just speaks HTTP/JSON-RPC to
13
13
  // the daemon the user has running. See setup notes at the bottom of this file.
14
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
- import { join, basename } from "node:path";
16
- import { homedir } from "node:os";
17
14
  import { chunkText } from "./telegram.js";
15
+ import { INBOUND_MEDIA_MAX_BYTES, InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
18
16
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ // Base64 expands by 4/3; leave a small fixed budget for the JSON-RPC envelope without permitting an unlimited body.
18
+ const ATTACHMENT_RPC_MAX_BYTES = Math.ceil(INBOUND_MEDIA_MAX_BYTES / 3) * 4 + 64 * 1024;
19
19
  // E.164 phone numbers (+15551234567) anywhere in a string → +155****4567 (mirrors hermes' _redact_phone).
20
20
  const PHONE_RE = /\+[1-9]\d{6,14}/g;
21
21
  /** Redact phone numbers for logging. Keeps a 4/4 head+tail window; never logs the full E.164. (pure) */
@@ -85,7 +85,10 @@ export function parseSignalMessage(raw, selfNumber) {
85
85
  if (!data || typeof data !== "object")
86
86
  return null;
87
87
  const group = data.groupInfo;
88
- const groupId = group?.groupId;
88
+ // signal-cli omits (or nulls) groupInfo for a direct envelope. A present non-null but malformed group object
89
+ // remains group-classified even without an id, which is the conservative behavior during protocol drift.
90
+ const isGroup = group != null;
91
+ const groupId = typeof group?.groupId === "string" && group.groupId.trim() ? group.groupId.trim() : undefined;
89
92
  const chatId = groupId ? `group:${groupId}` : String(sender);
90
93
  const atts = Array.isArray(data.attachments) ? data.attachments : [];
91
94
  const images = atts
@@ -100,6 +103,7 @@ export function parseSignalMessage(raw, selfNumber) {
100
103
  userId: String(sender),
101
104
  userName: env.sourceName || String(sender),
102
105
  text: text || "[图片]",
106
+ chatType: isGroup ? "group" : "p2p",
103
107
  },
104
108
  images,
105
109
  };
@@ -108,7 +112,7 @@ export function signalAdapter(rpcUrl, selfNumber) {
108
112
  const base = rpcUrl.replace(/\/+$/, ""); // trim trailing slashes
109
113
  let rpcSeq = 0;
110
114
  /** One JSON-RPC 2.0 call to the signal-cli daemon. Returns `result` (any) or null on error. */
111
- async function rpc(method, params, signal) {
115
+ async function rpc(method, params, signal, responseLimit) {
112
116
  const id = `${method}_${++rpcSeq}`;
113
117
  try {
114
118
  const res = await fetch(`${base}/api/v1/rpc`, {
@@ -119,7 +123,9 @@ export function signalAdapter(rpcUrl, selfNumber) {
119
123
  });
120
124
  if (!res.ok)
121
125
  return null;
122
- const j = (await res.json());
126
+ const j = (responseLimit
127
+ ? JSON.parse((await readResponseBytesLimited(res, responseLimit, signal)).toString("utf8"))
128
+ : await res.json());
123
129
  if (j.error) {
124
130
  console.error(`hara gateway[signal]: RPC ${method} error:`, redactPhone(JSON.stringify(j.error)));
125
131
  return null;
@@ -138,22 +144,19 @@ export function signalAdapter(rpcUrl, selfNumber) {
138
144
  return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
139
145
  };
140
146
  /** Fetch a signal-cli attachment by id (base64 over JSON-RPC) → a local path under ~/.hara/signal/media. */
141
- async function downloadAttachment(ref) {
147
+ async function downloadAttachment(ref, options) {
142
148
  try {
143
- const result = await rpc("getAttachment", { account: selfNumber, id: ref.id });
149
+ const encodedLimit = Math.ceil(options.maxBytes / 3) * 4 + 64 * 1024;
150
+ const result = await rpc("getAttachment", { account: selfNumber, id: ref.id }, options.signal, Math.min(encodedLimit, ATTACHMENT_RPC_MAX_BYTES));
144
151
  // signal-cli returns either a raw base64 string or { data: "base64..." }
145
152
  const b64 = typeof result === "string" ? result : result && typeof result === "object" ? result.data : null;
146
153
  if (!b64 || typeof b64 !== "string")
147
154
  return null;
148
- const bytes = Buffer.from(b64, "base64");
155
+ const bytes = decodeBase64Media(b64, options.maxBytes);
149
156
  let ext = attachmentExt(ref.contentType, ref.filename);
150
157
  if (ext === ".bin")
151
158
  ext = extFromBytes(bytes); // last-resort magic-byte sniff
152
- const dir = join(homedir(), ".hara", "signal", "media");
153
- mkdirSync(dir, { recursive: true });
154
- const path = join(dir, `sig_${Date.now()}_${ref.id.slice(-12)}${ext}`);
155
- writeFileSync(path, bytes);
156
- return path;
159
+ return await savePrivateMediaBytes(bytes, { platform: "signal", filenameHint: `attachment${ext}`, ...options });
157
160
  }
158
161
  catch {
159
162
  return null;
@@ -170,11 +173,8 @@ export function signalAdapter(rpcUrl, selfNumber) {
170
173
  // Signal has no separate photo/document endpoints — everything is an `attachments` path on `send`.
171
174
  // signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
172
175
  await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
173
- // (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
174
- void readFileSync;
175
- void basename;
176
176
  },
177
- async start(onMessage, signal) {
177
+ async start(onMessage, signal, shouldDownload) {
178
178
  console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
179
179
  while (!signal.aborted) {
180
180
  try {
@@ -186,10 +186,15 @@ export function signalAdapter(rpcUrl, selfNumber) {
186
186
  const parsed = parseSignalMessage(raw, selfNumber);
187
187
  if (!parsed)
188
188
  continue;
189
- for (const ref of parsed.images) {
190
- const path = await downloadAttachment(ref);
191
- if (path)
192
- (parsed.msg.images ??= []).push(path);
189
+ if (shouldDownload?.(parsed.msg) === true) {
190
+ const budget = new InboundMediaBudget("signal", signal);
191
+ for (const ref of parsed.images) {
192
+ const path = await budget.download((options) => downloadAttachment(ref, options));
193
+ if (path) {
194
+ (parsed.msg.images ??= []).push(path);
195
+ (parsed.msg.transientFiles ??= []).push(path);
196
+ }
197
+ }
193
198
  }
194
199
  await onMessage(parsed.msg).catch(() => { });
195
200
  }
@@ -4,9 +4,9 @@
4
4
  // API (bot token, xoxb-). Same ChatAdapter shape as Discord/Telegram, so all cross-platform gateway plumbing
5
5
  // (send_file, in-chat system context, stuck-guard, image attach/describe) works unchanged. Two tokens are
6
6
  // required because Socket Mode (xapp-) and the Web API (xoxb-) are separate auth scopes.
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
- import { join, basename } from "node:path";
9
- import { homedir } from "node:os";
7
+ import { readFileSync } from "node:fs";
8
+ import { basename } from "node:path";
9
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
10
10
  import { chunkText } from "./telegram.js";
11
11
  const WEB = "https://slack.com/api";
12
12
  const WSImpl = globalThis.WebSocket;
@@ -34,16 +34,12 @@ async function slackApi(method, token, body) {
34
34
  }
35
35
  /** Slack file url_private is gated — it needs the BOT token as a Bearer header (a plain GET returns the login
36
36
  * page HTML, not the bytes). Download into ~/.hara/slack/media so the agent can SEE it. */
37
- async function downloadSlackFile(url, name, botToken) {
37
+ async function downloadSlackFile(url, name, botToken, options) {
38
38
  try {
39
- const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } });
39
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` }, signal: options.signal });
40
40
  if (!r.ok)
41
41
  return null;
42
- const dir = join(homedir(), ".hara", "slack", "media");
43
- mkdirSync(dir, { recursive: true });
44
- const path = join(dir, `sl_${Date.now()}_${basename(name) || "file.bin"}`);
45
- writeFileSync(path, Buffer.from(await r.arrayBuffer()));
46
- return path;
42
+ return await savePrivateResponse(r, { platform: "slack", filenameHint: name, ...options });
47
43
  }
48
44
  catch {
49
45
  return null;
@@ -52,6 +48,13 @@ async function downloadSlackFile(url, name, botToken) {
52
48
  /** Parse a Slack Events API `event` (the inner `payload.event`) → InboundMsg + its image file refs (pure;
53
49
  * download happens in start()). null = ignore (not a user message / our own / an edit/delete subtype / empty).
54
50
  * Mirrors discord.ts's parseDiscordMessage so it's unit-testable without the network. */
51
+ export function slackChatType(event) {
52
+ const channel = String(event?.channel ?? "").trim();
53
+ // Slack reserves D-prefixed ids for direct-message channels. It is stronger than a contradictory event field.
54
+ if (channel.startsWith("D"))
55
+ return "p2p";
56
+ return String(event?.channel_type ?? "").trim().toLowerCase() === "im" ? "p2p" : "group";
57
+ }
55
58
  export function parseSlackEvent(event, selfId) {
56
59
  if (event?.type !== "message")
57
60
  return null;
@@ -76,6 +79,7 @@ export function parseSlackEvent(event, selfId) {
76
79
  userId: String(event.user),
77
80
  userName: String(event.user), // Slack events carry only the id; users.info would resolve a name (skipped for zero extra calls)
78
81
  text: text || "[图片]",
82
+ chatType: slackChatType(event),
79
83
  },
80
84
  imageUrls,
81
85
  };
@@ -107,7 +111,7 @@ export function slackAdapter(appToken, botToken) {
107
111
  /* upload/send failed — surfaced upstream as "no file delivered" */
108
112
  }
109
113
  },
110
- async start(onMessage, signal) {
114
+ async start(onMessage, signal, shouldDownload) {
111
115
  if (!WSImpl) {
112
116
  console.error("hara gateway: Slack needs Node ≥ 22 (global WebSocket). Upgrade Node.");
113
117
  return;
@@ -118,7 +122,7 @@ export function slackAdapter(appToken, botToken) {
118
122
  while (!signal.aborted) {
119
123
  const wss = await openSocketUrl(appToken);
120
124
  if (wss)
121
- await connectOnce(wss, botToken, selfId, onMessage, signal);
125
+ await connectOnce(wss, botToken, selfId, onMessage, signal, shouldDownload);
122
126
  if (!signal.aborted)
123
127
  await sleep(3000, signal); // reconnect backoff (and re-open: each URL is single-use)
124
128
  }
@@ -133,7 +137,7 @@ async function openSocketUrl(appToken) {
133
137
  /** One Socket Mode connection: receive envelopes, ACK each by echoing its envelope_id, and dispatch message
134
138
  * events. Resolves on close/disconnect/abort; the caller re-opens a new URL and reconnects. v1 keeps it simple —
135
139
  * no buffered-payload backpressure (the daemon spawns one hara per message anyway). */
136
- function connectOnce(url, botToken, selfId, onMessage, signal) {
140
+ function connectOnce(url, botToken, selfId, onMessage, signal, shouldDownload) {
137
141
  return new Promise((resolve) => {
138
142
  const ws = new WSImpl(url);
139
143
  const stop = () => {
@@ -176,10 +180,15 @@ function connectOnce(url, botToken, selfId, onMessage, signal) {
176
180
  if (p.type === "events_api") {
177
181
  const parsed = parseSlackEvent(p.payload?.event, selfId);
178
182
  if (parsed) {
179
- for (const im of parsed.imageUrls) {
180
- const path = await downloadSlackFile(im.url, im.name, botToken);
181
- if (path)
182
- (parsed.msg.images ??= []).push(path);
183
+ if (shouldDownload?.(parsed.msg) === true) {
184
+ const budget = new InboundMediaBudget("slack", signal);
185
+ for (const im of parsed.imageUrls) {
186
+ const path = await budget.download((options) => downloadSlackFile(im.url, im.name, botToken, options));
187
+ if (path) {
188
+ (parsed.msg.images ??= []).push(path);
189
+ (parsed.msg.transientFiles ??= []).push(path);
190
+ }
191
+ }
183
192
  }
184
193
  await onMessage(parsed.msg).catch(() => { });
185
194
  }
@@ -1,9 +1,9 @@
1
1
  // Telegram adapter for `hara gateway` — long-poll getUpdates + sendMessage over the Bot API (built-in
2
2
  // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
3
  // / Feishu plug into next.
4
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
- import { join, basename } from "node:path";
6
- import { homedir } from "node:os";
4
+ import { readFileSync } from "node:fs";
5
+ import { basename } from "node:path";
6
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
7
7
  const API = "https://api.telegram.org";
8
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
9
  /** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
@@ -21,6 +21,7 @@ export function parseTelegramUpdate(u) {
21
21
  userId: m.from?.id ?? 0,
22
22
  userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
23
23
  text: text || "[图片]",
24
+ ...(m.chat?.type === "private" ? { chatType: "p2p" } : ["group", "supergroup"].includes(m.chat?.type) ? { chatType: "group" } : {}),
24
25
  };
25
26
  }
26
27
  /** The largest photo's file_id (Telegram sends `photo` as an ascending size array) (pure). null if none. */
@@ -29,21 +30,17 @@ export function photoFileId(u) {
29
30
  return Array.isArray(photo) && photo.length ? (photo[photo.length - 1]?.file_id ?? null) : null;
30
31
  }
31
32
  /** Download a Telegram file by file_id → a local path under ~/.hara/telegram/media (getFile then the file CDN). */
32
- async function downloadTelegramFile(base, token, fileId) {
33
+ async function downloadTelegramFile(base, token, fileId, options) {
33
34
  try {
34
- const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
35
+ const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`, { signal: options.signal });
35
36
  const j = (await r.json());
36
37
  const fp = j?.result?.file_path;
37
38
  if (!fp)
38
39
  return null;
39
- const dl = await fetch(`${API}/file/bot${token}/${fp}`);
40
+ const dl = await fetch(`${API}/file/bot${token}/${fp}`, { signal: options.signal });
40
41
  if (!dl.ok)
41
42
  return null;
42
- const dir = join(homedir(), ".hara", "telegram", "media");
43
- mkdirSync(dir, { recursive: true });
44
- const path = join(dir, `tg_${Date.now()}_${basename(fp)}`);
45
- writeFileSync(path, Buffer.from(await dl.arrayBuffer()));
46
- return path;
43
+ return await savePrivateResponse(dl, { platform: "telegram", filenameHint: fp, ...options });
47
44
  }
48
45
  catch {
49
46
  return null;
@@ -60,15 +57,29 @@ export function chunkText(text, max = 4000) {
60
57
  }
61
58
  export function telegramAdapter(token) {
62
59
  const base = `${API}/bot${token}`;
60
+ const request = async (method, init) => {
61
+ const response = await fetch(`${base}/${method}`, init);
62
+ let body;
63
+ try {
64
+ body = await response.json();
65
+ }
66
+ catch {
67
+ body = undefined;
68
+ }
69
+ if (!response.ok || body?.ok === false) {
70
+ throw new Error(`Telegram ${method} failed: HTTP ${response.status}${body?.description ? ` · ${body.description}` : ""}`);
71
+ }
72
+ return body;
73
+ };
63
74
  return {
64
75
  name: "telegram",
65
76
  async send(chatId, text) {
66
77
  for (const part of chunkText(text || "(empty)")) {
67
- await fetch(`${base}/sendMessage`, {
78
+ await request("sendMessage", {
68
79
  method: "POST",
69
80
  headers: { "content-type": "application/json" },
70
81
  body: JSON.stringify({ chat_id: chatId, text: part }),
71
- }).catch(() => { });
82
+ });
72
83
  }
73
84
  },
74
85
  async sendFile(chatId, filePath) {
@@ -78,9 +89,9 @@ export function telegramAdapter(token) {
78
89
  const form = new FormData();
79
90
  form.append("chat_id", String(chatId));
80
91
  form.append(isImg ? "photo" : "document", new Blob([readFileSync(filePath)]), name);
81
- await fetch(`${base}/${isImg ? "sendPhoto" : "sendDocument"}`, { method: "POST", body: form }).catch(() => { });
92
+ await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
82
93
  },
83
- async start(onMessage, signal) {
94
+ async start(onMessage, signal, shouldDownload) {
84
95
  let offset = 0;
85
96
  while (!signal.aborted) {
86
97
  try {
@@ -96,10 +107,13 @@ export function telegramAdapter(token) {
96
107
  if (!msg)
97
108
  continue;
98
109
  const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
99
- if (fid) {
100
- const path = await downloadTelegramFile(base, token, fid);
101
- if (path)
110
+ if (fid && shouldDownload?.(msg) === true) {
111
+ const budget = new InboundMediaBudget("telegram", signal);
112
+ const path = await budget.download((options) => downloadTelegramFile(base, token, fid, options));
113
+ if (path) {
102
114
  msg.images = [path];
115
+ msg.transientFiles = [path];
116
+ }
103
117
  }
104
118
  await onMessage(msg).catch(() => { });
105
119
  }
@@ -10,11 +10,11 @@
10
10
  // chunked protocol → AES-256-CBC decrypt inbound media that carries an `aeskey`. Every request frame carries a
11
11
  // `headers.req_id` and the server echoes it back so request/response are correlated. v1 limitations are documented
12
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";
13
+ import { readFileSync } from "node:fs";
14
+ import { basename, extname } from "node:path";
16
15
  import { randomUUID, createDecipheriv, createHash } from "node:crypto";
17
16
  import { chunkText } from "./telegram.js";
17
+ import { InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes } from "./media.js";
18
18
  const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
19
19
  const WSImpl = globalThis.WebSocket;
20
20
  // app-level command verbs on the AI Bot gateway (mirror the Hermes constants)
@@ -153,45 +153,48 @@ export function parseWecomMessage(payload, selfBotId) {
153
153
  const media = extractWecomMedia(body);
154
154
  if (!text && !media.length)
155
155
  return null; // unsupported type or empty
156
+ // WeCom AI Bot callbacks use chattype="single" or "group". Only the explicit single value proves a DM;
157
+ // missing/unknown types fail closed as group even when chatid happens to be absent.
158
+ const chatType = String(body.chattype ?? "").trim().toLowerCase() === "single" ? "p2p" : "group";
156
159
  return {
157
160
  msg: {
158
161
  chatId,
159
162
  userId: senderId || chatId,
160
163
  userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
161
164
  text: text || "[图片]",
165
+ chatType,
162
166
  },
163
167
  media,
164
168
  };
165
169
  }
166
170
  /** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
167
171
  * failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
168
- async function downloadWecomMedia(ref) {
172
+ async function downloadWecomMedia(ref, options) {
169
173
  try {
170
174
  let data = null;
171
175
  if (ref.base64) {
172
- data = Buffer.from(ref.base64.split(",").pop().trim(), "base64");
176
+ data = decodeBase64Media(ref.base64, options.maxBytes);
173
177
  }
174
178
  else if (ref.url) {
175
- const r = await fetch(ref.url);
179
+ const r = await fetch(ref.url, { signal: options.signal });
176
180
  if (!r.ok)
177
181
  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;
182
+ data = await readResponseBytesLimited(r, options.maxBytes, options.signal);
184
183
  }
185
184
  if (!data || !data.length)
186
185
  return null;
187
- const dir = join(homedir(), ".hara", "wecom", "media");
188
- mkdirSync(dir, { recursive: true });
186
+ if (ref.aesKeyB64)
187
+ data = decryptWecomMedia(data, ref.aesKeyB64);
188
+ if (!data.length || data.length > options.maxBytes)
189
+ return null;
189
190
  let name = ref.fileName ? basename(ref.fileName) : "";
190
191
  if (!name || (ref.kind === "image" && !extname(name)))
191
192
  name = `image${imageExtFromBytes(data)}`;
192
- const path = join(dir, `wc_${Date.now()}_${name || "file.bin"}`);
193
- writeFileSync(path, data);
194
- return path;
193
+ return await savePrivateMediaBytes(data, {
194
+ platform: "wecom",
195
+ filenameHint: name || "file.bin",
196
+ ...options,
197
+ });
195
198
  }
196
199
  catch {
197
200
  return null;
@@ -254,13 +257,13 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
254
257
  /* upload/send failed — caller surfaces a generic failure; nothing else to do */
255
258
  }
256
259
  },
257
- async start(onMessage, signal) {
260
+ async start(onMessage, signal, shouldDownload) {
258
261
  if (!WSImpl) {
259
262
  console.error("hara gateway: WeCom needs Node ≥ 22 (global WebSocket). Upgrade Node.");
260
263
  return;
261
264
  }
262
265
  while (!signal.aborted) {
263
- await connectOnce(botId, secret, wsUrl, conn, onMessage, signal);
266
+ await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload);
264
267
  if (!signal.aborted)
265
268
  await sleep(3000, signal); // reconnect backoff
266
269
  }
@@ -270,7 +273,7 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
270
273
  /** One gateway connection: open WS → `aibot_subscribe` auth → correlate request/response by req_id, dispatch
271
274
  * `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
272
275
  * subscribe each time — v1 keeps it simple, no resume). */
273
- function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
276
+ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload) {
274
277
  return new Promise((resolve) => {
275
278
  const ws = new WSImpl(wsUrl);
276
279
  let hb = null;
@@ -371,10 +374,15 @@ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
371
374
  }
372
375
  const parsed = parseWecomMessage(p, botId);
373
376
  if (parsed) {
374
- for (const ref of parsed.media) {
375
- const path = await downloadWecomMedia(ref);
376
- if (path)
377
- (parsed.msg.images ??= []).push(path);
377
+ if (shouldDownload?.(parsed.msg) === true) {
378
+ const budget = new InboundMediaBudget("wecom", signal);
379
+ for (const ref of parsed.media) {
380
+ const path = await budget.download((options) => downloadWecomMedia(ref, options));
381
+ if (path) {
382
+ (parsed.msg.images ??= []).push(path);
383
+ (parsed.msg.transientFiles ??= []).push(path);
384
+ }
385
+ }
378
386
  }
379
387
  await onMessage(parsed.msg).catch(() => { });
380
388
  }