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