@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.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/gateway/signal.js
CHANGED
|
@@ -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
|
-
|
|
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 = (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
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
|
}
|
package/dist/gateway/slack.js
CHANGED
|
@@ -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
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
(
|
|
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
|
}
|
package/dist/gateway/telegram.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
|
101
|
-
|
|
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
|
}
|
package/dist/gateway/wecom.js
CHANGED
|
@@ -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
|
|
14
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
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
|
}
|