@nanhara/hara 0.67.0 → 0.89.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.
@@ -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
+ }
@@ -0,0 +1,282 @@
1
+ // `hara gateway` — an opt-in long-running daemon that lets you drive your LOCAL hara from a chat app
2
+ // (Telegram now; WeChat-iLink / Feishu via the same ChatAdapter next). Each inbound message → a fresh `hara`
3
+ // subprocess (the cron pattern) on that chat's session → the reply is sent back. This is hara's first
4
+ // persistent process; it is never required by the core CLI.
5
+ import { spawn } from "node:child_process";
6
+ import { telegramAdapter } from "./telegram.js";
7
+ import { chatContext, chatCd, newChatSession, setChatSession, toggleVoice } from "./sessions.js";
8
+ import { synthesize } from "./tts.js";
9
+ import { selfArgv } from "../cron/runner.js";
10
+ import { listSessions, resolveSessionId, loadSession } from "../session/store.js";
11
+ import { homedir, tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
14
+ /** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
15
+ export function parseCommand(text) {
16
+ const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
17
+ return m ? { cmd: m[1].toLowerCase(), arg: m[2].trim() } : null;
18
+ }
19
+ /** Whether a user may drive the gateway. Empty allowlist = nobody (safe default — never wide-open). */
20
+ export function isAllowed(userId, allowlist) {
21
+ return allowlist.size > 0 && allowlist.has(String(userId));
22
+ }
23
+ /** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
24
+ * (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
25
+ export function cleanReply(raw) {
26
+ return raw
27
+ .split("\n")
28
+ .filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
29
+ .join("\n")
30
+ .trim();
31
+ }
32
+ let outboxSeq = 0;
33
+ /** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
34
+ * via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
35
+ * in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
36
+ function runHara(text, sessionId, cwd, platform, images) {
37
+ const outbox = join(tmpdir(), `hara-outbox-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
38
+ return new Promise((res) => {
39
+ const self = selfArgv();
40
+ const child = spawn(self[0], [...self.slice(1), "-p", text, "--approval", "full-auto", "--resume", sessionId], {
41
+ cwd,
42
+ env: {
43
+ ...process.env,
44
+ HARA_GATEWAY: platform,
45
+ HARA_GATEWAY_OUTBOX: outbox,
46
+ ...(images?.length ? { HARA_GATEWAY_IMAGES: images.join("\n") } : {}),
47
+ },
48
+ });
49
+ let out = "";
50
+ const cap = (d) => {
51
+ out = (out + d.toString()).slice(-12000);
52
+ };
53
+ child.stdout.on("data", cap);
54
+ child.stderr.on("data", cap);
55
+ const finish = (reply) => {
56
+ let files = [];
57
+ try {
58
+ if (existsSync(outbox)) {
59
+ files = readFileSync(outbox, "utf8").split("\n").map((s) => s.trim()).filter(Boolean);
60
+ rmSync(outbox, { force: true });
61
+ }
62
+ }
63
+ catch {
64
+ /* outbox is best-effort; a missing/unreadable file just means nothing to send */
65
+ }
66
+ res({ reply, files });
67
+ };
68
+ child.on("error", (e) => finish(`(error: ${e.message})`));
69
+ child.on("close", () => finish(cleanReply(out) || "(no output)"));
70
+ });
71
+ }
72
+ /** Re-exported so `hara gateway --platform weixin --login` can run the QR flow. */
73
+ export { weixinLogin } from "./weixin.js";
74
+ async function buildAdapter(platform) {
75
+ if (platform === "weixin") {
76
+ const { loadWeixinCreds, weixinAdapter } = await import("./weixin.js");
77
+ const creds = loadWeixinCreds();
78
+ if (!creds) {
79
+ console.error("hara gateway: no WeChat login found. Run `hara gateway --platform weixin --login` first.");
80
+ return null;
81
+ }
82
+ // The iLink user_id is whoever scanned the QR — the bot owner. Auto-allow them so there's no wxid dance.
83
+ return { adapter: weixinAdapter(creds), ownerId: creds.user_id || undefined };
84
+ }
85
+ if (platform === "discord") {
86
+ const token = process.env.HARA_DISCORD_TOKEN;
87
+ if (!token) {
88
+ console.error("hara gateway: set HARA_DISCORD_TOKEN (Discord bot token) and HARA_GATEWAY_ALLOWED=<your discord user id>. Enable the Message Content Intent on the bot.");
89
+ return null;
90
+ }
91
+ const { discordAdapter } = await import("./discord.js");
92
+ return { adapter: discordAdapter(token) };
93
+ }
94
+ if (platform === "feishu" || platform === "lark") {
95
+ const appId = process.env.HARA_FEISHU_APP_ID;
96
+ const appSecret = process.env.HARA_FEISHU_APP_SECRET;
97
+ if (!appId || !appSecret) {
98
+ console.error("hara gateway: set HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET (Feishu app console) and HARA_GATEWAY_ALLOWED=<your open_id>. (HARA_FEISHU_DOMAIN=lark for larksuite.com.)");
99
+ return null;
100
+ }
101
+ const { feishuAdapter } = await import("./feishu.js");
102
+ return { adapter: feishuAdapter(appId, appSecret) };
103
+ }
104
+ if (platform === "slack") {
105
+ const appToken = process.env.HARA_SLACK_APP_TOKEN;
106
+ const botToken = process.env.HARA_SLACK_BOT_TOKEN;
107
+ if (!appToken || !botToken) {
108
+ console.error("hara gateway: set HARA_SLACK_APP_TOKEN (xapp-, Socket Mode app-level token w/ connections:write) + HARA_SLACK_BOT_TOKEN (xoxb-, bot token w/ chat:write,files:write,files:read,*:history) and HARA_GATEWAY_ALLOWED=<your slack user id>.");
109
+ return null;
110
+ }
111
+ const { slackAdapter } = await import("./slack.js");
112
+ return { adapter: slackAdapter(appToken, botToken) };
113
+ }
114
+ if (platform === "mattermost") {
115
+ const url = process.env.HARA_MATTERMOST_URL;
116
+ const token = process.env.HARA_MATTERMOST_TOKEN;
117
+ if (!url || !token) {
118
+ console.error("hara gateway: set HARA_MATTERMOST_URL (e.g. https://mm.example.com) + HARA_MATTERMOST_TOKEN (bot or personal-access token) and HARA_GATEWAY_ALLOWED=<your mattermost user id>.");
119
+ return null;
120
+ }
121
+ const { mattermostAdapter } = await import("./mattermost.js");
122
+ return { adapter: mattermostAdapter(url, token) };
123
+ }
124
+ if (platform === "matrix") {
125
+ const homeserver = process.env.HARA_MATRIX_HOMESERVER;
126
+ const token = process.env.HARA_MATRIX_TOKEN;
127
+ const userId = process.env.HARA_MATRIX_USER_ID;
128
+ if (!homeserver || !token || !userId) {
129
+ console.error("hara gateway: set HARA_MATRIX_HOMESERVER (e.g. https://matrix.org), HARA_MATRIX_TOKEN (access token), HARA_MATRIX_USER_ID (@bot:server) and HARA_GATEWAY_ALLOWED=<@you:server>. Unencrypted rooms only (no E2EE in v1).");
130
+ return null;
131
+ }
132
+ const { matrixAdapter } = await import("./matrix.js");
133
+ return { adapter: matrixAdapter(homeserver, token, userId), ownerId: userId };
134
+ }
135
+ if (platform === "dingtalk" || platform === "ding") {
136
+ const clientId = process.env.HARA_DINGTALK_CLIENT_ID;
137
+ const clientSecret = process.env.HARA_DINGTALK_CLIENT_SECRET;
138
+ if (!clientId || !clientSecret) {
139
+ console.error("hara gateway: set HARA_DINGTALK_CLIENT_ID + HARA_DINGTALK_CLIENT_SECRET (钉钉开放平台 app AppKey/AppSecret, Stream mode enabled on the bot) and HARA_GATEWAY_ALLOWED=<your senderStaffId>.");
140
+ return null;
141
+ }
142
+ const { dingtalkAdapter } = await import("./dingtalk.js");
143
+ return { adapter: dingtalkAdapter(clientId, clientSecret) };
144
+ }
145
+ const token = process.env.HARA_TELEGRAM_TOKEN;
146
+ if (!token) {
147
+ console.error("hara gateway: set HARA_TELEGRAM_TOKEN (from @BotFather) and HARA_GATEWAY_ALLOWED=<your telegram user id>.");
148
+ return null;
149
+ }
150
+ return { adapter: telegramAdapter(token) };
151
+ }
152
+ /** Allowlist = the env ids ∪ the bot owner (on WeChat, whoever scanned the QR is always allowed). */
153
+ export function resolveAllowlist(envValue, ownerId) {
154
+ const set = new Set((envValue ?? "").split(",").map((s) => s.trim()).filter(Boolean));
155
+ if (ownerId)
156
+ set.add(ownerId);
157
+ return set;
158
+ }
159
+ /** The gateway's default workspace when no --cwd is given: a dedicated safe home under ~/.hara (like Hermes'
160
+ * ~/.hermes), NOT the launch dir — so a full-auto chat bot never lands on a real repo by accident. */
161
+ export function defaultWorkspace() {
162
+ const dir = join(homedir(), ".hara", "workspace");
163
+ mkdirSync(dir, { recursive: true });
164
+ const agents = join(dir, "AGENTS.md");
165
+ if (!existsSync(agents)) {
166
+ writeFileSync(agents, "# hara chat workspace\n\nDefault working directory for `hara gateway` (Telegram/WeChat). Each message runs here with `--approval full-auto`. A safe scratch — pass `--cwd <dir>` to point the gateway at a real project instead.\n");
167
+ }
168
+ return dir;
169
+ }
170
+ export async function runGateway(opts) {
171
+ const platform = opts.platform || "telegram";
172
+ const cwd = opts.cwd ?? defaultWorkspace(); // dir-free default: hara's own ~/.hara/workspace, like Hermes' ~/.hermes
173
+ const built = await buildAdapter(platform);
174
+ if (!built)
175
+ process.exit(1);
176
+ const { adapter, ownerId } = built;
177
+ const allowlist = resolveAllowlist(process.env.HARA_GATEWAY_ALLOWED, ownerId);
178
+ if (allowlist.size === 0) {
179
+ const hint = platform === "weixin" ? "your WeChat id" : "your Telegram user id (DM @userinfobot)";
180
+ console.error(`hara gateway: ⚠ HARA_GATEWAY_ALLOWED is empty — nobody is allowed. Set it to ${hint}.`);
181
+ }
182
+ else if (ownerId) {
183
+ console.error(`hara gateway: bot owner auto-allowed (${ownerId}).`);
184
+ }
185
+ const ac = new AbortController();
186
+ process.on("SIGINT", () => ac.abort());
187
+ process.on("SIGTERM", () => ac.abort());
188
+ console.error(`hara gateway: ${adapter.name} up · cwd=${cwd} · ${allowlist.size} allowed user(s) · Ctrl-C to stop`);
189
+ await adapter.start(async (m) => {
190
+ if (!isAllowed(m.userId, allowlist)) {
191
+ console.error(`hara gateway: ✗ message from ${m.userId} — not in allowlist. Add it to HARA_GATEWAY_ALLOWED to authorize.`);
192
+ await adapter.send(m.chatId, "⛔ not authorized.");
193
+ return;
194
+ }
195
+ const ctx = chatContext(adapter.name, m.chatId, cwd); // this chat's current { cwd, sessionId }
196
+ const cmd = parseCommand(m.text);
197
+ if (cmd) {
198
+ if (cmd.cmd === "help")
199
+ return adapter.send(m.chatId, "commands:\n/pwd · /cd <dir> — project\n/sessions · /new · /resume <id> — threads\n/voice · /say <text> — speech · /send <path> — send a file\n/help\nanything else = run hara here");
200
+ if (cmd.cmd === "pwd")
201
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n🧵 ${ctx.sessionId.slice(-18)}`);
202
+ if (cmd.cmd === "cd" || cmd.cmd === "project") {
203
+ if (!cmd.arg)
204
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\nusage: /cd <dir> (absolute, ~, or relative to here)`);
205
+ const target = resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir()));
206
+ if (!existsSync(target) || !statSync(target).isDirectory())
207
+ return adapter.send(m.chatId, `✗ not a directory: ${target}`);
208
+ const sid = chatCd(adapter.name, m.chatId, target);
209
+ return adapter.send(m.chatId, `📂 now in ${target}\n🧵 ${sid.slice(-18)} · /sessions lists this dir's threads`);
210
+ }
211
+ if (cmd.cmd === "new")
212
+ return adapter.send(m.chatId, `✨ new thread: ${newChatSession(adapter.name, m.chatId, cwd).slice(-18)}`);
213
+ if (cmd.cmd === "sessions") {
214
+ const list = listSessions(ctx.cwd).slice(0, 10).map((x) => `${x.id.slice(-18)} ${x.title || "(untitled)"}`).join("\n");
215
+ return adapter.send(m.chatId, `📂 ${ctx.cwd}\n${list || "(no threads in this dir yet)"}`);
216
+ }
217
+ if (cmd.cmd === "resume") {
218
+ const id = resolveSessionId(cmd.arg);
219
+ if (!id)
220
+ return adapter.send(m.chatId, `no session '${cmd.arg}'`);
221
+ const target = loadSession(id)?.meta.cwd || ctx.cwd; // follow the session's own dir so it runs in the right place
222
+ setChatSession(adapter.name, m.chatId, id, target);
223
+ return adapter.send(m.chatId, `↩ resumed ${id.slice(-18)}\n📂 ${target}`);
224
+ }
225
+ if (cmd.cmd === "voice") {
226
+ if (!adapter.sendFile)
227
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
228
+ const on = toggleVoice(adapter.name, m.chatId);
229
+ return adapter.send(m.chatId, on ? "🔊 voice replies ON — I'll speak each reply too." : "🔇 voice replies OFF.");
230
+ }
231
+ if (cmd.cmd === "say") {
232
+ if (!adapter.sendFile)
233
+ return adapter.send(m.chatId, "this platform can't send voice yet.");
234
+ if (!cmd.arg)
235
+ return adapter.send(m.chatId, "usage: /say <text to speak>");
236
+ const audio = await synthesize(cmd.arg);
237
+ if (!audio)
238
+ return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
239
+ await adapter.sendFile(m.chatId, audio);
240
+ rmSync(audio, { force: true });
241
+ return;
242
+ }
243
+ if (cmd.cmd === "send") {
244
+ if (!adapter.sendFile)
245
+ return adapter.send(m.chatId, "this platform can't send files yet.");
246
+ const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
247
+ if (!p || !existsSync(p) || !statSync(p).isFile())
248
+ return adapter.send(m.chatId, `✗ not a file: ${p || "(none)"}\nusage: /send <path> (abs, ~, or relative to current dir)`);
249
+ await adapter.sendFile(m.chatId, p);
250
+ return;
251
+ }
252
+ // any other slash word → treat as a normal task
253
+ }
254
+ await adapter.send(m.chatId, "⟳ working…");
255
+ const { reply, files } = await runHara(m.text, ctx.sessionId, ctx.cwd, adapter.name, m.images);
256
+ const hasReply = reply && reply !== "(no output)";
257
+ if (hasReply)
258
+ await adapter.send(m.chatId, reply);
259
+ else if (files.length)
260
+ await adapter.send(m.chatId, "📎");
261
+ // Deliver any files the agent queued via send_file (images inline, others as attachments).
262
+ for (const f of files) {
263
+ if (!adapter.sendFile) {
264
+ await adapter.send(m.chatId, "(this platform can't send files yet)");
265
+ break;
266
+ }
267
+ try {
268
+ await adapter.sendFile(m.chatId, f);
269
+ }
270
+ catch (e) {
271
+ await adapter.send(m.chatId, `✗ couldn't send ${f}: ${e.message}`);
272
+ }
273
+ }
274
+ if (hasReply && ctx.voice && adapter.sendFile) {
275
+ const audio = await synthesize(reply);
276
+ if (audio) {
277
+ await adapter.sendFile(m.chatId, audio);
278
+ rmSync(audio, { force: true });
279
+ }
280
+ }
281
+ }, ac.signal);
282
+ }