@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
@@ -4,9 +4,9 @@
4
4
  // allow users via HARA_GATEWAY_ALLOWED (Mattermost user ids). Same ChatAdapter shape as Telegram/Discord, so all
5
5
  // the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
6
6
  // works unchanged. Auth is a WS "authentication_challenge" rather than an HTTP header.
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
- import { join, basename } from "node:path";
9
- import { homedir } from "node:os";
7
+ import { readFileSync } from "node:fs";
8
+ import { basename } from "node:path";
9
+ import { InboundMediaBudget, savePrivateResponse } from "./media.js";
10
10
  import { chunkText } from "./telegram.js";
11
11
  const WSImpl = globalThis.WebSocket;
12
12
  const sleep = (ms, signal) => new Promise((r) => {
@@ -24,18 +24,15 @@ function normalizeBase(raw) {
24
24
  function wsUrlFromBase(base) {
25
25
  return normalizeBase(base).replace(/^http/i, "ws") + "/api/v4/websocket";
26
26
  }
27
- async function downloadMattermostFile(base, token, fileId, name) {
27
+ async function downloadMattermostFile(base, token, fileId, name, options) {
28
28
  try {
29
29
  const r = await fetch(`${normalizeBase(base)}/api/v4/files/${encodeURIComponent(fileId)}`, {
30
30
  headers: { Authorization: `Bearer ${token}` },
31
+ signal: options.signal,
31
32
  });
32
33
  if (!r.ok)
33
34
  return null;
34
- const dir = join(homedir(), ".hara", "mattermost", "media");
35
- mkdirSync(dir, { recursive: true });
36
- const path = join(dir, `mm_${Date.now()}_${basename(name) || "file.bin"}`);
37
- writeFileSync(path, Buffer.from(await r.arrayBuffer()));
38
- return path;
35
+ return await savePrivateResponse(r, { platform: "mattermost", filenameHint: name, ...options });
39
36
  }
40
37
  catch {
41
38
  return null;
@@ -44,7 +41,11 @@ async function downloadMattermostFile(base, token, fileId, name) {
44
41
  /** Parse a Mattermost "posted" event's post → InboundMsg + its image file ids (pure; download happens in
45
42
  * start()). The post is the already-parsed object (the event's data.post is a JSON string the caller decodes).
46
43
  * null = ignore (own post / system post / empty). */
47
- export function parseMattermostPost(post, selfUserId) {
44
+ export function mattermostChatType(channelType) {
45
+ // Mattermost channel types: D=direct, G=group DM, O=open, P=private. Unknown is never assumed to be a DM.
46
+ return String(channelType ?? "").trim().toUpperCase() === "D" ? "p2p" : "group";
47
+ }
48
+ export function parseMattermostPost(post, selfUserId, channelType) {
48
49
  if (!post?.channel_id || !post?.user_id)
49
50
  return null;
50
51
  if (post.user_id === selfUserId)
@@ -64,6 +65,7 @@ export function parseMattermostPost(post, selfUserId) {
64
65
  userId: String(post.user_id),
65
66
  userName: String(post.user_id), // enriched by start() from the event's sender_name when available
66
67
  text: text || "[图片]",
68
+ chatType: mattermostChatType(channelType),
67
69
  },
68
70
  imageFileIds,
69
71
  };
@@ -101,13 +103,13 @@ export function mattermostAdapter(serverUrl, token) {
101
103
  body: JSON.stringify({ channel_id: chatId, message: "", file_ids: [fileId] }),
102
104
  }).catch(() => { });
103
105
  },
104
- async start(onMessage, signal) {
106
+ async start(onMessage, signal, shouldDownload) {
105
107
  if (!WSImpl) {
106
108
  console.error("hara gateway: Mattermost needs Node ≥ 22 (global WebSocket). Upgrade Node.");
107
109
  return;
108
110
  }
109
111
  while (!signal.aborted) {
110
- await connectOnce(base, token, onMessage, signal);
112
+ await connectOnce(base, token, onMessage, signal, shouldDownload);
111
113
  if (!signal.aborted)
112
114
  await sleep(3000, signal); // reconnect backoff
113
115
  }
@@ -117,7 +119,7 @@ export function mattermostAdapter(serverUrl, token) {
117
119
  /** One WS connection: send authentication_challenge, then dispatch "posted" events. Resolves on close/abort;
118
120
  * the caller reconnects. v1 keeps it simple — fresh auth each time, no resume. The bot's own user id is
119
121
  * resolved via GET /users/me so we can drop our own echoes. */
120
- function connectOnce(base, token, onMessage, signal) {
122
+ function connectOnce(base, token, onMessage, signal, shouldDownload) {
121
123
  return new Promise((resolve) => {
122
124
  const ws = new WSImpl(wsUrlFromBase(base));
123
125
  let seq = 1;
@@ -172,33 +174,43 @@ function connectOnce(base, token, onMessage, signal) {
172
174
  catch {
173
175
  return;
174
176
  }
175
- const parsed = parseMattermostPost(post, selfId);
177
+ const parsed = parseMattermostPost(post, selfId, p.data?.channel_type);
176
178
  if (!parsed)
177
179
  return;
178
180
  // enrich the display name from the event payload when the server includes it
179
181
  const senderName = String(p.data?.sender_name ?? "").replace(/^@/, "");
180
182
  if (senderName)
181
183
  parsed.msg.userName = senderName;
182
- for (const fid of parsed.imageFileIds) {
183
- // resolve the file's mime/name, then download only images so the agent can SEE them
184
- let name = "image";
185
- let mime = "";
186
- try {
187
- const info = await fetch(`${base}/api/v4/files/${encodeURIComponent(fid)}/info`, { headers: { Authorization: `Bearer ${token}` } });
188
- if (info.ok) {
189
- const ij = (await info.json());
190
- name = String(ij?.name ?? name);
191
- mime = String(ij?.mime_type ?? "");
184
+ if (shouldDownload?.(parsed.msg) === true) {
185
+ const budget = new InboundMediaBudget("mattermost", signal);
186
+ for (const fid of parsed.imageFileIds) {
187
+ const path = await budget.download(async (options) => {
188
+ // Resolve mime/name inside the same bounded slot; metadata is part of the download lifecycle too.
189
+ let name = "image";
190
+ let mime = "";
191
+ try {
192
+ const info = await fetch(`${base}/api/v4/files/${encodeURIComponent(fid)}/info`, {
193
+ headers: { Authorization: `Bearer ${token}` },
194
+ signal: options.signal,
195
+ });
196
+ if (info.ok) {
197
+ const ij = (await info.json());
198
+ name = String(ij?.name ?? name);
199
+ mime = String(ij?.mime_type ?? "");
200
+ }
201
+ }
202
+ catch {
203
+ /* fall back to extension sniff below */
204
+ }
205
+ if (!isImage(name, mime))
206
+ return null;
207
+ return downloadMattermostFile(base, token, fid, name, options);
208
+ });
209
+ if (path) {
210
+ (parsed.msg.images ??= []).push(path);
211
+ (parsed.msg.transientFiles ??= []).push(path);
192
212
  }
193
213
  }
194
- catch {
195
- /* fall back to extension sniff below */
196
- }
197
- if (!isImage(name, mime))
198
- continue;
199
- const path = await downloadMattermostFile(base, token, fid, name);
200
- if (path)
201
- (parsed.msg.images ??= []).push(path);
202
214
  }
203
215
  await onMessage(parsed.msg).catch(() => { });
204
216
  });