@nanhara/hara 0.70.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,190 @@
1
+ // Slack adapter for `hara gateway` — uses Socket Mode so the local daemon connects OUT over Node's native
2
+ // global WebSocket (zero new dep on Node ≥ 22): apps.connections.open (app-level token, xapp-) hands back a
3
+ // wss:// URL → we connect, ACK every envelope, and turn message events into InboundMsg. Outbound is the Web
4
+ // API (bot token, xoxb-). Same ChatAdapter shape as Discord/Telegram, so all cross-platform gateway plumbing
5
+ // (send_file, in-chat system context, stuck-guard, image attach/describe) works unchanged. Two tokens are
6
+ // required because Socket Mode (xapp-) and the Web API (xoxb-) are separate auth scopes.
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 WEB = "https://slack.com/api";
12
+ const WSImpl = globalThis.WebSocket;
13
+ const sleep = (ms, signal) => new Promise((r) => {
14
+ if (signal?.aborted)
15
+ return r();
16
+ const t = setTimeout(r, ms);
17
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
18
+ });
19
+ const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
20
+ /** Call a Slack Web API method with a token (form-encoded, the most widely-accepted shape). Returns the parsed
21
+ * JSON; callers check `.ok`. Never throws — a failed call resolves to `{ ok: false }`. */
22
+ async function slackApi(method, token, body) {
23
+ try {
24
+ const r = await fetch(`${WEB}/${method}`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
27
+ body: JSON.stringify(body),
28
+ });
29
+ return await r.json();
30
+ }
31
+ catch {
32
+ return { ok: false };
33
+ }
34
+ }
35
+ /** Slack file url_private is gated — it needs the BOT token as a Bearer header (a plain GET returns the login
36
+ * page HTML, not the bytes). Download into ~/.hara/slack/media so the agent can SEE it. */
37
+ async function downloadSlackFile(url, name, botToken) {
38
+ try {
39
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` } });
40
+ if (!r.ok)
41
+ return null;
42
+ const dir = join(homedir(), ".hara", "slack", "media");
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = join(dir, `sl_${Date.now()}_${basename(name) || "file.bin"}`);
45
+ writeFileSync(path, Buffer.from(await r.arrayBuffer()));
46
+ return path;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Parse a Slack Events API `event` (the inner `payload.event`) → InboundMsg + its image file refs (pure;
53
+ * download happens in start()). null = ignore (not a user message / our own / an edit/delete subtype / empty).
54
+ * Mirrors discord.ts's parseDiscordMessage so it's unit-testable without the network. */
55
+ export function parseSlackEvent(event, selfId) {
56
+ if (event?.type !== "message")
57
+ return null;
58
+ if (event.bot_id || (selfId && event.user === selfId))
59
+ return null; // ignore bots + our own messages
60
+ // message_changed / message_deleted / channel_join … carry no fresh user text we want to act on
61
+ if (event.subtype && event.subtype !== "file_share")
62
+ return null;
63
+ if (!event.channel || !event.user)
64
+ return null;
65
+ const files = Array.isArray(event.files) ? event.files : [];
66
+ const imageUrls = files
67
+ .filter((f) => isImage(String(f?.name ?? ""), f?.mimetype))
68
+ .map((f) => ({ url: String(f?.url_private_download || f?.url_private || ""), name: String(f?.name ?? "image") }))
69
+ .filter((f) => f.url);
70
+ const text = String(event.text ?? "");
71
+ if (!text && !imageUrls.length)
72
+ return null;
73
+ return {
74
+ msg: {
75
+ chatId: String(event.channel),
76
+ userId: String(event.user),
77
+ userName: String(event.user), // Slack events carry only the id; users.info would resolve a name (skipped for zero extra calls)
78
+ text: text || "[图片]",
79
+ },
80
+ imageUrls,
81
+ };
82
+ }
83
+ export function slackAdapter(appToken, botToken) {
84
+ return {
85
+ name: "slack",
86
+ async send(chatId, text) {
87
+ // Slack hard-caps a message at 40k chars; 3500 keeps us clear of mrkdwn expansion + block limits.
88
+ for (const part of chunkText(text || "(empty)", 3500)) {
89
+ await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
90
+ }
91
+ },
92
+ async sendFile(chatId, filePath) {
93
+ // 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
94
+ // non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
95
+ try {
96
+ const bytes = readFileSync(filePath);
97
+ const name = basename(filePath);
98
+ const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
99
+ if (!url?.ok || !url.upload_url || !url.file_id)
100
+ return;
101
+ const up = await fetch(url.upload_url, { method: "POST", body: new Blob([bytes]) }); // presigned URL: no auth header
102
+ if (!up.ok)
103
+ return;
104
+ await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
105
+ }
106
+ catch {
107
+ /* upload/send failed — surfaced upstream as "no file delivered" */
108
+ }
109
+ },
110
+ async start(onMessage, signal) {
111
+ if (!WSImpl) {
112
+ console.error("hara gateway: Slack needs Node ≥ 22 (global WebSocket). Upgrade Node.");
113
+ return;
114
+ }
115
+ // Resolve our own bot user id once so parseSlackEvent can drop our own messages (echo-loop guard).
116
+ const auth = await slackApi("auth.test", botToken, {});
117
+ const selfId = String(auth?.user_id ?? "");
118
+ while (!signal.aborted) {
119
+ const wss = await openSocketUrl(appToken);
120
+ if (wss)
121
+ await connectOnce(wss, botToken, selfId, onMessage, signal);
122
+ if (!signal.aborted)
123
+ await sleep(3000, signal); // reconnect backoff (and re-open: each URL is single-use)
124
+ }
125
+ },
126
+ };
127
+ }
128
+ /** Ask Slack for a fresh single-use Socket Mode wss:// URL (app-level token). null on failure → caller backs off. */
129
+ async function openSocketUrl(appToken) {
130
+ const j = await slackApi("apps.connections.open", appToken, {});
131
+ return j?.ok && typeof j.url === "string" ? j.url : null;
132
+ }
133
+ /** One Socket Mode connection: receive envelopes, ACK each by echoing its envelope_id, and dispatch message
134
+ * events. Resolves on close/disconnect/abort; the caller re-opens a new URL and reconnects. v1 keeps it simple —
135
+ * no buffered-payload backpressure (the daemon spawns one hara per message anyway). */
136
+ function connectOnce(url, botToken, selfId, onMessage, signal) {
137
+ return new Promise((resolve) => {
138
+ const ws = new WSImpl(url);
139
+ const stop = () => {
140
+ signal.removeEventListener("abort", stop);
141
+ try {
142
+ ws.close();
143
+ }
144
+ catch {
145
+ /* already closing */
146
+ }
147
+ resolve();
148
+ };
149
+ signal.addEventListener("abort", stop, { once: true });
150
+ ws.addEventListener("close", () => {
151
+ signal.removeEventListener("abort", stop);
152
+ resolve();
153
+ });
154
+ ws.addEventListener("error", () => { });
155
+ ws.addEventListener("message", async (ev) => {
156
+ let p;
157
+ try {
158
+ p = JSON.parse(String(ev.data));
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ // ACK first — Slack redelivers (and eventually disconnects the socket) for any envelope we don't ack in ~3s.
164
+ if (p.envelope_id) {
165
+ try {
166
+ ws.send(JSON.stringify({ envelope_id: p.envelope_id }));
167
+ }
168
+ catch {
169
+ /* socket gone */
170
+ }
171
+ }
172
+ if (p.type === "disconnect") {
173
+ stop(); // server is rotating us off (refresh / too_many_connections) → drop and let the caller reconnect
174
+ return;
175
+ }
176
+ if (p.type === "events_api") {
177
+ const parsed = parseSlackEvent(p.payload?.event, selfId);
178
+ if (parsed) {
179
+ for (const im of parsed.imageUrls) {
180
+ const path = await downloadSlackFile(im.url, im.name, botToken);
181
+ if (path)
182
+ (parsed.msg.images ??= []).push(path);
183
+ }
184
+ await onMessage(parsed.msg).catch(() => { });
185
+ }
186
+ }
187
+ // type "hello" (connection established) and slash_commands/interactive envelopes: acked above, nothing else to do.
188
+ });
189
+ });
190
+ }
@@ -0,0 +1,115 @@
1
+ // Telegram adapter for `hara gateway` — long-poll getUpdates + sendMessage over the Bot API (built-in
2
+ // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
+ // / Feishu plug into next.
4
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
+ import { join, basename } from "node:path";
6
+ import { homedir } from "node:os";
7
+ const API = "https://api.telegram.org";
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ /** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
10
+ * (photo → caption or a "[图片]" marker; the image itself is downloaded in start()). null otherwise. */
11
+ export function parseTelegramUpdate(u) {
12
+ const m = u?.message;
13
+ if (!m || !m.chat?.id)
14
+ return null;
15
+ const text = typeof m.text === "string" ? m.text : typeof m.caption === "string" ? m.caption : "";
16
+ const hasPhoto = Array.isArray(m.photo) && m.photo.length > 0;
17
+ if (!text && !hasPhoto)
18
+ return null; // not text or a photo (sticker/location/etc.)
19
+ return {
20
+ chatId: m.chat.id,
21
+ userId: m.from?.id ?? 0,
22
+ userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
23
+ text: text || "[图片]",
24
+ };
25
+ }
26
+ /** The largest photo's file_id (Telegram sends `photo` as an ascending size array) (pure). null if none. */
27
+ export function photoFileId(u) {
28
+ const photo = u?.message?.photo;
29
+ return Array.isArray(photo) && photo.length ? (photo[photo.length - 1]?.file_id ?? null) : null;
30
+ }
31
+ /** Download a Telegram file by file_id → a local path under ~/.hara/telegram/media (getFile then the file CDN). */
32
+ async function downloadTelegramFile(base, token, fileId) {
33
+ try {
34
+ const r = await fetch(`${base}/getFile?file_id=${encodeURIComponent(fileId)}`);
35
+ const j = (await r.json());
36
+ const fp = j?.result?.file_path;
37
+ if (!fp)
38
+ return null;
39
+ const dl = await fetch(`${API}/file/bot${token}/${fp}`);
40
+ if (!dl.ok)
41
+ return null;
42
+ const dir = join(homedir(), ".hara", "telegram", "media");
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = join(dir, `tg_${Date.now()}_${basename(fp)}`);
45
+ writeFileSync(path, Buffer.from(await dl.arrayBuffer()));
46
+ return path;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Split text into chunks ≤ max (Telegram caps a message at 4096 chars) (pure). */
53
+ export function chunkText(text, max = 4000) {
54
+ if (text.length <= max)
55
+ return [text];
56
+ const out = [];
57
+ for (let i = 0; i < text.length; i += max)
58
+ out.push(text.slice(i, i + max));
59
+ return out;
60
+ }
61
+ export function telegramAdapter(token) {
62
+ const base = `${API}/bot${token}`;
63
+ return {
64
+ name: "telegram",
65
+ async send(chatId, text) {
66
+ for (const part of chunkText(text || "(empty)")) {
67
+ await fetch(`${base}/sendMessage`, {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({ chat_id: chatId, text: part }),
71
+ }).catch(() => { });
72
+ }
73
+ },
74
+ async sendFile(chatId, filePath) {
75
+ // images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
76
+ const name = basename(filePath);
77
+ const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
78
+ const form = new FormData();
79
+ form.append("chat_id", String(chatId));
80
+ form.append(isImg ? "photo" : "document", new Blob([readFileSync(filePath)]), name);
81
+ await fetch(`${base}/${isImg ? "sendPhoto" : "sendDocument"}`, { method: "POST", body: form }).catch(() => { });
82
+ },
83
+ async start(onMessage, signal) {
84
+ let offset = 0;
85
+ while (!signal.aborted) {
86
+ try {
87
+ const res = await fetch(`${base}/getUpdates?timeout=30&offset=${offset}`, { signal });
88
+ if (!res.ok) {
89
+ await sleep(2000);
90
+ continue;
91
+ }
92
+ const j = (await res.json());
93
+ for (const u of j.result ?? []) {
94
+ offset = Math.max(offset, (u.update_id ?? 0) + 1);
95
+ const msg = parseTelegramUpdate(u);
96
+ if (!msg)
97
+ continue;
98
+ const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
99
+ if (fid) {
100
+ const path = await downloadTelegramFile(base, token, fid);
101
+ if (path)
102
+ msg.images = [path];
103
+ }
104
+ await onMessage(msg).catch(() => { });
105
+ }
106
+ }
107
+ catch {
108
+ if (signal.aborted)
109
+ break;
110
+ await sleep(2000); // network blip → back off + retry
111
+ }
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,100 @@
1
+ // Pluggable text-to-speech for the chat gateway's voice replies. Mirrors the video project's provider design:
2
+ // a config-driven registry, nothing vendor-hardcoded, both API and local. Selected by env:
3
+ // HARA_TTS_PROVIDER = say | openai | cmd (default: say)
4
+ // HARA_TTS_VOICE / HARA_TTS_MODEL / HARA_TTS_BASE_URL / HARA_TTS_API_KEY / HARA_TTS_CMD
5
+ // Providers:
6
+ // say — local macOS `say` (zero-config default; fast, ~0.5s; Chinese voices e.g. Tingting) → m4a
7
+ // openai — any OpenAI-compatible /audio/speech endpoint (point BASE_URL at Aliyun DashScope or a local
8
+ // TTS server); reuses the existing `openai` dep, no new dependency
9
+ // cmd — a configurable local command (point it at VoxCPM or any local TTS); the text is piped on stdin,
10
+ // `{out}` in the command is replaced with the output path
11
+ import { spawn } from "node:child_process";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { writeFileSync } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ /** Read TTS settings from the environment (fully configurable — nothing hardcoded). */
17
+ export function ttsConfigFromEnv(env = process.env) {
18
+ return {
19
+ provider: (env.HARA_TTS_PROVIDER || "say").trim(),
20
+ voice: (env.HARA_TTS_VOICE || "").trim(),
21
+ model: (env.HARA_TTS_MODEL || "").trim(),
22
+ baseURL: (env.HARA_TTS_BASE_URL || "").trim(),
23
+ apiKey: (env.HARA_TTS_API_KEY || "").trim(),
24
+ cmd: (env.HARA_TTS_CMD || "").trim(),
25
+ };
26
+ }
27
+ /** Normalize a reply for speech: collapse whitespace, drop code fences, cap length (long audio is unwanted). */
28
+ export function ttsCleanText(text, max = 1200) {
29
+ return text
30
+ .replace(/```[\s\S]*?```/g, " (code omitted) ")
31
+ .replace(/\s+/g, " ")
32
+ .trim()
33
+ .slice(0, max);
34
+ }
35
+ function run(cmd, args, stdin) {
36
+ return new Promise((resolve) => {
37
+ const c = spawn(cmd, args, { stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"] });
38
+ c.on("error", () => resolve(false));
39
+ c.on("close", (code) => resolve(code === 0));
40
+ if (stdin != null)
41
+ c.stdin?.end(stdin);
42
+ });
43
+ }
44
+ // local: macOS `say` → m4a (AAC). Zero config; HARA_TTS_VOICE picks the voice (default Tingting / zh).
45
+ async function sayTts(text, out, cfg) {
46
+ return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text]);
47
+ }
48
+ // API: OpenAI-compatible /audio/speech. Works against OpenAI, Aliyun DashScope (compatible-mode), or a local
49
+ // server exposing the same shape — set HARA_TTS_BASE_URL + HARA_TTS_API_KEY + HARA_TTS_MODEL + HARA_TTS_VOICE.
50
+ async function openaiTts(text, out, cfg) {
51
+ if (!cfg.apiKey && !cfg.baseURL)
52
+ return false; // not configured → unavailable
53
+ const { default: OpenAI } = (await import("openai"));
54
+ const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
55
+ const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text });
56
+ const buf = Buffer.from(await res.arrayBuffer());
57
+ if (!buf.length)
58
+ return false;
59
+ writeFileSync(out, buf);
60
+ return true;
61
+ }
62
+ // local: a configurable command (e.g. VoxCPM). `{out}` → output path; the text is piped on stdin.
63
+ async function cmdTts(text, out, cfg) {
64
+ if (!cfg.cmd)
65
+ return false;
66
+ return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], text);
67
+ }
68
+ const PROVIDERS = {
69
+ say: sayTts,
70
+ openai: openaiTts,
71
+ cmd: cmdTts,
72
+ };
73
+ const EXT = { say: "m4a", openai: "mp3", cmd: "wav" };
74
+ /** Synthesize `text` to a temp audio file; returns its path, or null on failure. Falls back to local `say`
75
+ * if a configured provider fails (so a misconfigured API never silently kills voice replies). */
76
+ export async function synthesize(text, cfg = ttsConfigFromEnv()) {
77
+ const clean = ttsCleanText(text);
78
+ if (!clean)
79
+ return null;
80
+ const provider = PROVIDERS[cfg.provider] ? cfg.provider : "say";
81
+ const out = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.${EXT[provider] || "wav"}`);
82
+ try {
83
+ if (await PROVIDERS[provider](clean, out, cfg))
84
+ return out;
85
+ }
86
+ catch (e) {
87
+ console.error(`tts(${provider}): ${e?.message ?? e}`);
88
+ }
89
+ if (provider !== "say") {
90
+ try {
91
+ const o2 = join(tmpdir(), `hara-tts-${randomUUID().slice(0, 8)}.m4a`);
92
+ if (await sayTts(clean, o2, cfg))
93
+ return o2; // graceful fallback to local
94
+ }
95
+ catch {
96
+ /* give up */
97
+ }
98
+ }
99
+ return null;
100
+ }