@nanhara/hara 0.70.0 → 0.95.2
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 +395 -0
- package/README.md +7 -2
- package/dist/agent/compact.js +10 -0
- package/dist/agent/context-report.js +33 -0
- package/dist/agent/failover.js +53 -0
- package/dist/agent/loop.js +120 -13
- package/dist/agent/rewind.js +20 -0
- package/dist/agent/route.js +47 -0
- package/dist/checkpoints.js +91 -0
- package/dist/config.js +33 -9
- package/dist/context/subdir-hints.js +76 -0
- package/dist/cron/runner.js +7 -4
- package/dist/exec/jobs.js +82 -0
- package/dist/gateway/dingtalk.js +209 -0
- package/dist/gateway/discord.js +164 -0
- package/dist/gateway/feishu.js +144 -0
- package/dist/gateway/matrix.js +188 -0
- package/dist/gateway/mattermost.js +206 -0
- package/dist/gateway/serve.js +339 -0
- package/dist/gateway/sessions.js +89 -0
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/slack.js +190 -0
- package/dist/gateway/telegram.js +115 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/tts.js +100 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/gateway/weixin.js +590 -0
- package/dist/index.js +1073 -120
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/sandbox.js +15 -12
- package/dist/security/external-content.js +57 -0
- package/dist/security/permissions.js +211 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/builtin.js +49 -2
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/send.js +35 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tools/web.js +4 -3
- package/dist/tui/App.js +241 -30
- package/dist/tui/run.js +36 -1
- package/package.json +4 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Matrix adapter for `hara gateway` — client-server API with a /sync long-poll (built-in fetch, zero new dep;
|
|
2
|
+
// no WebSocket). Mirrors the Telegram long-poll model and the Discord download-media/sendFile patterns. Creds
|
|
3
|
+
// from HARA_MATRIX_HOMESERVER (e.g. https://matrix.org) + HARA_MATRIX_TOKEN (access token) + HARA_MATRIX_USER_ID
|
|
4
|
+
// (the bot's own @user:server, for self-filtering). Same ChatAdapter shape as the others, so all cross-platform
|
|
5
|
+
// gateway plumbing (send_file, system context, stuck-guard, image attach/describe) works unchanged.
|
|
6
|
+
//
|
|
7
|
+
// LIMITATION (v1): NO end-to-end encryption. Encrypted rooms (m.room.encrypted events) are skipped — only
|
|
8
|
+
// plaintext rooms work. E2EE would need libolm + a crypto store (see hermes' matrix-nio adapter), which breaks
|
|
9
|
+
// the zero-dep constraint. Invite this bot into UNENCRYPTED rooms only.
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join, basename } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { chunkText } from "./telegram.js";
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
16
|
+
const mimeFromExt = (name) => {
|
|
17
|
+
const ext = name.toLowerCase().split(".").pop() ?? "";
|
|
18
|
+
const map = {
|
|
19
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
20
|
+
mp4: "video/mp4", mp3: "audio/mpeg", ogg: "audio/ogg", pdf: "application/pdf", txt: "text/plain",
|
|
21
|
+
};
|
|
22
|
+
return map[ext] ?? "application/octet-stream";
|
|
23
|
+
};
|
|
24
|
+
/** Parse `mxc://server/mediaId` → its two parts (pure). null if not an mxc URL. */
|
|
25
|
+
export function parseMxc(mxc) {
|
|
26
|
+
if (typeof mxc !== "string" || !mxc.startsWith("mxc://"))
|
|
27
|
+
return null;
|
|
28
|
+
const rest = mxc.slice(6); // strip "mxc://"
|
|
29
|
+
const slash = rest.indexOf("/");
|
|
30
|
+
if (slash < 0)
|
|
31
|
+
return null;
|
|
32
|
+
const server = rest.slice(0, slash);
|
|
33
|
+
const mediaId = rest.slice(slash + 1);
|
|
34
|
+
if (!server || !mediaId)
|
|
35
|
+
return null;
|
|
36
|
+
return { server, mediaId };
|
|
37
|
+
}
|
|
38
|
+
/** Extract an InboundMsg from a single Matrix timeline event (pure). Accepts m.room.message of msgtype m.text
|
|
39
|
+
* (and friends carrying a body) plus m.image (→ marker text + the mxc url to download in start()). Ignores our
|
|
40
|
+
* own messages and encrypted events. Returns the InboundMsg + the image's mxc url (null if none). null = skip. */
|
|
41
|
+
export function parseMatrixEvent(event, selfUserId) {
|
|
42
|
+
if (!event || event.type !== "m.room.message")
|
|
43
|
+
return null; // encrypted (m.room.encrypted) / state events → skip
|
|
44
|
+
const sender = typeof event.sender === "string" ? event.sender : "";
|
|
45
|
+
const roomId = typeof event.__roomId === "string" ? event.__roomId : ""; // injected by start() (events carry no room id)
|
|
46
|
+
if (!sender || !roomId)
|
|
47
|
+
return null;
|
|
48
|
+
if (sender === selfUserId)
|
|
49
|
+
return null; // ignore our own messages
|
|
50
|
+
const content = event.content ?? {};
|
|
51
|
+
const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
|
|
52
|
+
const body = typeof content.body === "string" ? content.body : "";
|
|
53
|
+
let imageMxc = null;
|
|
54
|
+
let text = body;
|
|
55
|
+
if (msgtype === "m.image") {
|
|
56
|
+
imageMxc = typeof content.url === "string" && content.url.startsWith("mxc://") ? content.url : null;
|
|
57
|
+
text = body || "[图片]";
|
|
58
|
+
}
|
|
59
|
+
else if (!body) {
|
|
60
|
+
return null; // text-less non-image (sticker/location/reaction-as-message/etc.)
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
msg: {
|
|
64
|
+
chatId: roomId,
|
|
65
|
+
userId: sender,
|
|
66
|
+
userName: sender,
|
|
67
|
+
text: text || "[图片]",
|
|
68
|
+
},
|
|
69
|
+
imageMxc,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** 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) {
|
|
74
|
+
const parts = parseMxc(mxc);
|
|
75
|
+
if (!parts)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const url = `${homeserver}/_matrix/media/v3/download/${encodeURIComponent(parts.server)}/${encodeURIComponent(parts.mediaId)}`;
|
|
79
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
80
|
+
if (!r.ok)
|
|
81
|
+
return null;
|
|
82
|
+
const dir = join(homedir(), ".hara", "matrix", "media");
|
|
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;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function matrixAdapter(homeserver, token, selfUserId) {
|
|
93
|
+
const base = homeserver.replace(/\/+$/, ""); // trim trailing slashes
|
|
94
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
95
|
+
let txnSeq = Date.now(); // monotonically increasing txn id base (survives clock-ish across a process run)
|
|
96
|
+
const nextTxn = () => `hara${++txnSeq}`;
|
|
97
|
+
return {
|
|
98
|
+
name: "matrix",
|
|
99
|
+
async send(chatId, text) {
|
|
100
|
+
for (const part of chunkText(text || "(empty)", 4000)) {
|
|
101
|
+
const txn = nextTxn();
|
|
102
|
+
await fetch(`${base}/_matrix/client/v3/rooms/${encodeURIComponent(String(chatId))}/send/m.room.message/${txn}`, {
|
|
103
|
+
method: "PUT",
|
|
104
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ msgtype: "m.text", body: part }),
|
|
106
|
+
}).catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
async sendFile(chatId, filePath) {
|
|
110
|
+
const name = basename(filePath);
|
|
111
|
+
const mime = mimeFromExt(name);
|
|
112
|
+
try {
|
|
113
|
+
// 1) upload bytes → content_uri (mxc://)
|
|
114
|
+
const up = await fetch(`${base}/_matrix/media/v3/upload?filename=${encodeURIComponent(name)}`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { ...auth, "content-type": mime },
|
|
117
|
+
body: readFileSync(filePath),
|
|
118
|
+
});
|
|
119
|
+
if (!up.ok)
|
|
120
|
+
return;
|
|
121
|
+
const j = (await up.json());
|
|
122
|
+
const mxc = j?.content_uri;
|
|
123
|
+
if (!mxc)
|
|
124
|
+
return;
|
|
125
|
+
// 2) send a referencing m.room.message — m.image for images, m.file otherwise
|
|
126
|
+
const msgtype = isImage(name, mime) ? "m.image" : "m.file";
|
|
127
|
+
const txn = nextTxn();
|
|
128
|
+
await fetch(`${base}/_matrix/client/v3/rooms/${encodeURIComponent(String(chatId))}/send/m.room.message/${txn}`, {
|
|
129
|
+
method: "PUT",
|
|
130
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ msgtype, body: name, filename: name, url: mxc, info: { mimetype: mime } }),
|
|
132
|
+
}).catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* upload/send failed — surfaced upstream as "no file delivered" */
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
async start(onMessage, signal) {
|
|
139
|
+
// Prime the cursor: a since-less, timeout=0 sync skips backlog so we only see messages from now on.
|
|
140
|
+
let since = "";
|
|
141
|
+
try {
|
|
142
|
+
const r = await fetch(`${base}/_matrix/client/v3/sync?timeout=0`, { headers: auth, signal });
|
|
143
|
+
if (r.ok) {
|
|
144
|
+
const j = (await r.json());
|
|
145
|
+
if (typeof j.next_batch === "string")
|
|
146
|
+
since = j.next_batch;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
if (signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
while (!signal.aborted) {
|
|
154
|
+
try {
|
|
155
|
+
const q = `timeout=30000${since ? `&since=${encodeURIComponent(since)}` : ""}`;
|
|
156
|
+
const res = await fetch(`${base}/_matrix/client/v3/sync?${q}`, { headers: auth, signal });
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
await sleep(2000);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const j = (await res.json());
|
|
162
|
+
if (typeof j.next_batch === "string")
|
|
163
|
+
since = j.next_batch;
|
|
164
|
+
const join = j.rooms?.join ?? {};
|
|
165
|
+
for (const [roomId, room] of Object.entries(join)) {
|
|
166
|
+
for (const event of room?.timeline?.events ?? []) {
|
|
167
|
+
event.__roomId = roomId; // events carry no room id in /sync — inject it for the pure parser
|
|
168
|
+
const parsed = parseMatrixEvent(event, selfUserId);
|
|
169
|
+
if (!parsed)
|
|
170
|
+
continue;
|
|
171
|
+
if (parsed.imageMxc) {
|
|
172
|
+
const path = await downloadMatrixMedia(base, token, parsed.imageMxc);
|
|
173
|
+
if (path)
|
|
174
|
+
parsed.msg.images = [path];
|
|
175
|
+
}
|
|
176
|
+
await onMessage(parsed.msg).catch(() => { });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
if (signal.aborted)
|
|
182
|
+
break;
|
|
183
|
+
await sleep(2000); // network blip → back off + retry
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Mattermost adapter for `hara gateway` — connects to a self-hosted (or cloud) Mattermost over the v4
|
|
2
|
+
// WebSocket (Node's native global WebSocket, zero new dep on Node ≥ 22) for inbound events, and the v4 REST
|
|
3
|
+
// API for outbound. Server from HARA_MATTERMOST_URL, token (bot or personal-access) from HARA_MATTERMOST_TOKEN;
|
|
4
|
+
// allow users via HARA_GATEWAY_ALLOWED (Mattermost user ids). Same ChatAdapter shape as Telegram/Discord, so all
|
|
5
|
+
// the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
|
|
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";
|
|
10
|
+
import { chunkText } from "./telegram.js";
|
|
11
|
+
const WSImpl = globalThis.WebSocket;
|
|
12
|
+
const sleep = (ms, signal) => new Promise((r) => {
|
|
13
|
+
if (signal?.aborted)
|
|
14
|
+
return r();
|
|
15
|
+
const t = setTimeout(r, ms);
|
|
16
|
+
signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
|
|
17
|
+
});
|
|
18
|
+
const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
19
|
+
/** Strip a trailing slash and any `/api/v4` suffix so we can rebuild paths cleanly (pure). */
|
|
20
|
+
function normalizeBase(raw) {
|
|
21
|
+
return raw.trim().replace(/\/+$/, "").replace(/\/api\/v4$/i, "");
|
|
22
|
+
}
|
|
23
|
+
/** http(s):// base → wss(s):// websocket endpoint (pure). */
|
|
24
|
+
function wsUrlFromBase(base) {
|
|
25
|
+
return normalizeBase(base).replace(/^http/i, "ws") + "/api/v4/websocket";
|
|
26
|
+
}
|
|
27
|
+
async function downloadMattermostFile(base, token, fileId, name) {
|
|
28
|
+
try {
|
|
29
|
+
const r = await fetch(`${normalizeBase(base)}/api/v4/files/${encodeURIComponent(fileId)}`, {
|
|
30
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
31
|
+
});
|
|
32
|
+
if (!r.ok)
|
|
33
|
+
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;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Parse a Mattermost "posted" event's post → InboundMsg + its image file ids (pure; download happens in
|
|
45
|
+
* start()). The post is the already-parsed object (the event's data.post is a JSON string the caller decodes).
|
|
46
|
+
* null = ignore (own post / system post / empty). */
|
|
47
|
+
export function parseMattermostPost(post, selfUserId) {
|
|
48
|
+
if (!post?.channel_id || !post?.user_id)
|
|
49
|
+
return null;
|
|
50
|
+
if (post.user_id === selfUserId)
|
|
51
|
+
return null; // ignore our own posts
|
|
52
|
+
if (post.type)
|
|
53
|
+
return null; // system post (join/leave/etc.) — type "" is a normal user post
|
|
54
|
+
const fileIds = Array.isArray(post.file_ids) ? post.file_ids.map((f) => String(f)) : [];
|
|
55
|
+
// file_ids alone don't tell us the mime; the caller resolves per-file. Here we surface them all and let the
|
|
56
|
+
// download step filter to images. To stay pure we pass them through as candidate image ids.
|
|
57
|
+
const imageFileIds = fileIds;
|
|
58
|
+
const text = String(post.message ?? "");
|
|
59
|
+
if (!text && !imageFileIds.length)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
msg: {
|
|
63
|
+
chatId: String(post.channel_id),
|
|
64
|
+
userId: String(post.user_id),
|
|
65
|
+
userName: String(post.user_id), // enriched by start() from the event's sender_name when available
|
|
66
|
+
text: text || "[图片]",
|
|
67
|
+
},
|
|
68
|
+
imageFileIds,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function mattermostAdapter(serverUrl, token) {
|
|
72
|
+
const base = normalizeBase(serverUrl);
|
|
73
|
+
const api = `${base}/api/v4`;
|
|
74
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
75
|
+
return {
|
|
76
|
+
name: "mattermost",
|
|
77
|
+
async send(chatId, text) {
|
|
78
|
+
for (const part of chunkText(text || "(empty)", 4000)) {
|
|
79
|
+
await fetch(`${api}/posts`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ channel_id: chatId, message: part }),
|
|
83
|
+
}).catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async sendFile(chatId, filePath) {
|
|
87
|
+
// upload the file (multipart) → get a file_id, then create a post referencing it
|
|
88
|
+
const form = new FormData();
|
|
89
|
+
form.append("channel_id", String(chatId));
|
|
90
|
+
form.append("files", new Blob([readFileSync(filePath)]), basename(filePath));
|
|
91
|
+
const up = await fetch(`${api}/files`, { method: "POST", headers: auth, body: form }).catch(() => null);
|
|
92
|
+
if (!up || !up.ok)
|
|
93
|
+
return;
|
|
94
|
+
const j = (await up.json().catch(() => null));
|
|
95
|
+
const fileId = j?.file_infos?.[0]?.id;
|
|
96
|
+
if (!fileId)
|
|
97
|
+
return;
|
|
98
|
+
await fetch(`${api}/posts`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ channel_id: chatId, message: "", file_ids: [fileId] }),
|
|
102
|
+
}).catch(() => { });
|
|
103
|
+
},
|
|
104
|
+
async start(onMessage, signal) {
|
|
105
|
+
if (!WSImpl) {
|
|
106
|
+
console.error("hara gateway: Mattermost needs Node ≥ 22 (global WebSocket). Upgrade Node.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
while (!signal.aborted) {
|
|
110
|
+
await connectOnce(base, token, onMessage, signal);
|
|
111
|
+
if (!signal.aborted)
|
|
112
|
+
await sleep(3000, signal); // reconnect backoff
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** One WS connection: send authentication_challenge, then dispatch "posted" events. Resolves on close/abort;
|
|
118
|
+
* the caller reconnects. v1 keeps it simple — fresh auth each time, no resume. The bot's own user id is
|
|
119
|
+
* resolved via GET /users/me so we can drop our own echoes. */
|
|
120
|
+
function connectOnce(base, token, onMessage, signal) {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const ws = new WSImpl(wsUrlFromBase(base));
|
|
123
|
+
let seq = 1;
|
|
124
|
+
let selfId = "";
|
|
125
|
+
const stop = () => {
|
|
126
|
+
signal.removeEventListener("abort", stop);
|
|
127
|
+
try {
|
|
128
|
+
ws.close();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* already closing */
|
|
132
|
+
}
|
|
133
|
+
resolve();
|
|
134
|
+
};
|
|
135
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
136
|
+
ws.addEventListener("close", () => {
|
|
137
|
+
signal.removeEventListener("abort", stop);
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
ws.addEventListener("error", () => { });
|
|
141
|
+
ws.addEventListener("open", async () => {
|
|
142
|
+
// resolve our own user id first so parseMattermostPost can ignore our echoes
|
|
143
|
+
try {
|
|
144
|
+
const me = await fetch(`${base}/api/v4/users/me`, { headers: { Authorization: `Bearer ${token}` } });
|
|
145
|
+
if (me.ok)
|
|
146
|
+
selfId = String(((await me.json())?.id) ?? "");
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* best-effort; without it we just won't filter our own posts */
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
ws.send(JSON.stringify({ seq: seq++, action: "authentication_challenge", data: { token } }));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* socket gone */
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
ws.addEventListener("message", async (ev) => {
|
|
159
|
+
let p;
|
|
160
|
+
try {
|
|
161
|
+
p = JSON.parse(String(ev.data));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (p.event !== "posted")
|
|
167
|
+
return;
|
|
168
|
+
let post;
|
|
169
|
+
try {
|
|
170
|
+
post = JSON.parse(String(p.data?.post ?? ""));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const parsed = parseMattermostPost(post, selfId);
|
|
176
|
+
if (!parsed)
|
|
177
|
+
return;
|
|
178
|
+
// enrich the display name from the event payload when the server includes it
|
|
179
|
+
const senderName = String(p.data?.sender_name ?? "").replace(/^@/, "");
|
|
180
|
+
if (senderName)
|
|
181
|
+
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 ?? "");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
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
|
+
}
|
|
203
|
+
await onMessage(parsed.msg).catch(() => { });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|