@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/feishu.js
CHANGED
|
@@ -8,11 +8,8 @@
|
|
|
8
8
|
// every binaries release fail. This form works under both resolutions.
|
|
9
9
|
import * as larkNs from "@larksuiteoapi/node-sdk";
|
|
10
10
|
const lark = (larkNs.default ?? larkNs);
|
|
11
|
-
import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
|
|
12
|
-
import { pipeline } from "node:stream/promises";
|
|
13
|
-
import { join, basename } from "node:path";
|
|
14
|
-
import { homedir } from "node:os";
|
|
15
11
|
import { chunkText } from "./telegram.js";
|
|
12
|
+
import { InboundMediaBudget, cleanupTransientMedia, savePrivateMedia } from "./media.js";
|
|
16
13
|
/** Normalize a Feishu message's parsed content by type → text + any media keys (pure; download done by caller). */
|
|
17
14
|
export function parseFeishuContent(messageType, content) {
|
|
18
15
|
if (messageType === "text")
|
|
@@ -41,31 +38,39 @@ export function flattenPost(content) {
|
|
|
41
38
|
}
|
|
42
39
|
return out.join(" ").trim();
|
|
43
40
|
}
|
|
44
|
-
async function downloadFeishuResource(client, messageId, fileKey, type) {
|
|
41
|
+
async function downloadFeishuResource(client, messageId, fileKey, type, options) {
|
|
45
42
|
try {
|
|
46
|
-
const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type } });
|
|
47
|
-
|
|
48
|
-
mkdirSync(dir, { recursive: true });
|
|
49
|
-
const path = join(dir, `fs_${Date.now()}_${fileKey.slice(-8)}.${type === "image" ? "jpg" : "bin"}`);
|
|
50
|
-
if (typeof resp?.writeFile === "function")
|
|
51
|
-
await resp.writeFile(path);
|
|
52
|
-
else if (typeof resp?.getReadableStream === "function")
|
|
53
|
-
await pipeline(resp.getReadableStream(), createWriteStream(path));
|
|
54
|
-
else
|
|
43
|
+
const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type } }, { signal: options.signal });
|
|
44
|
+
if (typeof resp?.getReadableStream !== "function")
|
|
55
45
|
return null;
|
|
56
|
-
return
|
|
46
|
+
return await savePrivateMedia(resp.getReadableStream(), {
|
|
47
|
+
platform: "feishu",
|
|
48
|
+
filenameHint: type === "image" ? "image.jpg" : "file.bin",
|
|
49
|
+
contentType: resp?.headers?.["content-type"],
|
|
50
|
+
...options,
|
|
51
|
+
});
|
|
57
52
|
}
|
|
58
53
|
catch {
|
|
59
54
|
return null;
|
|
60
55
|
}
|
|
61
56
|
}
|
|
62
|
-
/** Build an InboundMsg from a Feishu im.message.receive_v1 event (downloads media).
|
|
63
|
-
|
|
57
|
+
/** Build an InboundMsg from a Feishu im.message.receive_v1 event (downloads media). Handles BOTH p2p (DM) and
|
|
58
|
+
* group messages — group @-mentions are surfaced (with `isSelf`) so gateway flows can target them. null = skip. */
|
|
59
|
+
async function toInbound(client, data, botOpenId, signal, shouldDownload) {
|
|
64
60
|
const msg = data?.message;
|
|
65
|
-
if (!msg?.chat_id
|
|
66
|
-
return null;
|
|
61
|
+
if (!msg?.chat_id)
|
|
62
|
+
return null;
|
|
63
|
+
const chatType = msg.chat_type === "p2p" ? "p2p" : msg.chat_type === "group" ? "group" : undefined;
|
|
67
64
|
const sender = data?.sender?.sender_id;
|
|
68
65
|
const userId = String(sender?.open_id || sender?.user_id || msg.chat_id);
|
|
66
|
+
const rawMentions = Array.isArray(msg.mentions) ? msg.mentions : [];
|
|
67
|
+
const mentions = rawMentions.length
|
|
68
|
+
? rawMentions.map((x) => ({
|
|
69
|
+
id: x?.id?.open_id || x?.id?.user_id || (typeof x?.id === "string" ? x.id : undefined),
|
|
70
|
+
name: x?.name,
|
|
71
|
+
isSelf: !!botOpenId && x?.id?.open_id === botOpenId,
|
|
72
|
+
}))
|
|
73
|
+
: undefined;
|
|
69
74
|
let content = {};
|
|
70
75
|
try {
|
|
71
76
|
content = JSON.parse(msg.content ?? "{}");
|
|
@@ -76,73 +81,167 @@ async function toInbound(client, data) {
|
|
|
76
81
|
const parsed = parseFeishuContent(String(msg.message_type), content);
|
|
77
82
|
let text = parsed.text;
|
|
78
83
|
const images = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const transientFiles = [];
|
|
85
|
+
const mediaMarker = parsed.imageKey ? "[图片]" : parsed.fileKey ? "[附件]" : "";
|
|
86
|
+
const base = {
|
|
87
|
+
chatId: String(msg.chat_id),
|
|
88
|
+
userId,
|
|
89
|
+
userName: userId,
|
|
90
|
+
text: text || mediaMarker,
|
|
91
|
+
chatType,
|
|
92
|
+
mentions,
|
|
93
|
+
};
|
|
94
|
+
let handedOff = false;
|
|
95
|
+
try {
|
|
96
|
+
if (shouldDownload?.(base) === true && (parsed.imageKey || parsed.fileKey)) {
|
|
97
|
+
const budget = new InboundMediaBudget("feishu", signal);
|
|
98
|
+
if (parsed.imageKey) {
|
|
99
|
+
const p = await budget.download((options) => downloadFeishuResource(client, msg.message_id, parsed.imageKey, "image", options));
|
|
100
|
+
if (p) {
|
|
101
|
+
images.push(p);
|
|
102
|
+
transientFiles.push(p);
|
|
103
|
+
text = "[图片]";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (parsed.fileKey) {
|
|
107
|
+
const p = await budget.download((options) => downloadFeishuResource(client, msg.message_id, parsed.fileKey, "file", options));
|
|
108
|
+
const label = parsed.fileName === "audio" ? "语音" : `文件 ${parsed.fileName ?? ""}`.trim();
|
|
109
|
+
if (p) {
|
|
110
|
+
transientFiles.push(p);
|
|
111
|
+
text = `[${label}: ${p}]`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
84
114
|
}
|
|
115
|
+
// Make @-placeholders readable: Feishu puts "@_user_1" tokens in text + a mentions[] carrying their names.
|
|
116
|
+
for (const x of rawMentions)
|
|
117
|
+
if (x?.key && x?.name)
|
|
118
|
+
text = text.split(String(x.key)).join(`@${x.name}`);
|
|
119
|
+
text = text.trim();
|
|
120
|
+
if (!text && !images.length && !(mentions && mentions.length))
|
|
121
|
+
return null;
|
|
122
|
+
const inbound = {
|
|
123
|
+
...base,
|
|
124
|
+
text: text || mediaMarker || "[消息]",
|
|
125
|
+
images: images.length ? images : undefined,
|
|
126
|
+
transientFiles: transientFiles.length ? transientFiles : undefined,
|
|
127
|
+
};
|
|
128
|
+
handedOff = true;
|
|
129
|
+
return inbound;
|
|
85
130
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (p)
|
|
90
|
-
text = `[${label}: ${p}]`;
|
|
131
|
+
finally {
|
|
132
|
+
if (!handedOff && transientFiles.length)
|
|
133
|
+
await cleanupTransientMedia("feishu", transientFiles);
|
|
91
134
|
}
|
|
92
|
-
text = text.trim();
|
|
93
|
-
if (!text && !images.length)
|
|
94
|
-
return null;
|
|
95
|
-
return { chatId: String(msg.chat_id), userId, userName: userId, text: text || "[图片]", images: images.length ? images : undefined };
|
|
96
135
|
}
|
|
97
136
|
export function feishuAdapter(appId, appSecret) {
|
|
98
137
|
const domain = process.env.HARA_FEISHU_DOMAIN === "lark" ? lark.Domain.Lark : lark.Domain.Feishu;
|
|
99
138
|
const client = new lark.Client({ appId, appSecret, domain });
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
139
|
+
// Self-healing long connection. The SDK's pong watchdog (wsConfig.pingTimeout) is OFF by default, which lets a
|
|
140
|
+
// silently-dropped socket stay "ready" forever while events just stop — the exact failure that made this
|
|
141
|
+
// gateway go deaf. Turning it on means: no inbound frame within pingTimeout seconds of a ping → presumed dead
|
|
142
|
+
// → reconnect. handshakeTimeoutMs stops a stuck DNS/proxy handshake from hanging. Lifecycle logs give
|
|
143
|
+
// visibility so a reconnect is observable instead of a mystery silence.
|
|
144
|
+
const wsClient = new lark.WSClient({
|
|
145
|
+
appId,
|
|
146
|
+
appSecret,
|
|
147
|
+
domain,
|
|
148
|
+
autoReconnect: true,
|
|
149
|
+
wsConfig: { pingTimeout: 10 },
|
|
150
|
+
handshakeTimeoutMs: 15_000,
|
|
151
|
+
onReconnecting: () => console.error("hara feishu: ⟳ ws reconnecting…"),
|
|
152
|
+
onReconnected: () => console.error("hara feishu: ✓ ws reconnected"),
|
|
153
|
+
onError: (err) => console.error(`hara feishu: ws error — ${err?.message ?? err}`),
|
|
154
|
+
});
|
|
155
|
+
// The bot's own open_id — resolved once, lazily — so a group message that @-mentions the bot can be flagged
|
|
156
|
+
// isSelf (what a flow's `mention:"self"` triggers on). Failure degrades gracefully: isSelf just stays false.
|
|
157
|
+
let botOpenId;
|
|
158
|
+
let botOpenIdAttemptAt = Number.NEGATIVE_INFINITY;
|
|
159
|
+
const ensureBotOpenId = async () => {
|
|
160
|
+
if (botOpenId)
|
|
161
|
+
return botOpenId;
|
|
162
|
+
if (Date.now() - botOpenIdAttemptAt < 60_000)
|
|
163
|
+
return undefined;
|
|
164
|
+
botOpenIdAttemptAt = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
const r = await client.request({ method: "GET", url: "/open-apis/bot/v3/info" });
|
|
167
|
+
botOpenId = r?.bot?.open_id ?? r?.data?.bot?.open_id ?? undefined;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
botOpenId = undefined;
|
|
171
|
+
}
|
|
172
|
+
return botOpenId;
|
|
173
|
+
};
|
|
174
|
+
const sendMsg = async (chatId, msgType, content) => {
|
|
175
|
+
const response = await client.im.message.create({
|
|
176
|
+
params: { receive_id_type: "chat_id" },
|
|
177
|
+
data: { receive_id: String(chatId), msg_type: msgType, content: JSON.stringify(content) },
|
|
178
|
+
});
|
|
179
|
+
if (typeof response?.code === "number" && response.code !== 0) {
|
|
180
|
+
throw new Error(`Feishu send failed: code=${response.code}${response.msg ? ` · ${response.msg}` : ""}`);
|
|
181
|
+
}
|
|
182
|
+
return response;
|
|
183
|
+
};
|
|
104
184
|
return {
|
|
105
185
|
name: "feishu",
|
|
106
186
|
async send(chatId, text) {
|
|
107
187
|
for (const part of chunkText(text || "(empty)", 4000))
|
|
108
188
|
await sendMsg(chatId, "text", { text: part });
|
|
109
189
|
},
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
190
|
+
// Track + recall: lets the gateway clean up transient UX messages ("⟳ working…") once the real reply
|
|
191
|
+
// lands — Feishu permits deleting the bot's own messages (DELETE im/v1/messages/:id).
|
|
192
|
+
async sendTracked(chatId, text) {
|
|
193
|
+
const r = await sendMsg(chatId, "text", { text });
|
|
194
|
+
return r?.data?.message_id ?? r?.message_id ?? undefined;
|
|
195
|
+
},
|
|
196
|
+
async recall(_chatId, messageId) {
|
|
113
197
|
try {
|
|
114
|
-
|
|
115
|
-
const up = await client.im.image.create({ data: { image_type: "message", image: createReadStream(filePath) } });
|
|
116
|
-
const key = up?.image_key ?? up?.data?.image_key;
|
|
117
|
-
if (key)
|
|
118
|
-
await sendMsg(chatId, "image", { image_key: key });
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
const up = await client.im.file.create({ data: { file_type: "stream", file_name: name, file: createReadStream(filePath) } });
|
|
122
|
-
const key = up?.file_key ?? up?.data?.file_key;
|
|
123
|
-
if (key)
|
|
124
|
-
await sendMsg(chatId, "file", { file_key: key });
|
|
125
|
-
}
|
|
198
|
+
await client.im.message.delete({ path: { message_id: messageId } });
|
|
126
199
|
}
|
|
127
200
|
catch {
|
|
128
|
-
/*
|
|
201
|
+
/* best-effort cleanup — an unrecallable message just stays */
|
|
129
202
|
}
|
|
130
203
|
},
|
|
131
|
-
async
|
|
204
|
+
async sendFile(chatId, file) {
|
|
205
|
+
const name = file.safeName;
|
|
206
|
+
const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
207
|
+
if (isImg) {
|
|
208
|
+
const up = await client.im.image.create({ data: { image_type: "message", image: file.bytes } });
|
|
209
|
+
const key = up?.image_key ?? up?.data?.image_key;
|
|
210
|
+
if (!key)
|
|
211
|
+
throw new Error("Feishu image upload returned no image_key");
|
|
212
|
+
await sendMsg(chatId, "image", { image_key: key });
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const up = await client.im.file.create({ data: { file_type: "stream", file_name: name, file: file.bytes } });
|
|
216
|
+
const key = up?.file_key ?? up?.data?.file_key;
|
|
217
|
+
if (!key)
|
|
218
|
+
throw new Error("Feishu file upload returned no file_key");
|
|
219
|
+
await sendMsg(chatId, "file", { file_key: key });
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
async start(onMessage, signal, shouldDownload) {
|
|
132
223
|
const eventDispatcher = new lark.EventDispatcher({}).register({
|
|
133
224
|
"im.message.receive_v1": async (data) => {
|
|
134
|
-
const m = await toInbound(client, data);
|
|
225
|
+
const m = await toInbound(client, data, await ensureBotOpenId(), signal, shouldDownload);
|
|
135
226
|
if (m)
|
|
136
|
-
await onMessage(m).catch(() => { });
|
|
227
|
+
await onMessage(m).catch((error) => console.error(`hara feishu: message handling failed — ${error instanceof Error ? error.message : String(error)}`));
|
|
137
228
|
},
|
|
138
229
|
});
|
|
139
|
-
wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect)
|
|
140
|
-
//
|
|
230
|
+
wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect + watchdog)
|
|
231
|
+
// Keep the adapter alive until the gateway aborts, then CLOSE the WSClient so its socket + timers release
|
|
232
|
+
// the event loop and the process actually exits on SIGTERM (previously the live connection pinned the
|
|
233
|
+
// loop, so only kill -9 worked — and a hard kill leaves a dirty disconnect that Feishu can throttle).
|
|
141
234
|
await new Promise((resolve) => {
|
|
142
235
|
if (signal.aborted)
|
|
143
236
|
return resolve();
|
|
144
237
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
145
238
|
});
|
|
239
|
+
try {
|
|
240
|
+
wsClient.close();
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* best-effort clean shutdown */
|
|
244
|
+
}
|
|
146
245
|
},
|
|
147
246
|
};
|
|
148
247
|
}
|