@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,209 @@
|
|
|
1
|
+
// DingTalk (钉钉) adapter for `hara gateway` — connects to DingTalk's Stream Mode over Node's native global
|
|
2
|
+
// WebSocket (zero new dep on Node ≥ 22) so the LOCAL daemon dials OUT (no public webhook endpoint needed, like
|
|
3
|
+
// Feishu's long-connection). Creds from HARA_DINGTALK_CLIENT_ID / HARA_DINGTALK_CLIENT_SECRET (the app's
|
|
4
|
+
// AppKey/AppSecret on open.dingtalk.com). Replies go to the per-message `sessionWebhook` the inbound payload
|
|
5
|
+
// carries. Same ChatAdapter shape as Telegram/Discord/Feishu, so all the cross-platform gateway plumbing
|
|
6
|
+
// (system context, stuck-guard, allowlist) works unchanged. v1 limitations: file send is not supported (DingTalk
|
|
7
|
+
// bot replies via sessionWebhook only do text/markdown cards) and inbound images are noted but not downloaded.
|
|
8
|
+
import { chunkText } from "./telegram.js";
|
|
9
|
+
// Open a Stream connection here → response gives the WS endpoint + a ticket; then we dial endpoint?ticket=…
|
|
10
|
+
const OPEN_CONNECTION = "https://api.dingtalk.com/v1.0/gateway/connections/open";
|
|
11
|
+
const BOT_TOPIC = "/v1.0/im/bot/messages/get"; // the CALLBACK topic that carries a bot chat message
|
|
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
|
+
/** Parse a DingTalk bot message payload (the JSON.parse'd `data` field of a CALLBACK frame) → InboundMsg plus the
|
|
20
|
+
* per-message sessionWebhook used to reply (pure). Accepts text (and picture, marked "[图片]"); null otherwise.
|
|
21
|
+
* conversationId routes group/DM; senderStaffId is preferred for the allowlist (the org staff id), else senderId. */
|
|
22
|
+
export function parseDingtalkMessage(msg) {
|
|
23
|
+
if (!msg)
|
|
24
|
+
return null;
|
|
25
|
+
const chatId = String(msg.conversationId || msg.senderId || "");
|
|
26
|
+
if (!chatId)
|
|
27
|
+
return null;
|
|
28
|
+
const userId = String(msg.senderStaffId || msg.senderId || "");
|
|
29
|
+
const userName = String(msg.senderNick || userId);
|
|
30
|
+
const sessionWebhook = typeof msg.sessionWebhook === "string" ? msg.sessionWebhook : null;
|
|
31
|
+
const type = String(msg.msgtype || "");
|
|
32
|
+
let text = "";
|
|
33
|
+
if (type === "text")
|
|
34
|
+
text = String(msg.text?.content ?? "").trim();
|
|
35
|
+
else if (type === "picture")
|
|
36
|
+
text = "[图片]"; // v1: inbound image not downloaded (downloadCode in content)
|
|
37
|
+
else if (type === "richText")
|
|
38
|
+
text = flattenRichText(msg.content?.richText).trim();
|
|
39
|
+
if (!text)
|
|
40
|
+
return null; // unsupported type (audio/file/etc.) or empty
|
|
41
|
+
return { msg: { chatId, userId, userName, text }, sessionWebhook };
|
|
42
|
+
}
|
|
43
|
+
/** Flatten a DingTalk richText message (an array of {text}/{type} runs) into plain text (pure). */
|
|
44
|
+
function flattenRichText(runs) {
|
|
45
|
+
if (!Array.isArray(runs))
|
|
46
|
+
return "";
|
|
47
|
+
return runs.map((r) => (r && typeof r.text === "string" ? r.text : "")).join(" ").trim();
|
|
48
|
+
}
|
|
49
|
+
export function dingtalkAdapter(clientId, clientSecret) {
|
|
50
|
+
// chatId → the latest sessionWebhook seen for it (expires ~ a few hours; refreshed on each inbound message).
|
|
51
|
+
const webhooks = new Map();
|
|
52
|
+
return {
|
|
53
|
+
name: "dingtalk",
|
|
54
|
+
async send(chatId, text) {
|
|
55
|
+
const url = webhooks.get(String(chatId));
|
|
56
|
+
if (!url)
|
|
57
|
+
return; // no inbound seen yet → no webhook to reply through (replies must follow a message)
|
|
58
|
+
for (const part of chunkText(text || "(empty)")) {
|
|
59
|
+
await fetch(url, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ msgtype: "text", text: { content: part } }),
|
|
63
|
+
}).catch(() => { });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
// sendFile intentionally omitted: DingTalk bot replies (sessionWebhook) can't upload arbitrary files in v1.
|
|
67
|
+
// The gateway surfaces "(this platform can't send files yet)" when the agent queues a file.
|
|
68
|
+
async start(onMessage, signal) {
|
|
69
|
+
if (!WSImpl) {
|
|
70
|
+
console.error("hara gateway: DingTalk needs Node ≥ 22 (global WebSocket). Upgrade Node.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
while (!signal.aborted) {
|
|
74
|
+
await connectOnce(clientId, clientSecret, webhooks, onMessage, signal);
|
|
75
|
+
if (!signal.aborted)
|
|
76
|
+
await sleep(3000, signal); // reconnect backoff
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** One Stream connection: register → open the WS to the returned endpoint?ticket=…, then ACK every frame and
|
|
82
|
+
* dispatch bot messages. Resolves on close/abort; the caller reconnects (fresh registration each time). */
|
|
83
|
+
async function connectOnce(clientId, clientSecret, webhooks, onMessage, signal) {
|
|
84
|
+
let endpoint;
|
|
85
|
+
let ticket;
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(OPEN_CONNECTION, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
clientId,
|
|
92
|
+
clientSecret,
|
|
93
|
+
subscriptions: [
|
|
94
|
+
{ type: "EVENT", topic: "*" },
|
|
95
|
+
{ type: "CALLBACK", topic: BOT_TOPIC },
|
|
96
|
+
],
|
|
97
|
+
ua: "hara-cli/stream",
|
|
98
|
+
localIp: "127.0.0.1",
|
|
99
|
+
}),
|
|
100
|
+
signal,
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
console.error(`hara gateway: DingTalk connection register failed (HTTP ${res.status}) — check HARA_DINGTALK_CLIENT_ID/SECRET and that Stream mode is enabled.`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const j = (await res.json());
|
|
107
|
+
if (!j.endpoint || !j.ticket) {
|
|
108
|
+
console.error("hara gateway: DingTalk register returned no endpoint/ticket.");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
endpoint = j.endpoint;
|
|
112
|
+
ticket = j.ticket;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
if (!signal.aborted)
|
|
116
|
+
console.error("hara gateway: DingTalk connection register error (network).");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const ws = new WSImpl(`${endpoint}?ticket=${encodeURIComponent(ticket)}`);
|
|
121
|
+
let hb = null;
|
|
122
|
+
const cleanup = () => {
|
|
123
|
+
if (hb)
|
|
124
|
+
clearInterval(hb);
|
|
125
|
+
signal.removeEventListener("abort", stop);
|
|
126
|
+
};
|
|
127
|
+
const stop = () => {
|
|
128
|
+
cleanup();
|
|
129
|
+
try {
|
|
130
|
+
ws.close();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* already closing */
|
|
134
|
+
}
|
|
135
|
+
resolve();
|
|
136
|
+
};
|
|
137
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
138
|
+
ws.addEventListener("open", () => {
|
|
139
|
+
// Application-level keepalive: a SYSTEM/ping frame keeps the connection registered between messages.
|
|
140
|
+
hb = setInterval(() => {
|
|
141
|
+
try {
|
|
142
|
+
ws.send(JSON.stringify({ type: "SYSTEM", headers: { topic: "ping" }, data: "{}" }));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* socket gone — close handler resolves */
|
|
146
|
+
}
|
|
147
|
+
}, 30000);
|
|
148
|
+
});
|
|
149
|
+
ws.addEventListener("close", () => {
|
|
150
|
+
cleanup();
|
|
151
|
+
resolve();
|
|
152
|
+
});
|
|
153
|
+
ws.addEventListener("error", () => { });
|
|
154
|
+
ws.addEventListener("message", async (ev) => {
|
|
155
|
+
let frame;
|
|
156
|
+
try {
|
|
157
|
+
frame = JSON.parse(String(ev.data));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const headers = frame?.headers ?? {};
|
|
163
|
+
const topic = String(headers.topic ?? "");
|
|
164
|
+
// ACK every frame with its messageId per the Stream ack protocol; reply data is a JSON-string `response`.
|
|
165
|
+
const ack = (response) => {
|
|
166
|
+
if (!headers.messageId)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
ws.send(JSON.stringify({
|
|
170
|
+
code: 200,
|
|
171
|
+
headers: { messageId: headers.messageId, contentType: "application/json" },
|
|
172
|
+
message: "OK",
|
|
173
|
+
data: JSON.stringify({ response: response ?? {} }),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* socket gone */
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
if (frame?.type === "SYSTEM") {
|
|
181
|
+
if (topic === "disconnect") {
|
|
182
|
+
stop(); // server asked us to drop → close and let the caller re-register
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
ack({}); // ping / connected / other system frames → just acknowledge
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (topic === BOT_TOPIC) {
|
|
189
|
+
let payload;
|
|
190
|
+
try {
|
|
191
|
+
payload = JSON.parse(String(frame.data ?? "{}")); // CALLBACK data is a JSON string
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
ack({});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
ack({}); // ack first (DingTalk expects a prompt ack; the actual reply goes via sessionWebhook)
|
|
198
|
+
const parsed = parseDingtalkMessage(payload);
|
|
199
|
+
if (parsed) {
|
|
200
|
+
if (parsed.sessionWebhook)
|
|
201
|
+
webhooks.set(String(parsed.msg.chatId), parsed.sessionWebhook);
|
|
202
|
+
await onMessage(parsed.msg).catch(() => { });
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
ack({}); // any other EVENT frame → acknowledge so DingTalk doesn't retry
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -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
|
+
}
|