@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -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, 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";
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
- const dir = join(homedir(), ".hara", "feishu", "media");
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 path;
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). null = skip (not a DM / empty). */
63
- async function toInbound(client, data) {
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 || msg.chat_type !== "p2p")
66
- return null; // v1: direct messages only
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
- if (parsed.imageKey) {
80
- const p = await downloadFeishuResource(client, msg.message_id, parsed.imageKey, "image");
81
- if (p) {
82
- images.push(p);
83
- text = "[图片]";
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
- else if (parsed.fileKey) {
87
- const p = await downloadFeishuResource(client, msg.message_id, parsed.fileKey, "file");
88
- const label = parsed.fileName === "audio" ? "语音" : `文件 ${parsed.fileName ?? ""}`.trim();
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
- const wsClient = new lark.WSClient({ appId, appSecret, domain });
101
- const sendMsg = (chatId, msgType, content) => client.im.message
102
- .create({ params: { receive_id_type: "chat_id" }, data: { receive_id: String(chatId), msg_type: msgType, content: JSON.stringify(content) } })
103
- .catch(() => undefined);
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
- try {
114
- if (isImg) {
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
- }
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
- catch {
128
- /* upload/send failed surfaced upstream as "no file delivered" */
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
- // keep the adapter alive until the gateway aborts (the WSClient manages the socket itself)
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
  }