@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- 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 +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
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;
|
|
@@ -132,28 +138,25 @@ export function signalAdapter(rpcUrl, selfNumber) {
|
|
|
132
138
|
return null;
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
|
-
/** Recipient/group routing
|
|
141
|
+
/** Recipient/group routing for outbound text sends (mirrors hermes). */
|
|
136
142
|
const target = (chatId) => {
|
|
137
143
|
const id = String(chatId);
|
|
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;
|
|
@@ -166,15 +169,10 @@ export function signalAdapter(rpcUrl, selfNumber) {
|
|
|
166
169
|
await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
|
|
167
170
|
}
|
|
168
171
|
},
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
|
|
174
|
-
void readFileSync;
|
|
175
|
-
void basename;
|
|
176
|
-
},
|
|
177
|
-
async start(onMessage, signal) {
|
|
172
|
+
// signal-cli's JSON-RPC attachment API accepts only a server-side pathname. Exposing sendFile here would
|
|
173
|
+
// reopen the verified snapshot after the security boundary, so outbound files stay disabled for Signal
|
|
174
|
+
// until its daemon offers a byte-oriented upload API.
|
|
175
|
+
async start(onMessage, signal, shouldDownload) {
|
|
178
176
|
console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
|
|
179
177
|
while (!signal.aborted) {
|
|
180
178
|
try {
|
|
@@ -186,10 +184,15 @@ export function signalAdapter(rpcUrl, selfNumber) {
|
|
|
186
184
|
const parsed = parseSignalMessage(raw, selfNumber);
|
|
187
185
|
if (!parsed)
|
|
188
186
|
continue;
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
187
|
+
if (shouldDownload?.(parsed.msg) === true) {
|
|
188
|
+
const budget = new InboundMediaBudget("signal", signal);
|
|
189
|
+
for (const ref of parsed.images) {
|
|
190
|
+
const path = await budget.download((options) => downloadAttachment(ref, options));
|
|
191
|
+
if (path) {
|
|
192
|
+
(parsed.msg.images ??= []).push(path);
|
|
193
|
+
(parsed.msg.transientFiles ??= []).push(path);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
193
196
|
}
|
|
194
197
|
await onMessage(parsed.msg).catch(() => { });
|
|
195
198
|
}
|
package/dist/gateway/slack.js
CHANGED
|
@@ -4,9 +4,7 @@
|
|
|
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 {
|
|
8
|
-
import { join, basename } from "node:path";
|
|
9
|
-
import { homedir } from "node:os";
|
|
7
|
+
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
10
8
|
import { chunkText } from "./telegram.js";
|
|
11
9
|
const WEB = "https://slack.com/api";
|
|
12
10
|
const WSImpl = globalThis.WebSocket;
|
|
@@ -34,16 +32,12 @@ async function slackApi(method, token, body) {
|
|
|
34
32
|
}
|
|
35
33
|
/** Slack file url_private is gated — it needs the BOT token as a Bearer header (a plain GET returns the login
|
|
36
34
|
* page HTML, not the bytes). Download into ~/.hara/slack/media so the agent can SEE it. */
|
|
37
|
-
async function downloadSlackFile(url, name, botToken) {
|
|
35
|
+
async function downloadSlackFile(url, name, botToken, options) {
|
|
38
36
|
try {
|
|
39
|
-
const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } });
|
|
37
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` }, signal: options.signal });
|
|
40
38
|
if (!r.ok)
|
|
41
39
|
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;
|
|
40
|
+
return await savePrivateResponse(r, { platform: "slack", filenameHint: name, ...options });
|
|
47
41
|
}
|
|
48
42
|
catch {
|
|
49
43
|
return null;
|
|
@@ -52,6 +46,13 @@ async function downloadSlackFile(url, name, botToken) {
|
|
|
52
46
|
/** Parse a Slack Events API `event` (the inner `payload.event`) → InboundMsg + its image file refs (pure;
|
|
53
47
|
* download happens in start()). null = ignore (not a user message / our own / an edit/delete subtype / empty).
|
|
54
48
|
* Mirrors discord.ts's parseDiscordMessage so it's unit-testable without the network. */
|
|
49
|
+
export function slackChatType(event) {
|
|
50
|
+
const channel = String(event?.channel ?? "").trim();
|
|
51
|
+
// Slack reserves D-prefixed ids for direct-message channels. It is stronger than a contradictory event field.
|
|
52
|
+
if (channel.startsWith("D"))
|
|
53
|
+
return "p2p";
|
|
54
|
+
return String(event?.channel_type ?? "").trim().toLowerCase() === "im" ? "p2p" : "group";
|
|
55
|
+
}
|
|
55
56
|
export function parseSlackEvent(event, selfId) {
|
|
56
57
|
if (event?.type !== "message")
|
|
57
58
|
return null;
|
|
@@ -76,6 +77,7 @@ export function parseSlackEvent(event, selfId) {
|
|
|
76
77
|
userId: String(event.user),
|
|
77
78
|
userName: String(event.user), // Slack events carry only the id; users.info would resolve a name (skipped for zero extra calls)
|
|
78
79
|
text: text || "[图片]",
|
|
80
|
+
chatType: slackChatType(event),
|
|
79
81
|
},
|
|
80
82
|
imageUrls,
|
|
81
83
|
};
|
|
@@ -89,16 +91,16 @@ export function slackAdapter(appToken, botToken) {
|
|
|
89
91
|
await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
|
|
90
92
|
}
|
|
91
93
|
},
|
|
92
|
-
async sendFile(chatId,
|
|
94
|
+
async sendFile(chatId, file) {
|
|
93
95
|
// 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
|
|
94
96
|
// non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
|
|
95
97
|
try {
|
|
96
|
-
const bytes =
|
|
97
|
-
const name =
|
|
98
|
+
const bytes = file.bytes;
|
|
99
|
+
const name = file.safeName;
|
|
98
100
|
const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
|
|
99
101
|
if (!url?.ok || !url.upload_url || !url.file_id)
|
|
100
102
|
return;
|
|
101
|
-
const up = await fetch(url.upload_url, { method: "POST", body: new Blob([bytes]) }); // presigned URL: no auth header
|
|
103
|
+
const up = await fetch(url.upload_url, { method: "POST", body: new Blob([new Uint8Array(bytes)]) }); // presigned URL: no auth header
|
|
102
104
|
if (!up.ok)
|
|
103
105
|
return;
|
|
104
106
|
await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
|
|
@@ -107,7 +109,7 @@ export function slackAdapter(appToken, botToken) {
|
|
|
107
109
|
/* upload/send failed — surfaced upstream as "no file delivered" */
|
|
108
110
|
}
|
|
109
111
|
},
|
|
110
|
-
async start(onMessage, signal) {
|
|
112
|
+
async start(onMessage, signal, shouldDownload) {
|
|
111
113
|
if (!WSImpl) {
|
|
112
114
|
console.error("hara gateway: Slack needs Node ≥ 22 (global WebSocket). Upgrade Node.");
|
|
113
115
|
return;
|
|
@@ -118,7 +120,7 @@ export function slackAdapter(appToken, botToken) {
|
|
|
118
120
|
while (!signal.aborted) {
|
|
119
121
|
const wss = await openSocketUrl(appToken);
|
|
120
122
|
if (wss)
|
|
121
|
-
await connectOnce(wss, botToken, selfId, onMessage, signal);
|
|
123
|
+
await connectOnce(wss, botToken, selfId, onMessage, signal, shouldDownload);
|
|
122
124
|
if (!signal.aborted)
|
|
123
125
|
await sleep(3000, signal); // reconnect backoff (and re-open: each URL is single-use)
|
|
124
126
|
}
|
|
@@ -133,7 +135,7 @@ async function openSocketUrl(appToken) {
|
|
|
133
135
|
/** One Socket Mode connection: receive envelopes, ACK each by echoing its envelope_id, and dispatch message
|
|
134
136
|
* events. Resolves on close/disconnect/abort; the caller re-opens a new URL and reconnects. v1 keeps it simple —
|
|
135
137
|
* no buffered-payload backpressure (the daemon spawns one hara per message anyway). */
|
|
136
|
-
function connectOnce(url, botToken, selfId, onMessage, signal) {
|
|
138
|
+
function connectOnce(url, botToken, selfId, onMessage, signal, shouldDownload) {
|
|
137
139
|
return new Promise((resolve) => {
|
|
138
140
|
const ws = new WSImpl(url);
|
|
139
141
|
const stop = () => {
|
|
@@ -176,10 +178,15 @@ function connectOnce(url, botToken, selfId, onMessage, signal) {
|
|
|
176
178
|
if (p.type === "events_api") {
|
|
177
179
|
const parsed = parseSlackEvent(p.payload?.event, selfId);
|
|
178
180
|
if (parsed) {
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
(
|
|
181
|
+
if (shouldDownload?.(parsed.msg) === true) {
|
|
182
|
+
const budget = new InboundMediaBudget("slack", signal);
|
|
183
|
+
for (const im of parsed.imageUrls) {
|
|
184
|
+
const path = await budget.download((options) => downloadSlackFile(im.url, im.name, botToken, options));
|
|
185
|
+
if (path) {
|
|
186
|
+
(parsed.msg.images ??= []).push(path);
|
|
187
|
+
(parsed.msg.transientFiles ??= []).push(path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
183
190
|
}
|
|
184
191
|
await onMessage(parsed.msg).catch(() => { });
|
|
185
192
|
}
|
package/dist/gateway/telegram.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
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 {
|
|
5
|
-
import { join, basename } from "node:path";
|
|
6
|
-
import { homedir } from "node:os";
|
|
4
|
+
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
7
5
|
const API = "https://api.telegram.org";
|
|
8
6
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
7
|
/** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
|
|
@@ -21,6 +19,7 @@ export function parseTelegramUpdate(u) {
|
|
|
21
19
|
userId: m.from?.id ?? 0,
|
|
22
20
|
userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
|
|
23
21
|
text: text || "[图片]",
|
|
22
|
+
...(m.chat?.type === "private" ? { chatType: "p2p" } : ["group", "supergroup"].includes(m.chat?.type) ? { chatType: "group" } : {}),
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
/** The largest photo's file_id (Telegram sends `photo` as an ascending size array) (pure). null if none. */
|
|
@@ -29,21 +28,17 @@ export function photoFileId(u) {
|
|
|
29
28
|
return Array.isArray(photo) && photo.length ? (photo[photo.length - 1]?.file_id ?? null) : null;
|
|
30
29
|
}
|
|
31
30
|
/** 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) {
|
|
31
|
+
async function downloadTelegramFile(base, token, fileId, options) {
|
|
33
32
|
try {
|
|
34
|
-
const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}
|
|
33
|
+
const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`, { signal: options.signal });
|
|
35
34
|
const j = (await r.json());
|
|
36
35
|
const fp = j?.result?.file_path;
|
|
37
36
|
if (!fp)
|
|
38
37
|
return null;
|
|
39
|
-
const dl = await fetch(`${API}/file/bot${token}/${fp}
|
|
38
|
+
const dl = await fetch(`${API}/file/bot${token}/${fp}`, { signal: options.signal });
|
|
40
39
|
if (!dl.ok)
|
|
41
40
|
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;
|
|
41
|
+
return await savePrivateResponse(dl, { platform: "telegram", filenameHint: fp, ...options });
|
|
47
42
|
}
|
|
48
43
|
catch {
|
|
49
44
|
return null;
|
|
@@ -60,27 +55,41 @@ export function chunkText(text, max = 4000) {
|
|
|
60
55
|
}
|
|
61
56
|
export function telegramAdapter(token) {
|
|
62
57
|
const base = `${API}/bot${token}`;
|
|
58
|
+
const request = async (method, init) => {
|
|
59
|
+
const response = await fetch(`${base}/${method}`, init);
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await response.json();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
body = undefined;
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok || body?.ok === false) {
|
|
68
|
+
throw new Error(`Telegram ${method} failed: HTTP ${response.status}${body?.description ? ` · ${body.description}` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
return body;
|
|
71
|
+
};
|
|
63
72
|
return {
|
|
64
73
|
name: "telegram",
|
|
65
74
|
async send(chatId, text) {
|
|
66
75
|
for (const part of chunkText(text || "(empty)")) {
|
|
67
|
-
await
|
|
76
|
+
await request("sendMessage", {
|
|
68
77
|
method: "POST",
|
|
69
78
|
headers: { "content-type": "application/json" },
|
|
70
79
|
body: JSON.stringify({ chat_id: chatId, text: part }),
|
|
71
|
-
})
|
|
80
|
+
});
|
|
72
81
|
}
|
|
73
82
|
},
|
|
74
|
-
async sendFile(chatId,
|
|
83
|
+
async sendFile(chatId, file) {
|
|
75
84
|
// images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
|
|
76
|
-
const name =
|
|
85
|
+
const name = file.safeName;
|
|
77
86
|
const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
78
87
|
const form = new FormData();
|
|
79
88
|
form.append("chat_id", String(chatId));
|
|
80
|
-
form.append(isImg ? "photo" : "document", new Blob([
|
|
81
|
-
await
|
|
89
|
+
form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
|
|
90
|
+
await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
|
|
82
91
|
},
|
|
83
|
-
async start(onMessage, signal) {
|
|
92
|
+
async start(onMessage, signal, shouldDownload) {
|
|
84
93
|
let offset = 0;
|
|
85
94
|
while (!signal.aborted) {
|
|
86
95
|
try {
|
|
@@ -96,10 +105,13 @@ export function telegramAdapter(token) {
|
|
|
96
105
|
if (!msg)
|
|
97
106
|
continue;
|
|
98
107
|
const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
|
|
99
|
-
if (fid) {
|
|
100
|
-
const
|
|
101
|
-
|
|
108
|
+
if (fid && shouldDownload?.(msg) === true) {
|
|
109
|
+
const budget = new InboundMediaBudget("telegram", signal);
|
|
110
|
+
const path = await budget.download((options) => downloadTelegramFile(base, token, fid, options));
|
|
111
|
+
if (path) {
|
|
102
112
|
msg.images = [path];
|
|
113
|
+
msg.transientFiles = [path];
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
await onMessage(msg).catch(() => { });
|
|
105
117
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
|
|
8
8
|
// ONLY injects into panes that opted in by registering — never an arbitrary pane.
|
|
9
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { chmodSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { homedir } from "node:os";
|
|
@@ -26,8 +26,16 @@ function load() {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
function save(routes) {
|
|
29
|
-
mkdirSync(dir(), { recursive: true });
|
|
30
|
-
|
|
29
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(dir(), 0o700);
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
writeFileSync(storePath(), JSON.stringify({ routes }, null, 2), { mode: 0o600 });
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(storePath(), 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch { /* best effort */ }
|
|
31
39
|
}
|
|
32
40
|
/** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
|
|
33
41
|
* reply; "bind" = persistent (every reply injects until unbound). */
|
package/dist/gateway/wecom.js
CHANGED
|
@@ -10,11 +10,10 @@
|
|
|
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 {
|
|
14
|
-
import { join, basename, extname } from "node:path";
|
|
15
|
-
import { homedir } from "node:os";
|
|
13
|
+
import { basename, extname } from "node:path";
|
|
16
14
|
import { randomUUID, createDecipheriv, createHash } from "node:crypto";
|
|
17
15
|
import { chunkText } from "./telegram.js";
|
|
16
|
+
import { InboundMediaBudget, decodeBase64Media, readResponseBytesLimited, savePrivateMediaBytes } from "./media.js";
|
|
18
17
|
const DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com";
|
|
19
18
|
const WSImpl = globalThis.WebSocket;
|
|
20
19
|
// app-level command verbs on the AI Bot gateway (mirror the Hermes constants)
|
|
@@ -153,45 +152,48 @@ export function parseWecomMessage(payload, selfBotId) {
|
|
|
153
152
|
const media = extractWecomMedia(body);
|
|
154
153
|
if (!text && !media.length)
|
|
155
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";
|
|
156
158
|
return {
|
|
157
159
|
msg: {
|
|
158
160
|
chatId,
|
|
159
161
|
userId: senderId || chatId,
|
|
160
162
|
userName: String(sender.name ?? sender.userid ?? senderId ?? chatId),
|
|
161
163
|
text: text || "[图片]",
|
|
164
|
+
chatType,
|
|
162
165
|
},
|
|
163
166
|
media,
|
|
164
167
|
};
|
|
165
168
|
}
|
|
166
169
|
/** Download (and AES-decrypt if needed) a WeCom inbound media ref → a local path under ~/.hara/wecom/media. null on
|
|
167
170
|
* failure. Inline base64 and remote (optionally encrypted) urls are both handled. */
|
|
168
|
-
async function downloadWecomMedia(ref) {
|
|
171
|
+
async function downloadWecomMedia(ref, options) {
|
|
169
172
|
try {
|
|
170
173
|
let data = null;
|
|
171
174
|
if (ref.base64) {
|
|
172
|
-
data =
|
|
175
|
+
data = decodeBase64Media(ref.base64, options.maxBytes);
|
|
173
176
|
}
|
|
174
177
|
else if (ref.url) {
|
|
175
|
-
const r = await fetch(ref.url);
|
|
178
|
+
const r = await fetch(ref.url, { signal: options.signal });
|
|
176
179
|
if (!r.ok)
|
|
177
180
|
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;
|
|
181
|
+
data = await readResponseBytesLimited(r, options.maxBytes, options.signal);
|
|
184
182
|
}
|
|
185
183
|
if (!data || !data.length)
|
|
186
184
|
return null;
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
if (ref.aesKeyB64)
|
|
186
|
+
data = decryptWecomMedia(data, ref.aesKeyB64);
|
|
187
|
+
if (!data.length || data.length > options.maxBytes)
|
|
188
|
+
return null;
|
|
189
189
|
let name = ref.fileName ? basename(ref.fileName) : "";
|
|
190
190
|
if (!name || (ref.kind === "image" && !extname(name)))
|
|
191
191
|
name = `image${imageExtFromBytes(data)}`;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
return await savePrivateMediaBytes(data, {
|
|
193
|
+
platform: "wecom",
|
|
194
|
+
filenameHint: name || "file.bin",
|
|
195
|
+
...options,
|
|
196
|
+
});
|
|
195
197
|
}
|
|
196
198
|
catch {
|
|
197
199
|
return null;
|
|
@@ -201,17 +203,17 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
|
201
203
|
// per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
|
|
202
204
|
// it publishes through `conn`.
|
|
203
205
|
const conn = { send: null };
|
|
204
|
-
// Upload
|
|
205
|
-
async function uploadMedia(
|
|
206
|
+
// Upload already-verified bytes to WeCom via the 3-step chunked protocol → its media_id.
|
|
207
|
+
async function uploadMedia(file, mediaType) {
|
|
206
208
|
if (!conn.send)
|
|
207
209
|
throw new Error("WeCom socket not connected");
|
|
208
|
-
const data =
|
|
210
|
+
const data = file.bytes;
|
|
209
211
|
if (data.length > ABSOLUTE_MAX_BYTES)
|
|
210
212
|
throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
|
|
211
213
|
const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
|
|
212
214
|
const init = await conn.send(CMD_UPLOAD_INIT, {
|
|
213
215
|
type: mediaType,
|
|
214
|
-
filename:
|
|
216
|
+
filename: file.safeName,
|
|
215
217
|
total_size: data.length,
|
|
216
218
|
total_chunks: totalChunks,
|
|
217
219
|
md5: createHash("md5").update(data).digest("hex"),
|
|
@@ -242,25 +244,25 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
|
242
244
|
.catch(() => { });
|
|
243
245
|
}
|
|
244
246
|
},
|
|
245
|
-
async sendFile(chatId,
|
|
247
|
+
async sendFile(chatId, file) {
|
|
246
248
|
if (!conn.send)
|
|
247
249
|
return;
|
|
248
|
-
const mediaType = isImageName(
|
|
250
|
+
const mediaType = isImageName(file.safeName) ? "image" : "file";
|
|
249
251
|
try {
|
|
250
|
-
const mediaId = await uploadMedia(
|
|
252
|
+
const mediaId = await uploadMedia(file, mediaType);
|
|
251
253
|
await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
|
|
252
254
|
}
|
|
253
255
|
catch {
|
|
254
256
|
/* upload/send failed — caller surfaces a generic failure; nothing else to do */
|
|
255
257
|
}
|
|
256
258
|
},
|
|
257
|
-
async start(onMessage, signal) {
|
|
259
|
+
async start(onMessage, signal, shouldDownload) {
|
|
258
260
|
if (!WSImpl) {
|
|
259
261
|
console.error("hara gateway: WeCom needs Node ≥ 22 (global WebSocket). Upgrade Node.");
|
|
260
262
|
return;
|
|
261
263
|
}
|
|
262
264
|
while (!signal.aborted) {
|
|
263
|
-
await connectOnce(botId, secret, wsUrl, conn, onMessage, signal);
|
|
265
|
+
await connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload);
|
|
264
266
|
if (!signal.aborted)
|
|
265
267
|
await sleep(3000, signal); // reconnect backoff
|
|
266
268
|
}
|
|
@@ -270,7 +272,7 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
|
|
|
270
272
|
/** One gateway connection: open WS → `aibot_subscribe` auth → correlate request/response by req_id, dispatch
|
|
271
273
|
* `aibot_msg_callback` events, heartbeat with `ping`. Resolves on close/abort; the caller reconnects (fresh
|
|
272
274
|
* subscribe each time — v1 keeps it simple, no resume). */
|
|
273
|
-
function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
|
|
275
|
+
function connectOnce(botId, secret, wsUrl, conn, onMessage, signal, shouldDownload) {
|
|
274
276
|
return new Promise((resolve) => {
|
|
275
277
|
const ws = new WSImpl(wsUrl);
|
|
276
278
|
let hb = null;
|
|
@@ -371,10 +373,15 @@ function connectOnce(botId, secret, wsUrl, conn, onMessage, signal) {
|
|
|
371
373
|
}
|
|
372
374
|
const parsed = parseWecomMessage(p, botId);
|
|
373
375
|
if (parsed) {
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
}
|
|
378
385
|
}
|
|
379
386
|
await onMessage(parsed.msg).catch(() => { });
|
|
380
387
|
}
|