@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,164 @@
1
+ // Discord adapter for `hara gateway` — connects to the Discord gateway over Node's native global WebSocket
2
+ // (zero new dep on Node ≥ 22) for inbound, and the REST API for outbound. Token from HARA_DISCORD_TOKEN; allow
3
+ // users via HARA_GATEWAY_ALLOWED (Discord user-id snowflakes). Same ChatAdapter shape as Telegram/WeChat, so all
4
+ // the cross-platform gateway plumbing (send_file, in-chat system context, stuck-guard, image attach/describe)
5
+ // works unchanged. NOTE: receiving message text needs the privileged "Message Content Intent" enabled for the
6
+ // bot in the Discord developer portal.
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 REST = "https://discord.com/api/v10";
12
+ const GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json";
13
+ // GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15, privileged)
14
+ const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
15
+ const WSImpl = globalThis.WebSocket;
16
+ const sleep = (ms, signal) => new Promise((r) => {
17
+ if (signal?.aborted)
18
+ return r();
19
+ const t = setTimeout(r, ms);
20
+ signal?.addEventListener?.("abort", () => { clearTimeout(t); r(); }, { once: true });
21
+ });
22
+ const isImage = (name, mime) => (mime?.startsWith("image/") ?? false) || /\.(png|jpe?g|gif|webp)$/i.test(name);
23
+ async function downloadDiscordAttachment(url, filename) {
24
+ try {
25
+ const r = await fetch(url);
26
+ if (!r.ok)
27
+ return null;
28
+ const dir = join(homedir(), ".hara", "discord", "media");
29
+ mkdirSync(dir, { recursive: true });
30
+ const path = join(dir, `dc_${Date.now()}_${basename(filename) || "file.bin"}`);
31
+ writeFileSync(path, Buffer.from(await r.arrayBuffer()));
32
+ return path;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Parse a Discord MESSAGE_CREATE payload → InboundMsg + its image attachment URLs (pure; download happens in
39
+ * start()). null = ignore (own message / another bot / empty). */
40
+ export function parseDiscordMessage(d, selfId) {
41
+ if (!d?.channel_id || !d?.author?.id)
42
+ return null;
43
+ if (d.author.id === selfId || d.author.bot)
44
+ return null; // ignore our own messages + other bots
45
+ const atts = Array.isArray(d.attachments) ? d.attachments : [];
46
+ const imageUrls = atts
47
+ .filter((a) => isImage(String(a?.filename ?? ""), a?.content_type))
48
+ .map((a) => ({ url: String(a.url), name: String(a.filename ?? "image") }));
49
+ const text = String(d.content ?? "");
50
+ if (!text && !imageUrls.length)
51
+ return null;
52
+ return {
53
+ msg: {
54
+ chatId: String(d.channel_id),
55
+ userId: String(d.author.id),
56
+ userName: d.author.global_name || d.author.username || String(d.author.id),
57
+ text: text || "[图片]",
58
+ },
59
+ imageUrls,
60
+ };
61
+ }
62
+ export function discordAdapter(token) {
63
+ const auth = { Authorization: `Bot ${token}` };
64
+ return {
65
+ name: "discord",
66
+ async send(chatId, text) {
67
+ for (const part of chunkText(text || "(empty)", 2000)) {
68
+ await fetch(`${REST}/channels/${chatId}/messages`, {
69
+ method: "POST",
70
+ headers: { ...auth, "content-type": "application/json" },
71
+ body: JSON.stringify({ content: part }),
72
+ }).catch(() => { });
73
+ }
74
+ },
75
+ async sendFile(chatId, filePath) {
76
+ const form = new FormData();
77
+ form.append("payload_json", JSON.stringify({}));
78
+ form.append("files[0]", new Blob([readFileSync(filePath)]), basename(filePath));
79
+ await fetch(`${REST}/channels/${chatId}/messages`, { method: "POST", headers: auth, body: form }).catch(() => { });
80
+ },
81
+ async start(onMessage, signal) {
82
+ if (!WSImpl) {
83
+ console.error("hara gateway: Discord needs Node ≥ 22 (global WebSocket). Upgrade Node.");
84
+ return;
85
+ }
86
+ while (!signal.aborted) {
87
+ await connectOnce(token, onMessage, signal);
88
+ if (!signal.aborted)
89
+ await sleep(3000, signal); // reconnect backoff
90
+ }
91
+ },
92
+ };
93
+ }
94
+ /** One gateway connection: HELLO→heartbeat, IDENTIFY, then dispatch MESSAGE_CREATE. Resolves on close/abort;
95
+ * the caller reconnects. v1 keeps it simple — fresh IDENTIFY each time, no RESUME. */
96
+ function connectOnce(token, onMessage, signal) {
97
+ return new Promise((resolve) => {
98
+ const ws = new WSImpl(GATEWAY);
99
+ let hb = null;
100
+ let seq = null;
101
+ let selfId = "";
102
+ const stop = () => {
103
+ if (hb)
104
+ clearInterval(hb);
105
+ signal.removeEventListener("abort", stop);
106
+ try {
107
+ ws.close();
108
+ }
109
+ catch {
110
+ /* already closing */
111
+ }
112
+ resolve();
113
+ };
114
+ signal.addEventListener("abort", stop, { once: true });
115
+ ws.addEventListener("close", () => {
116
+ if (hb)
117
+ clearInterval(hb);
118
+ signal.removeEventListener("abort", stop);
119
+ resolve();
120
+ });
121
+ ws.addEventListener("error", () => { });
122
+ ws.addEventListener("message", async (ev) => {
123
+ let p;
124
+ try {
125
+ p = JSON.parse(String(ev.data));
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ if (typeof p.s === "number")
131
+ seq = p.s;
132
+ if (p.op === 10) {
133
+ const interval = p.d?.heartbeat_interval ?? 41250;
134
+ hb = setInterval(() => {
135
+ try {
136
+ ws.send(JSON.stringify({ op: 1, d: seq }));
137
+ }
138
+ catch {
139
+ /* socket gone */
140
+ }
141
+ }, interval);
142
+ ws.send(JSON.stringify({ op: 2, d: { token, intents: INTENTS, properties: { os: "linux", browser: "hara", device: "hara" } } }));
143
+ }
144
+ else if (p.op === 0) {
145
+ if (p.t === "READY")
146
+ selfId = p.d?.user?.id ?? "";
147
+ else if (p.t === "MESSAGE_CREATE") {
148
+ const parsed = parseDiscordMessage(p.d, selfId);
149
+ if (parsed) {
150
+ for (const im of parsed.imageUrls) {
151
+ const path = await downloadDiscordAttachment(im.url, im.name);
152
+ if (path)
153
+ (parsed.msg.images ??= []).push(path);
154
+ }
155
+ await onMessage(parsed.msg).catch(() => { });
156
+ }
157
+ }
158
+ }
159
+ else if (p.op === 7 || p.op === 9) {
160
+ stop(); // server asked to reconnect / invalid session → drop and let the caller reconnect
161
+ }
162
+ });
163
+ });
164
+ }
@@ -0,0 +1,144 @@
1
+ // Feishu/Lark adapter for `hara gateway` — uses the official @larksuiteoapi/node-sdk: a WSClient long-connection
2
+ // for inbound events (no public webhook endpoint needed — fits hara's local daemon) and the REST Client for
3
+ // outbound. Creds from HARA_FEISHU_APP_ID / HARA_FEISHU_APP_SECRET (+ HARA_FEISHU_DOMAIN=lark for larksuite.com).
4
+ // Same ChatAdapter shape as the others, so all cross-platform gateway logic (send_file, system context,
5
+ // stuck-guard, image attach/describe) works unchanged. v1 = p2p (DM) only; group support is a fast-follow.
6
+ import lark from "@larksuiteoapi/node-sdk";
7
+ import { createReadStream, createWriteStream, mkdirSync } from "node:fs";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { join, basename } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { chunkText } from "./telegram.js";
12
+ /** Normalize a Feishu message's parsed content by type → text + any media keys (pure; download done by caller). */
13
+ export function parseFeishuContent(messageType, content) {
14
+ if (messageType === "text")
15
+ return { text: String(content?.text ?? "") };
16
+ if (messageType === "post")
17
+ return { text: flattenPost(content) };
18
+ if (messageType === "image")
19
+ return { text: "", imageKey: content?.image_key };
20
+ if (messageType === "file")
21
+ return { text: "", fileKey: content?.file_key, fileName: content?.file_name };
22
+ if (messageType === "audio")
23
+ return { text: "", fileKey: content?.file_key, fileName: "audio" };
24
+ return { text: "" };
25
+ }
26
+ /** Flatten a Feishu rich-text "post" message into plain text (best-effort over its [[{tag,text}]] runs). */
27
+ export function flattenPost(content) {
28
+ const blocks = content?.content ?? content?.zh_cn?.content ?? content?.en_us?.content ?? [];
29
+ const out = [];
30
+ for (const line of Array.isArray(blocks) ? blocks : []) {
31
+ for (const seg of Array.isArray(line) ? line : []) {
32
+ if (seg?.tag === "text" && seg.text)
33
+ out.push(String(seg.text));
34
+ else if (seg?.tag === "a" && seg.text)
35
+ out.push(String(seg.text));
36
+ }
37
+ }
38
+ return out.join(" ").trim();
39
+ }
40
+ async function downloadFeishuResource(client, messageId, fileKey, type) {
41
+ try {
42
+ const resp = await client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type } });
43
+ const dir = join(homedir(), ".hara", "feishu", "media");
44
+ mkdirSync(dir, { recursive: true });
45
+ const path = join(dir, `fs_${Date.now()}_${fileKey.slice(-8)}.${type === "image" ? "jpg" : "bin"}`);
46
+ if (typeof resp?.writeFile === "function")
47
+ await resp.writeFile(path);
48
+ else if (typeof resp?.getReadableStream === "function")
49
+ await pipeline(resp.getReadableStream(), createWriteStream(path));
50
+ else
51
+ return null;
52
+ return path;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Build an InboundMsg from a Feishu im.message.receive_v1 event (downloads media). null = skip (not a DM / empty). */
59
+ async function toInbound(client, data) {
60
+ const msg = data?.message;
61
+ if (!msg?.chat_id || msg.chat_type !== "p2p")
62
+ return null; // v1: direct messages only
63
+ const sender = data?.sender?.sender_id;
64
+ const userId = String(sender?.open_id || sender?.user_id || msg.chat_id);
65
+ let content = {};
66
+ try {
67
+ content = JSON.parse(msg.content ?? "{}");
68
+ }
69
+ catch {
70
+ /* malformed content → empty */
71
+ }
72
+ const parsed = parseFeishuContent(String(msg.message_type), content);
73
+ let text = parsed.text;
74
+ const images = [];
75
+ if (parsed.imageKey) {
76
+ const p = await downloadFeishuResource(client, msg.message_id, parsed.imageKey, "image");
77
+ if (p) {
78
+ images.push(p);
79
+ text = "[图片]";
80
+ }
81
+ }
82
+ else if (parsed.fileKey) {
83
+ const p = await downloadFeishuResource(client, msg.message_id, parsed.fileKey, "file");
84
+ const label = parsed.fileName === "audio" ? "语音" : `文件 ${parsed.fileName ?? ""}`.trim();
85
+ if (p)
86
+ text = `[${label}: ${p}]`;
87
+ }
88
+ text = text.trim();
89
+ if (!text && !images.length)
90
+ return null;
91
+ return { chatId: String(msg.chat_id), userId, userName: userId, text: text || "[图片]", images: images.length ? images : undefined };
92
+ }
93
+ export function feishuAdapter(appId, appSecret) {
94
+ const domain = process.env.HARA_FEISHU_DOMAIN === "lark" ? lark.Domain.Lark : lark.Domain.Feishu;
95
+ const client = new lark.Client({ appId, appSecret, domain });
96
+ const wsClient = new lark.WSClient({ appId, appSecret, domain });
97
+ const sendMsg = (chatId, msgType, content) => client.im.message
98
+ .create({ params: { receive_id_type: "chat_id" }, data: { receive_id: String(chatId), msg_type: msgType, content: JSON.stringify(content) } })
99
+ .catch(() => undefined);
100
+ return {
101
+ name: "feishu",
102
+ async send(chatId, text) {
103
+ for (const part of chunkText(text || "(empty)", 4000))
104
+ await sendMsg(chatId, "text", { text: part });
105
+ },
106
+ async sendFile(chatId, filePath) {
107
+ const name = basename(filePath);
108
+ const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
109
+ try {
110
+ if (isImg) {
111
+ const up = await client.im.image.create({ data: { image_type: "message", image: createReadStream(filePath) } });
112
+ const key = up?.image_key ?? up?.data?.image_key;
113
+ if (key)
114
+ await sendMsg(chatId, "image", { image_key: key });
115
+ }
116
+ else {
117
+ const up = await client.im.file.create({ data: { file_type: "stream", file_name: name, file: createReadStream(filePath) } });
118
+ const key = up?.file_key ?? up?.data?.file_key;
119
+ if (key)
120
+ await sendMsg(chatId, "file", { file_key: key });
121
+ }
122
+ }
123
+ catch {
124
+ /* upload/send failed — surfaced upstream as "no file delivered" */
125
+ }
126
+ },
127
+ async start(onMessage, signal) {
128
+ const eventDispatcher = new lark.EventDispatcher({}).register({
129
+ "im.message.receive_v1": async (data) => {
130
+ const m = await toInbound(client, data);
131
+ if (m)
132
+ await onMessage(m).catch(() => { });
133
+ },
134
+ });
135
+ wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect)
136
+ // keep the adapter alive until the gateway aborts (the WSClient manages the socket itself)
137
+ await new Promise((resolve) => {
138
+ if (signal.aborted)
139
+ return resolve();
140
+ signal.addEventListener("abort", () => resolve(), { once: true });
141
+ });
142
+ },
143
+ };
144
+ }
@@ -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
+ }