@nanhara/hara 0.121.1 → 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 +66 -0
- package/README.md +40 -6
- package/dist/agent/loop.js +136 -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 +33 -23
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +3 -2
- 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 +631 -222
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +84 -9
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +30 -28
- 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 +168 -54
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/gateway/flows.js
CHANGED
|
@@ -6,17 +6,65 @@
|
|
|
6
6
|
// Opt-in: config lives in the user's ~/.hara/flows.json — no file, no flows (zero behaviour change).
|
|
7
7
|
// Platform-agnostic: matching only reads the generic InboundMsg fields each adapter populates (chatType,
|
|
8
8
|
// mentions), so a flow works on whatever platform surfaces the data it asks for.
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
9
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { deliverResult } from "../cron/deliver.js";
|
|
12
|
+
import { deliverResult, parseDeliver } from "../cron/deliver.js";
|
|
13
|
+
import { addPending } from "./flows-pending.js";
|
|
14
|
+
import { redactSensitiveValue } from "../security/secrets.js";
|
|
13
15
|
const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
16
|
+
function isStrings(value) {
|
|
17
|
+
return typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
18
|
+
}
|
|
19
|
+
function validFlow(value) {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
21
|
+
return false;
|
|
22
|
+
const f = value;
|
|
23
|
+
if (typeof f.name !== "string" || !f.name.trim() || typeof f.do !== "string" || !f.do.trim())
|
|
24
|
+
return false;
|
|
25
|
+
if (f.enabled !== undefined && typeof f.enabled !== "boolean")
|
|
26
|
+
return false;
|
|
27
|
+
if (f.guard !== undefined && typeof f.guard !== "string")
|
|
28
|
+
return false;
|
|
29
|
+
if (f.cwd !== undefined && typeof f.cwd !== "string")
|
|
30
|
+
return false;
|
|
31
|
+
if (f.schema !== undefined && (!f.schema || typeof f.schema !== "object" || Array.isArray(f.schema)))
|
|
32
|
+
return false;
|
|
33
|
+
if (f.deliver !== undefined && !isStrings(f.deliver))
|
|
34
|
+
return false;
|
|
35
|
+
if (f.notifyOn !== undefined && (!Array.isArray(f.notifyOn) || !f.notifyOn.every((item) => typeof item === "string")))
|
|
36
|
+
return false;
|
|
37
|
+
if (f.replyOn !== undefined && (!Array.isArray(f.replyOn) || !f.replyOn.every((item) => typeof item === "string")))
|
|
38
|
+
return false;
|
|
39
|
+
if (f.log !== undefined && typeof f.log !== "boolean")
|
|
40
|
+
return false;
|
|
41
|
+
if (f.reply !== undefined && typeof f.reply !== "boolean")
|
|
42
|
+
return false;
|
|
43
|
+
if (f.on !== undefined) {
|
|
44
|
+
if (!f.on || typeof f.on !== "object" || Array.isArray(f.on))
|
|
45
|
+
return false;
|
|
46
|
+
const on = f.on;
|
|
47
|
+
if (on.platform !== undefined && typeof on.platform !== "string")
|
|
48
|
+
return false;
|
|
49
|
+
if (on.chat !== undefined && !isStrings(on.chat))
|
|
50
|
+
return false;
|
|
51
|
+
if (on.chatType !== undefined && !["p2p", "group", "any"].includes(String(on.chatType)))
|
|
52
|
+
return false;
|
|
53
|
+
if (on.mention !== undefined && !isStrings(on.mention))
|
|
54
|
+
return false;
|
|
55
|
+
if (on.keyword !== undefined && !isStrings(on.keyword))
|
|
56
|
+
return false;
|
|
57
|
+
if (on.ignoreKeyword !== undefined && !isStrings(on.ignoreKeyword))
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
14
62
|
/** Load ~/.hara/flows.json — accepts a bare array or `{ "flows": [...] }`. Missing/malformed → [] (never throws). */
|
|
15
63
|
export function loadFlows() {
|
|
16
64
|
try {
|
|
17
65
|
const parsed = JSON.parse(readFileSync(join(homedir(), ".hara", "flows.json"), "utf8"));
|
|
18
66
|
const flows = Array.isArray(parsed) ? parsed : parsed?.flows;
|
|
19
|
-
return Array.isArray(flows) ? flows.filter((f) => f && f.enabled !== false
|
|
67
|
+
return Array.isArray(flows) ? flows.filter((f) => validFlow(f) && f.enabled !== false).slice(0, 64) : [];
|
|
20
68
|
}
|
|
21
69
|
catch {
|
|
22
70
|
return [];
|
|
@@ -49,40 +97,367 @@ export function matchFlow(r, m, platform) {
|
|
|
49
97
|
const kws = asArray(on.keyword);
|
|
50
98
|
if (kws.length && !kws.some((k) => (m.text ?? "").includes(k)))
|
|
51
99
|
return false;
|
|
100
|
+
const mutes = asArray(on.ignoreKeyword);
|
|
101
|
+
if (mutes.length && mutes.some((k) => (m.text ?? "").includes(k)))
|
|
102
|
+
return false;
|
|
52
103
|
return true;
|
|
53
104
|
}
|
|
105
|
+
/** Append a run record to ~/.hara/flows-log.jsonl — the reviewable trail of everything the flow judged,
|
|
106
|
+
* including runs the noise policy chose NOT to push (judged + logged ≠ delivered). */
|
|
107
|
+
export function appendFlowLog(entry) {
|
|
108
|
+
try {
|
|
109
|
+
const dir = join(homedir(), ".hara");
|
|
110
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
111
|
+
chmodSync(dir, 0o700);
|
|
112
|
+
const file = join(dir, "flows-log.jsonl");
|
|
113
|
+
if (existsSync(file) && statSync(file).size >= 1_000_000) {
|
|
114
|
+
const rotated = `${file}.1`;
|
|
115
|
+
rmSync(rotated, { force: true });
|
|
116
|
+
renameSync(file, rotated);
|
|
117
|
+
chmodSync(rotated, 0o600);
|
|
118
|
+
}
|
|
119
|
+
const safe = redactSensitiveValue({ at: new Date().toISOString(), ...entry }).value;
|
|
120
|
+
appendFileSync(file, JSON.stringify(safe) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
121
|
+
try {
|
|
122
|
+
chmodSync(file, 0o600);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* best-effort on filesystems without POSIX modes */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* logging is best-effort */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const senderFlowRate = new Map();
|
|
133
|
+
const chatFlowRate = new Map();
|
|
134
|
+
let globalFlowRate = [];
|
|
135
|
+
let activeFlowRuns = 0;
|
|
136
|
+
const MAX_ACTIVE_FLOW_RUNS = 4;
|
|
137
|
+
const RATE_MINUTE_MS = 60_000;
|
|
138
|
+
const RATE_HOUR_MS = 60 * RATE_MINUTE_MS;
|
|
139
|
+
const MAX_SENDER_RUNS_PER_MINUTE = 5;
|
|
140
|
+
const MAX_CHAT_RUNS_PER_MINUTE = 10;
|
|
141
|
+
const MAX_CHAT_RUNS_PER_HOUR = 60;
|
|
142
|
+
const MAX_GLOBAL_RUNS_PER_MINUTE = 20;
|
|
143
|
+
const MAX_GLOBAL_RUNS_PER_HOUR = 120;
|
|
144
|
+
const MAX_SENDER_RATE_BUCKETS = 1_024;
|
|
145
|
+
const MAX_CHAT_RATE_BUCKETS = 1_024;
|
|
146
|
+
const MAX_RATE_KEY_PART_CHARS = 160;
|
|
147
|
+
const RATE_SWEEP_INTERVAL = 64;
|
|
148
|
+
let claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
|
|
149
|
+
function boundedRateKey(...parts) {
|
|
150
|
+
return parts
|
|
151
|
+
.map((value) => {
|
|
152
|
+
const part = String(value ?? "").slice(0, MAX_RATE_KEY_PART_CHARS);
|
|
153
|
+
return `${part.length}:${part}`;
|
|
154
|
+
})
|
|
155
|
+
.join("");
|
|
156
|
+
}
|
|
157
|
+
function pruneRateTimes(times, now, windowMs, maxEntries) {
|
|
158
|
+
const live = [];
|
|
159
|
+
// Walk newest-first so even a bucket left by an older implementation cannot cause an unbounded copy.
|
|
160
|
+
for (let i = times.length - 1; i >= 0 && live.length < maxEntries; i--) {
|
|
161
|
+
const at = times[i];
|
|
162
|
+
// A backwards wall-clock adjustment must not reopen the limiter; future timestamps remain live.
|
|
163
|
+
if (Number.isFinite(at) && (at > now || now - at < windowMs))
|
|
164
|
+
live.push(at);
|
|
165
|
+
}
|
|
166
|
+
live.reverse();
|
|
167
|
+
return live;
|
|
168
|
+
}
|
|
169
|
+
function sweepRateMap(map, now, windowMs, maxEntriesPerBucket) {
|
|
170
|
+
for (const [key, times] of map) {
|
|
171
|
+
const live = pruneRateTimes(times, now, windowMs, maxEntriesPerBucket);
|
|
172
|
+
if (live.length)
|
|
173
|
+
map.set(key, live);
|
|
174
|
+
else
|
|
175
|
+
map.delete(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function prepareRateBucket(map, key, now, windowMs, maxBuckets, maxEntriesPerBucket) {
|
|
179
|
+
const current = map.get(key);
|
|
180
|
+
if (current) {
|
|
181
|
+
const live = pruneRateTimes(current, now, windowMs, maxEntriesPerBucket);
|
|
182
|
+
if (live.length)
|
|
183
|
+
map.set(key, live);
|
|
184
|
+
else
|
|
185
|
+
map.delete(key);
|
|
186
|
+
return live;
|
|
187
|
+
}
|
|
188
|
+
if (map.size >= maxBuckets) {
|
|
189
|
+
sweepRateMap(map, now, windowMs, maxEntriesPerBucket);
|
|
190
|
+
if (map.size >= maxBuckets)
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
function countRateTimes(times, now, windowMs) {
|
|
196
|
+
let count = 0;
|
|
197
|
+
for (const at of times)
|
|
198
|
+
if (at > now || now - at < windowMs)
|
|
199
|
+
count++;
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
/** @internal Clears process-local flow quotas between isolated tests. Never call while flow runs are active. */
|
|
203
|
+
export function resetFlowRateStateForTests() {
|
|
204
|
+
senderFlowRate.clear();
|
|
205
|
+
chatFlowRate.clear();
|
|
206
|
+
globalFlowRate = [];
|
|
207
|
+
activeFlowRuns = 0;
|
|
208
|
+
claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
|
|
209
|
+
}
|
|
210
|
+
function claimFlowRun(rule, m, platform) {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
if (--claimsUntilRateSweep <= 0) {
|
|
213
|
+
sweepRateMap(senderFlowRate, now, RATE_MINUTE_MS, MAX_SENDER_RUNS_PER_MINUTE);
|
|
214
|
+
sweepRateMap(chatFlowRate, now, RATE_HOUR_MS, MAX_CHAT_RUNS_PER_HOUR);
|
|
215
|
+
claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
|
|
216
|
+
}
|
|
217
|
+
globalFlowRate = pruneRateTimes(globalFlowRate, now, RATE_HOUR_MS, MAX_GLOBAL_RUNS_PER_HOUR);
|
|
218
|
+
if (activeFlowRuns >= MAX_ACTIVE_FLOW_RUNS)
|
|
219
|
+
return false;
|
|
220
|
+
const senderKey = boundedRateKey(rule.name, platform, m.chatId, m.userId);
|
|
221
|
+
const chatKey = boundedRateKey(rule.name, platform, m.chatId);
|
|
222
|
+
const senderMinute = prepareRateBucket(senderFlowRate, senderKey, now, RATE_MINUTE_MS, MAX_SENDER_RATE_BUCKETS, MAX_SENDER_RUNS_PER_MINUTE);
|
|
223
|
+
const chatHour = prepareRateBucket(chatFlowRate, chatKey, now, RATE_HOUR_MS, MAX_CHAT_RATE_BUCKETS, MAX_CHAT_RUNS_PER_HOUR);
|
|
224
|
+
// A full identity table is itself an abuse signal. Reject new identities instead of evicting a live
|
|
225
|
+
// bucket and letting an attacker cycle through unbounded sender/chat ids.
|
|
226
|
+
if (!senderMinute || !chatHour)
|
|
227
|
+
return false;
|
|
228
|
+
if (senderMinute.length >= MAX_SENDER_RUNS_PER_MINUTE ||
|
|
229
|
+
countRateTimes(chatHour, now, RATE_MINUTE_MS) >= MAX_CHAT_RUNS_PER_MINUTE ||
|
|
230
|
+
chatHour.length >= MAX_CHAT_RUNS_PER_HOUR ||
|
|
231
|
+
countRateTimes(globalFlowRate, now, RATE_MINUTE_MS) >= MAX_GLOBAL_RUNS_PER_MINUTE ||
|
|
232
|
+
globalFlowRate.length >= MAX_GLOBAL_RUNS_PER_HOUR) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
senderMinute.push(now);
|
|
236
|
+
chatHour.push(now);
|
|
237
|
+
globalFlowRate.push(now);
|
|
238
|
+
senderFlowRate.set(senderKey, senderMinute);
|
|
239
|
+
chatFlowRate.set(chatKey, chatHour);
|
|
240
|
+
activeFlowRuns++;
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
/** Extract the flow agent's structured result — {disposition,briefing,draft,dispatch?} — from its text
|
|
244
|
+
* output. Tolerant of surrounding prose/fences; null if there's no parseable object (caller falls back). */
|
|
245
|
+
export function parseAgentResult(raw) {
|
|
246
|
+
let value;
|
|
247
|
+
try {
|
|
248
|
+
value = JSON.parse(raw.trim()); // schema-enforced runs emit exactly the JSON
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* fall through to prose-fishing */
|
|
252
|
+
}
|
|
253
|
+
if (value === undefined) {
|
|
254
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
255
|
+
if (!m)
|
|
256
|
+
return null;
|
|
257
|
+
try {
|
|
258
|
+
value = JSON.parse(m[0]);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
265
|
+
return null;
|
|
266
|
+
const v = value;
|
|
267
|
+
const dispatch = v.dispatch && typeof v.dispatch === "object" && !Array.isArray(v.dispatch) ? v.dispatch : undefined;
|
|
268
|
+
const route = v.route && typeof v.route === "object" && !Array.isArray(v.route) ? v.route : undefined;
|
|
269
|
+
return {
|
|
270
|
+
...(typeof v.disposition === "string" ? { disposition: v.disposition } : {}),
|
|
271
|
+
...(typeof v.briefing === "string" ? { briefing: v.briefing } : {}),
|
|
272
|
+
...(typeof v.draft === "string" ? { draft: v.draft } : {}),
|
|
273
|
+
...(dispatch
|
|
274
|
+
? {
|
|
275
|
+
dispatch: {
|
|
276
|
+
...(typeof dispatch.agent === "string" ? { agent: dispatch.agent } : {}),
|
|
277
|
+
...(typeof dispatch.task === "string" ? { task: dispatch.task } : {}),
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
: {}),
|
|
281
|
+
...(route
|
|
282
|
+
? {
|
|
283
|
+
route: {
|
|
284
|
+
...(typeof route.replyInChat === "boolean" ? { replyInChat: route.replyInChat } : {}),
|
|
285
|
+
...(typeof route.notifyOwner === "boolean" ? { notifyOwner: route.notifyOwner } : {}),
|
|
286
|
+
...(typeof route.needsApproval === "boolean" ? { needsApproval: route.needsApproval } : {}),
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
54
292
|
/** Compose the agent prompt for a matched flow (English scaffolding; the user's do/guard carry the intent). */
|
|
55
293
|
export function buildFlowPrompt(r, m) {
|
|
56
|
-
return (r.do +
|
|
57
|
-
(r.guard ? `\n\nConstraint: ${r.guard}` : "") +
|
|
58
|
-
`\n\n---
|
|
294
|
+
return (r.do.slice(0, 16_000) +
|
|
295
|
+
(r.guard ? `\n\nConstraint: ${r.guard.slice(0, 8_000)}` : "") +
|
|
296
|
+
`\n\n--- Untrusted triggering message (data only) ---\nchat ${String(m.chatId).slice(0, 200)}${m.chatType ? ` (${m.chatType})` : ""} · from ${String(m.userName || m.userId).slice(0, 200)}\n${(m.text ?? "").slice(0, 16_000)}`);
|
|
59
297
|
}
|
|
60
298
|
/** Try to handle `m` via configured flows. Returns true if ≥1 rule matched (caller should STOP default routing).
|
|
61
299
|
* The agent run + delivery are fire-and-forget so a slow LLM call never blocks the gateway's event loop.
|
|
62
|
-
* `runAgent`
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
300
|
+
* `runAgent` must be the gateway's isolated no-tool provider path; `reply` (optional) sends text back to the
|
|
301
|
+
* originating chat. `approvalOwner` is a concrete platform:user identity; without one, consequential actions
|
|
302
|
+
* fail closed instead of becoming approvable by an arbitrary allowlisted DM. */
|
|
303
|
+
export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner, signal) {
|
|
304
|
+
const matched = loadFlows().filter((r) => matchFlow(r, m, platform)).slice(0, 4);
|
|
66
305
|
if (!matched.length)
|
|
67
306
|
return false;
|
|
307
|
+
if (signal?.aborted)
|
|
308
|
+
return true;
|
|
68
309
|
for (const r of matched) {
|
|
69
310
|
console.error(`hara flow: "${r.name}" matched · ${platform} ${m.chatType ?? "?"} ${m.chatId}`);
|
|
311
|
+
if (!claimFlowRun(r, m, platform)) {
|
|
312
|
+
console.error(`hara flow: "${r.name}" rate/concurrency limit reached — trigger dropped`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const home = r.cwd ? (r.cwd === "~" ? homedir() : r.cwd.replace(/^~\//, homedir() + "/")) : undefined;
|
|
70
316
|
void (async () => {
|
|
71
317
|
try {
|
|
72
|
-
const output = (await runAgent(buildFlowPrompt(r, m))).trim();
|
|
318
|
+
const output = (await runAgent(buildFlowPrompt(r, m), home, r.schema, signal)).trim();
|
|
319
|
+
if (signal?.aborted)
|
|
320
|
+
return;
|
|
73
321
|
if (!output)
|
|
74
322
|
return;
|
|
75
|
-
|
|
76
|
-
|
|
323
|
+
const parsed = parseAgentResult(output);
|
|
324
|
+
const dispo = parsed?.disposition;
|
|
325
|
+
// Draft sanitation: leaked tool-call/XML markup is model plumbing, not a human-readable message —
|
|
326
|
+
// never park or post it (a real incident sent "<tool_call>dispatch…" to a group). Drop such drafts.
|
|
327
|
+
if (parsed?.draft && /<\/?(tool_call|arg_key|arg_value|invoke|function)/i.test(parsed.draft)) {
|
|
328
|
+
console.error(`hara flow "${r.name}": draft contained tool-call markup — dropped`);
|
|
329
|
+
parsed.draft = "";
|
|
330
|
+
}
|
|
331
|
+
let briefing = parsed?.briefing?.trim() || output;
|
|
332
|
+
const park = (action) => {
|
|
333
|
+
if (!approvalOwner) {
|
|
334
|
+
console.error(`hara flow "${r.name}": approval required but no unique owner is configured — action dropped`);
|
|
335
|
+
briefing += "\n\n⚠ 需要审批,但网关未配置唯一主人;动作已安全丢弃。设置 HARA_GATEWAY_OWNER 后重试。";
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const executionTarget = action.kind === "org" ? action.origin : action.target;
|
|
339
|
+
if (executionTarget) {
|
|
340
|
+
const deliverable = parseDeliver(executionTarget);
|
|
341
|
+
if ("error" in deliverable) {
|
|
342
|
+
console.error(`hara flow "${r.name}": approval target cannot be delivered later — ${deliverable.error}`);
|
|
343
|
+
briefing += `\n\n⚠ 当前平台只支持即时回复,暂不支持离线审批后回发;动作已安全丢弃。`;
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return addPending({ ...action, owner: approvalOwner });
|
|
348
|
+
};
|
|
349
|
+
// Every judged run lands in the log — the reviewable trail even when the noise policy stays silent.
|
|
350
|
+
if (r.log !== false) {
|
|
351
|
+
appendFlowLog({
|
|
352
|
+
flow: r.name,
|
|
353
|
+
chat: String(m.chatId),
|
|
354
|
+
from: String(m.userName || m.userId),
|
|
355
|
+
text: (m.text ?? "").slice(0, 300),
|
|
356
|
+
disposition: dispo ?? null,
|
|
357
|
+
briefing: briefing.slice(0, 500),
|
|
358
|
+
...(parsed?.draft ? { draft: parsed.draft.slice(0, 500) } : {}),
|
|
359
|
+
...(parsed?.dispatch ? { dispatch: parsed.dispatch } : {}),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
// AI-decided routing (preferred): the agent judged reply/notify/approval per the rule's natural-
|
|
363
|
+
// language policy — code below only EXECUTES that judgment. The enum gates (replyOn/notifyOn) further
|
|
364
|
+
// down remain as legacy fallback for rules whose agents don't emit a route.
|
|
365
|
+
if (parsed?.route) {
|
|
366
|
+
const route = parsed.route;
|
|
367
|
+
const wantsDispatch = !!(parsed.dispatch?.agent && parsed.dispatch.task);
|
|
368
|
+
const draft = parsed.draft?.trim();
|
|
369
|
+
// A model may recommend replyInChat, but it cannot grant itself send authority. Auto-reply is an
|
|
370
|
+
// explicit config capability (`replyOn` + matching disposition); every other drafted send is parked.
|
|
371
|
+
const canAutoReply = !!(draft && dispo && r.replyOn?.includes(dispo));
|
|
372
|
+
if (wantsDispatch) {
|
|
373
|
+
// Delegation stays owner-gated regardless of the route — an authorization boundary, not a judgment.
|
|
374
|
+
const pendingDispatch = park({
|
|
375
|
+
kind: "org",
|
|
376
|
+
target: parsed.dispatch.agent,
|
|
377
|
+
draft: parsed.dispatch.task,
|
|
378
|
+
context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
|
|
379
|
+
notify: r.deliver,
|
|
380
|
+
origin: `${platform}:${m.chatId}`,
|
|
381
|
+
});
|
|
382
|
+
if (pendingDispatch)
|
|
383
|
+
briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
|
|
384
|
+
}
|
|
385
|
+
if (draft && (route.needsApproval || route.replyInChat) && (!canAutoReply || route.needsApproval || wantsDispatch)) {
|
|
386
|
+
const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft, context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
|
|
387
|
+
if (pendingSend) {
|
|
388
|
+
briefing += `\n\n[${pendingSend.id}] 拟发到群:${draft}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (route.replyInChat && canAutoReply && reply) {
|
|
392
|
+
if (!signal?.aborted)
|
|
393
|
+
await reply(draft).catch(() => { });
|
|
394
|
+
console.error(`hara flow "${r.name}": answered in-chat (route)`);
|
|
395
|
+
}
|
|
396
|
+
if (route.notifyOwner || route.needsApproval || wantsDispatch || (route.replyInChat && !canAutoReply)) {
|
|
397
|
+
for (const d of asArray(r.deliver)) {
|
|
398
|
+
if (signal?.aborted)
|
|
399
|
+
return;
|
|
400
|
+
const err = await deliverResult(d, briefing);
|
|
401
|
+
if (err)
|
|
402
|
+
console.error(`hara flow "${r.name}": deliver(${d}) failed — ${err}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
console.error(`hara flow "${r.name}": routed silently (no owner notify)`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Auto-answer lane (replyOn): safe Q&A goes straight back to the origin chat — no owner round-trip.
|
|
410
|
+
// Runs BEFORE the noise gate so an answered question needs no WeChat push at all.
|
|
411
|
+
if (r.replyOn?.length && dispo && r.replyOn.includes(dispo) && parsed?.draft?.trim() && reply) {
|
|
412
|
+
if (!signal?.aborted)
|
|
413
|
+
await reply(parsed.draft.trim()).catch(() => { });
|
|
414
|
+
console.error(`hara flow "${r.name}": ${dispo} — answered in-chat (replyOn)`);
|
|
415
|
+
if (!r.notifyOn?.length || !r.notifyOn.includes(dispo))
|
|
416
|
+
return; // answered; only ALSO brief the owner if asked to
|
|
417
|
+
}
|
|
418
|
+
// Noise policy (updatable config, hot-reloaded per message): only dispositions in notifyOn interrupt
|
|
419
|
+
// the owner. The AGENT judges the disposition; the CONFIG decides which judgments are push-worthy.
|
|
420
|
+
if (r.notifyOn?.length && (!dispo || !r.notifyOn.includes(dispo))) {
|
|
421
|
+
console.error(`hara flow "${r.name}": ${dispo ?? "missing disposition"} — logged, not delivered (notifyOn=${r.notifyOn.join(",")})`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Drafted reply → park for approval; approved = posted back to the ORIGIN chat.
|
|
425
|
+
if (parsed?.draft?.trim() && (dispo === "reply" || dispo === "handle")) {
|
|
426
|
+
const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft: parsed.draft.trim(), context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
|
|
427
|
+
if (pendingSend) {
|
|
428
|
+
briefing += `\n\n[${pendingSend.id}] 拟发到群:${parsed.draft.trim()}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Agent-proposed delegation → park an org dispatch; approved = that agent runs AT ITS HOME (global
|
|
432
|
+
// index resolution). Delegation is still human-gated: proposing is free, executing needs the owner.
|
|
433
|
+
if (parsed?.dispatch?.agent && parsed.dispatch.task) {
|
|
434
|
+
const pendingDispatch = park({
|
|
435
|
+
kind: "org",
|
|
436
|
+
target: parsed.dispatch.agent,
|
|
437
|
+
draft: parsed.dispatch.task,
|
|
438
|
+
context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
|
|
439
|
+
notify: r.deliver,
|
|
440
|
+
origin: `${platform}:${m.chatId}`, // the asker's chat — an approved task's result goes back here
|
|
441
|
+
});
|
|
442
|
+
if (pendingDispatch)
|
|
443
|
+
briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
|
|
444
|
+
}
|
|
445
|
+
for (const d of asArray(r.deliver)) {
|
|
446
|
+
if (signal?.aborted)
|
|
447
|
+
return;
|
|
448
|
+
const err = await deliverResult(d, briefing);
|
|
77
449
|
if (err)
|
|
78
|
-
console.error(`hara flow "${r.name}": deliver failed — ${err}`);
|
|
450
|
+
console.error(`hara flow "${r.name}": deliver(${d}) failed — ${err}`);
|
|
79
451
|
}
|
|
80
|
-
if (r.reply && reply)
|
|
81
|
-
await reply(
|
|
452
|
+
if (r.reply && reply && !signal?.aborted)
|
|
453
|
+
await reply(briefing).catch(() => { });
|
|
82
454
|
}
|
|
83
455
|
catch (e) {
|
|
84
456
|
console.error(`hara flow "${r.name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
85
457
|
}
|
|
458
|
+
finally {
|
|
459
|
+
activeFlowRuns = Math.max(0, activeFlowRuns - 1);
|
|
460
|
+
}
|
|
86
461
|
})();
|
|
87
462
|
}
|
|
88
463
|
return true;
|
package/dist/gateway/matrix.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// LIMITATION (v1): NO end-to-end encryption. Encrypted rooms (m.room.encrypted events) are skipped — only
|
|
8
8
|
// plaintext rooms work. E2EE would need libolm + a crypto store (see hermes' matrix-nio adapter), which breaks
|
|
9
9
|
// the zero-dep constraint. Invite this bot into UNENCRYPTED rooms only.
|
|
10
|
-
import { readFileSync
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
13
13
|
import { chunkText } from "./telegram.js";
|
|
14
14
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
15
|
const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
@@ -35,10 +35,49 @@ export function parseMxc(mxc) {
|
|
|
35
35
|
return null;
|
|
36
36
|
return { server, mediaId };
|
|
37
37
|
}
|
|
38
|
+
/** Read the latest m.direct account-data event from a /sync response. null means the response carried no
|
|
39
|
+
* update, while an empty Set means it explicitly cleared the mapping. Values are bounded and room-id shaped. */
|
|
40
|
+
export function matrixDirectRoomsFromSync(sync) {
|
|
41
|
+
const events = Array.isArray(sync?.account_data?.events) ? sync.account_data.events : [];
|
|
42
|
+
let found = false;
|
|
43
|
+
let content;
|
|
44
|
+
for (const event of events) {
|
|
45
|
+
if (event?.type !== "m.direct")
|
|
46
|
+
continue;
|
|
47
|
+
found = true;
|
|
48
|
+
content = event.content;
|
|
49
|
+
}
|
|
50
|
+
if (!found)
|
|
51
|
+
return null;
|
|
52
|
+
const rooms = new Set();
|
|
53
|
+
if (!content || typeof content !== "object" || Array.isArray(content))
|
|
54
|
+
return rooms;
|
|
55
|
+
for (const value of Object.values(content)) {
|
|
56
|
+
if (!Array.isArray(value))
|
|
57
|
+
continue;
|
|
58
|
+
for (const room of value) {
|
|
59
|
+
if (typeof room !== "string" || !room.startsWith("!") || room.length > 512)
|
|
60
|
+
continue;
|
|
61
|
+
rooms.add(room);
|
|
62
|
+
if (rooms.size >= 1000)
|
|
63
|
+
return rooms;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return rooms;
|
|
67
|
+
}
|
|
68
|
+
/** Matrix rooms have no intrinsic DM type. We accept p2p only when m.direct names the room AND the live
|
|
69
|
+
* joined-members response proves the room contains exactly this bot and this sender. Stale/shared mappings
|
|
70
|
+
* and malformed/unknown member shapes stay group-classified. */
|
|
71
|
+
export function matrixChatType(roomId, selfUserId, sender, directRooms, joinedMembers) {
|
|
72
|
+
if (!directRooms.has(roomId) || !joinedMembers || typeof joinedMembers !== "object" || Array.isArray(joinedMembers))
|
|
73
|
+
return "group";
|
|
74
|
+
const members = Object.keys(joinedMembers);
|
|
75
|
+
return members.length === 2 && members.includes(selfUserId) && members.includes(sender) ? "p2p" : "group";
|
|
76
|
+
}
|
|
38
77
|
/** Extract an InboundMsg from a single Matrix timeline event (pure). Accepts m.room.message of msgtype m.text
|
|
39
78
|
* (and friends carrying a body) plus m.image (→ marker text + the mxc url to download in start()). Ignores our
|
|
40
79
|
* own messages and encrypted events. Returns the InboundMsg + the image's mxc url (null if none). null = skip. */
|
|
41
|
-
export function parseMatrixEvent(event, selfUserId) {
|
|
80
|
+
export function parseMatrixEvent(event, selfUserId, chatType = "group") {
|
|
42
81
|
if (!event || event.type !== "m.room.message")
|
|
43
82
|
return null; // encrypted (m.room.encrypted) / state events → skip
|
|
44
83
|
const sender = typeof event.sender === "string" ? event.sender : "";
|
|
@@ -65,25 +104,39 @@ export function parseMatrixEvent(event, selfUserId) {
|
|
|
65
104
|
userId: sender,
|
|
66
105
|
userName: sender,
|
|
67
106
|
text: text || "[图片]",
|
|
107
|
+
chatType,
|
|
68
108
|
},
|
|
69
109
|
imageMxc,
|
|
70
110
|
};
|
|
71
111
|
}
|
|
112
|
+
async function resolveMatrixChatType(homeserver, token, roomId, selfUserId, sender, directRooms, signal) {
|
|
113
|
+
if (!directRooms.has(roomId))
|
|
114
|
+
return "group";
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, {
|
|
117
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
return "group";
|
|
122
|
+
const body = (await res.json());
|
|
123
|
+
return matrixChatType(roomId, selfUserId, sender, directRooms, body?.joined);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return "group";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
72
129
|
/** Download a Matrix mxc:// media to a local path under ~/.hara/matrix/media (resolves via /_matrix/media/v3/download). */
|
|
73
|
-
async function downloadMatrixMedia(homeserver, token, mxc) {
|
|
130
|
+
async function downloadMatrixMedia(homeserver, token, mxc, options) {
|
|
74
131
|
const parts = parseMxc(mxc);
|
|
75
132
|
if (!parts)
|
|
76
133
|
return null;
|
|
77
134
|
try {
|
|
78
135
|
const url = `${homeserver}/_matrix/media/v3/download/${encodeURIComponent(parts.server)}/${encodeURIComponent(parts.mediaId)}`;
|
|
79
|
-
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
136
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: options.signal });
|
|
80
137
|
if (!r.ok)
|
|
81
138
|
return null;
|
|
82
|
-
|
|
83
|
-
mkdirSync(dir, { recursive: true });
|
|
84
|
-
const path = join(dir, `mx_${Date.now()}_${parts.mediaId.slice(-12)}`);
|
|
85
|
-
writeFileSync(path, Buffer.from(await r.arrayBuffer()));
|
|
86
|
-
return path;
|
|
139
|
+
return await savePrivateResponse(r, { platform: "matrix", filenameHint: parts.mediaId, ...options });
|
|
87
140
|
}
|
|
88
141
|
catch {
|
|
89
142
|
return null;
|
|
@@ -135,13 +188,20 @@ export function matrixAdapter(homeserver, token, selfUserId) {
|
|
|
135
188
|
/* upload/send failed — surfaced upstream as "no file delivered" */
|
|
136
189
|
}
|
|
137
190
|
},
|
|
138
|
-
async start(onMessage, signal) {
|
|
191
|
+
async start(onMessage, signal, shouldDownload) {
|
|
192
|
+
let directRooms = new Set();
|
|
193
|
+
const updateDirectRooms = (sync) => {
|
|
194
|
+
const next = matrixDirectRoomsFromSync(sync);
|
|
195
|
+
if (next)
|
|
196
|
+
directRooms = next;
|
|
197
|
+
};
|
|
139
198
|
// Prime the cursor: a since-less, timeout=0 sync skips backlog so we only see messages from now on.
|
|
140
199
|
let since = "";
|
|
141
200
|
try {
|
|
142
201
|
const r = await fetch(`${base}/_matrix/client/v3/sync?timeout=0`, { headers: auth, signal });
|
|
143
202
|
if (r.ok) {
|
|
144
203
|
const j = (await r.json());
|
|
204
|
+
updateDirectRooms(j);
|
|
145
205
|
if (typeof j.next_batch === "string")
|
|
146
206
|
since = j.next_batch;
|
|
147
207
|
}
|
|
@@ -159,6 +219,7 @@ export function matrixAdapter(homeserver, token, selfUserId) {
|
|
|
159
219
|
continue;
|
|
160
220
|
}
|
|
161
221
|
const j = (await res.json());
|
|
222
|
+
updateDirectRooms(j);
|
|
162
223
|
if (typeof j.next_batch === "string")
|
|
163
224
|
since = j.next_batch;
|
|
164
225
|
const join = j.rooms?.join ?? {};
|
|
@@ -168,10 +229,14 @@ export function matrixAdapter(homeserver, token, selfUserId) {
|
|
|
168
229
|
const parsed = parseMatrixEvent(event, selfUserId);
|
|
169
230
|
if (!parsed)
|
|
170
231
|
continue;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
232
|
+
parsed.msg.chatType = await resolveMatrixChatType(base, token, roomId, selfUserId, String(parsed.msg.userId), directRooms, signal);
|
|
233
|
+
if (parsed.imageMxc && shouldDownload?.(parsed.msg) === true) {
|
|
234
|
+
const budget = new InboundMediaBudget("matrix", signal);
|
|
235
|
+
const path = await budget.download((options) => downloadMatrixMedia(base, token, parsed.imageMxc, options));
|
|
236
|
+
if (path) {
|
|
174
237
|
parsed.msg.images = [path];
|
|
238
|
+
parsed.msg.transientFiles = [path];
|
|
239
|
+
}
|
|
175
240
|
}
|
|
176
241
|
await onMessage(parsed.msg).catch(() => { });
|
|
177
242
|
}
|