@colinagent/hulala-bot 0.0.0-stage → 0.3.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/LICENSE +7 -0
- package/README.md +387 -2
- package/cordis.patch.yml +10 -0
- package/dist/index.mjs +359 -0
- package/dist/plugin.mjs +2432 -0
- package/package.json +83 -4
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,2432 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/host/plugin.ts
|
|
5
|
+
import { join as join5 } from "node:path";
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
|
|
8
|
+
// src/bridge.ts
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { stat } from "node:fs/promises";
|
|
11
|
+
import { resolve as resolve2 } from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/config.ts
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
|
|
20
|
+
// src/telegram/proxy.ts
|
|
21
|
+
import http from "node:http";
|
|
22
|
+
import https from "node:https";
|
|
23
|
+
import tls from "node:tls";
|
|
24
|
+
function normalizeProxy(value) {
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(value);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`proxy must be an http:// or https:// URL, not ${JSON.stringify(value)}`);
|
|
30
|
+
}
|
|
31
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
32
|
+
throw new Error(`proxy must be an http:// or https:// URL, not ${JSON.stringify(value)}`);
|
|
33
|
+
}
|
|
34
|
+
if (url.hostname === "") {
|
|
35
|
+
throw new Error(`proxy has no host: ${JSON.stringify(value)}`);
|
|
36
|
+
}
|
|
37
|
+
return url.toString();
|
|
38
|
+
}
|
|
39
|
+
function tunnel(proxy, host, port, secure, done) {
|
|
40
|
+
const proxySecure = proxy.protocol === "https:";
|
|
41
|
+
const authorization = proxy.username === "" ? undefined : `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`;
|
|
42
|
+
const request = (proxySecure ? https : http).request({
|
|
43
|
+
host: proxy.hostname,
|
|
44
|
+
port: Number(proxy.port || (proxySecure ? 443 : 80)),
|
|
45
|
+
method: "CONNECT",
|
|
46
|
+
path: `${host}:${String(port)}`,
|
|
47
|
+
headers: {
|
|
48
|
+
host: `${host}:${String(port)}`,
|
|
49
|
+
...authorization === undefined ? {} : { "proxy-authorization": authorization }
|
|
50
|
+
},
|
|
51
|
+
timeout: 20000
|
|
52
|
+
});
|
|
53
|
+
request.once("connect", (response, socket) => {
|
|
54
|
+
if (response.statusCode !== 200) {
|
|
55
|
+
socket.destroy();
|
|
56
|
+
const detail = response.statusMessage === undefined ? "" : ` ${response.statusMessage}`;
|
|
57
|
+
done(new Error(`proxy CONNECT failed: ${String(response.statusCode)}${detail}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!secure) {
|
|
61
|
+
done(null, socket);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const wrapped = tls.connect({ socket, servername: host });
|
|
65
|
+
wrapped.once("secureConnect", () => done(null, wrapped));
|
|
66
|
+
wrapped.once("error", (error) => done(error));
|
|
67
|
+
});
|
|
68
|
+
request.once("error", (error) => done(error));
|
|
69
|
+
request.once("timeout", () => request.destroy(new Error("proxy CONNECT timed out")));
|
|
70
|
+
request.end();
|
|
71
|
+
}
|
|
72
|
+
function connect(proxy, options, secure, callback) {
|
|
73
|
+
const host = options.host ?? "localhost";
|
|
74
|
+
const port = Number(options.port ?? (secure ? 443 : 80));
|
|
75
|
+
tunnel(proxy, host, port, secure, (error, socket) => {
|
|
76
|
+
if (callback === undefined)
|
|
77
|
+
return;
|
|
78
|
+
if (socket === undefined)
|
|
79
|
+
callback(error ?? new Error("no socket from the proxy"), undefined);
|
|
80
|
+
else
|
|
81
|
+
callback(error, socket);
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class HttpTunnelAgent extends http.Agent {
|
|
87
|
+
#proxy;
|
|
88
|
+
constructor(proxy) {
|
|
89
|
+
super({ keepAlive: false });
|
|
90
|
+
this.#proxy = proxy;
|
|
91
|
+
}
|
|
92
|
+
createConnection(options, callback) {
|
|
93
|
+
return connect(this.#proxy, options, false, callback);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class HttpsTunnelAgent extends https.Agent {
|
|
98
|
+
#proxy;
|
|
99
|
+
constructor(proxy) {
|
|
100
|
+
super({ keepAlive: false });
|
|
101
|
+
this.#proxy = proxy;
|
|
102
|
+
}
|
|
103
|
+
createConnection(options, callback) {
|
|
104
|
+
return connect(this.#proxy, options, true, callback);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function createProxiedFetch(proxy) {
|
|
108
|
+
const address = new URL(normalizeProxy(proxy));
|
|
109
|
+
return async (input, init = {}) => {
|
|
110
|
+
const target = new URL(input instanceof Request ? input.url : input instanceof URL ? input.href : input);
|
|
111
|
+
const secure = target.protocol === "https:";
|
|
112
|
+
const agent = secure ? new HttpsTunnelAgent(address) : new HttpTunnelAgent(address);
|
|
113
|
+
const headers = {};
|
|
114
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
115
|
+
headers[name] = value;
|
|
116
|
+
});
|
|
117
|
+
const body = typeof init.body === "string" ? init.body : undefined;
|
|
118
|
+
const status = await new Promise((resolve, reject) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (error, result) => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
124
|
+
if (error === null && result !== undefined)
|
|
125
|
+
resolve(result);
|
|
126
|
+
else
|
|
127
|
+
reject(error ?? new Error("proxy request failed"));
|
|
128
|
+
};
|
|
129
|
+
const request = (secure ? https : http).request({
|
|
130
|
+
host: target.hostname,
|
|
131
|
+
port: Number(target.port || (secure ? 443 : 80)),
|
|
132
|
+
path: `${target.pathname}${target.search}`,
|
|
133
|
+
method: init.method ?? "GET",
|
|
134
|
+
headers,
|
|
135
|
+
agent,
|
|
136
|
+
signal: init.signal ?? undefined
|
|
137
|
+
}, (response) => {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
140
|
+
response.on("end", () => finish(null, { code: response.statusCode ?? 0, text: Buffer.concat(chunks).toString("utf8") }));
|
|
141
|
+
response.on("error", (error) => finish(error));
|
|
142
|
+
});
|
|
143
|
+
request.once("error", (error) => finish(error));
|
|
144
|
+
request.end(body);
|
|
145
|
+
});
|
|
146
|
+
const responseHeaders = new Headers;
|
|
147
|
+
return new Response(status.text, { status: status.code, headers: responseHeaders });
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/config.ts
|
|
152
|
+
var run = promisify(execFile);
|
|
153
|
+
var TELEGRAM_API = "https://api.telegram.org";
|
|
154
|
+
var LARK_API = "https://open.feishu.cn/open-apis";
|
|
155
|
+
var LARK_API_INTERNATIONAL = "https://open.larksuite.com/open-apis";
|
|
156
|
+
function hulalaHome(env = process.env) {
|
|
157
|
+
return env.HULALA_HOME ?? join(homedir(), ".hulala");
|
|
158
|
+
}
|
|
159
|
+
function botStateDir(env = process.env) {
|
|
160
|
+
const dshHome = env.DSH_HOME;
|
|
161
|
+
if (dshHome !== undefined && dshHome !== "")
|
|
162
|
+
return join(dshHome, "config", "hulala-bot");
|
|
163
|
+
return join(hulalaHome(env), "config", "hulala-bot");
|
|
164
|
+
}
|
|
165
|
+
function expandHome(path) {
|
|
166
|
+
if (path === "~")
|
|
167
|
+
return homedir();
|
|
168
|
+
if (path.startsWith("~/"))
|
|
169
|
+
return join(homedir(), path.slice(2));
|
|
170
|
+
return path;
|
|
171
|
+
}
|
|
172
|
+
async function readKeychain(ref) {
|
|
173
|
+
const slash = ref.lastIndexOf("/");
|
|
174
|
+
const service = slash === -1 ? ref : ref.slice(0, slash);
|
|
175
|
+
const account = slash === -1 ? ref : ref.slice(slash + 1);
|
|
176
|
+
try {
|
|
177
|
+
if (process.platform === "darwin") {
|
|
178
|
+
const { stdout: stdout2 } = await run("security", ["find-generic-password", "-s", service, "-a", account, "-w"]);
|
|
179
|
+
return stdout2.trim() || undefined;
|
|
180
|
+
}
|
|
181
|
+
const { stdout } = await run("secret-tool", ["lookup", "service", service, "account", account]);
|
|
182
|
+
return stdout.trim() || undefined;
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function resolveSecret(raw, env, field) {
|
|
188
|
+
const direct = raw[field];
|
|
189
|
+
if (typeof direct === "string" && direct.trim() !== "")
|
|
190
|
+
return direct;
|
|
191
|
+
const envName = raw[`${field}Env`];
|
|
192
|
+
if (typeof envName === "string" && envName !== "") {
|
|
193
|
+
const value = env[envName];
|
|
194
|
+
if (value !== undefined && value.trim() !== "")
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
const ref = raw[`${field}Ref`];
|
|
198
|
+
if (typeof ref === "string" && ref !== "")
|
|
199
|
+
return await readKeychain(ref);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
async function readFileConfig(env, root) {
|
|
203
|
+
const stateDir = root ?? env.HULALA_BOT_STATE ?? botStateDir(env);
|
|
204
|
+
const file = env.HULALA_BOT_CONFIG ?? join(stateDir, "config.json");
|
|
205
|
+
let raw = {};
|
|
206
|
+
try {
|
|
207
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error.code !== "ENOENT")
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
return { raw, file, stateDir };
|
|
213
|
+
}
|
|
214
|
+
async function telegramConfig(raw, env, file) {
|
|
215
|
+
if (raw === undefined)
|
|
216
|
+
return;
|
|
217
|
+
const token = await resolveSecret(raw, env, "token") ?? env.TELEGRAM_BOT_TOKEN;
|
|
218
|
+
if (token === undefined || token.trim() === "") {
|
|
219
|
+
throw new Error(`no Telegram bot token: set tokenRef in ${file}, or TELEGRAM_BOT_TOKEN in the environment`);
|
|
220
|
+
}
|
|
221
|
+
const allowedUsers = (raw.allowedUsers ?? []).map(Number).filter(Number.isSafeInteger);
|
|
222
|
+
const allowedChats = (raw.allowedChats ?? []).map(Number).filter(Number.isSafeInteger);
|
|
223
|
+
if (allowedUsers.length === 0 && allowedChats.length === 0) {
|
|
224
|
+
throw new Error(`refusing to start Telegram with an empty allowlist: set telegram.allowedUsers or telegram.allowedChats in ${file}`);
|
|
225
|
+
}
|
|
226
|
+
const parseMode = raw.parseMode ?? "markdown";
|
|
227
|
+
if (parseMode !== "markdown" && parseMode !== "plain") {
|
|
228
|
+
throw new Error(`telegram.parseMode must be "markdown" or "plain", not ${JSON.stringify(String(parseMode))}`);
|
|
229
|
+
}
|
|
230
|
+
const pollSeconds = Number.isFinite(raw.pollTimeoutSeconds) ? raw.pollTimeoutSeconds : 25;
|
|
231
|
+
return {
|
|
232
|
+
token,
|
|
233
|
+
baseUrl: (raw.baseUrl ?? TELEGRAM_API).replace(/\/$/, ""),
|
|
234
|
+
proxy: raw.proxy === undefined ? undefined : normalizeProxy(raw.proxy),
|
|
235
|
+
allowedUsers,
|
|
236
|
+
allowedChats,
|
|
237
|
+
parseMode,
|
|
238
|
+
pollTimeoutSeconds: Math.max(1, Math.min(50, pollSeconds))
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function larkConfig(raw, env, file) {
|
|
242
|
+
if (raw === undefined)
|
|
243
|
+
return;
|
|
244
|
+
const appId = (raw.appId ?? "").trim();
|
|
245
|
+
if (!/^cli_[0-9a-fA-F]{16}$/.test(appId)) {
|
|
246
|
+
throw new Error(`lark.appId must look like cli_<16 hex>, not ${JSON.stringify(appId)} (from ${file})`);
|
|
247
|
+
}
|
|
248
|
+
const appSecret = await resolveSecret(raw, env, "appSecret") ?? env.LARK_APP_SECRET;
|
|
249
|
+
if (appSecret === undefined || appSecret.trim() === "") {
|
|
250
|
+
throw new Error(`no Lark app secret: set lark.appSecretRef in ${file}, or LARK_APP_SECRET in the environment`);
|
|
251
|
+
}
|
|
252
|
+
const allowedUsers = (raw.allowedUsers ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
253
|
+
const allowedChats = (raw.allowedChats ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
254
|
+
if (allowedUsers.length === 0 && allowedChats.length === 0) {
|
|
255
|
+
throw new Error(`refusing to start Lark with an empty allowlist: set lark.allowedUsers (open ids) or lark.allowedChats in ${file}`);
|
|
256
|
+
}
|
|
257
|
+
const domain = raw.domain ?? "feishu";
|
|
258
|
+
if (domain !== "feishu" && domain !== "lark") {
|
|
259
|
+
throw new Error(`lark.domain must be "feishu" or "lark", not ${JSON.stringify(String(domain))}`);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
appId,
|
|
263
|
+
appSecret,
|
|
264
|
+
domain,
|
|
265
|
+
baseUrl: (raw.baseUrl ?? (domain === "lark" ? LARK_API_INTERNATIONAL : LARK_API)).replace(/\/$/, ""),
|
|
266
|
+
allowedUsers,
|
|
267
|
+
allowedChats
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function loadConfig(env = process.env, root) {
|
|
271
|
+
const { raw, file, stateDir } = await readFileConfig(env, root);
|
|
272
|
+
const telegram = await telegramConfig(raw.telegram, env, file);
|
|
273
|
+
const lark = await larkConfig(raw.lark, env, file);
|
|
274
|
+
if (telegram === undefined && lark === undefined) {
|
|
275
|
+
throw new Error(`no channel configured: add a "telegram" or "lark" section to ${file}`);
|
|
276
|
+
}
|
|
277
|
+
const streamIntervalMs = Number.isFinite(raw.streamIntervalMs) ? raw.streamIntervalMs : 800;
|
|
278
|
+
return {
|
|
279
|
+
defaultCwd: resolve(expandHome(raw.defaultCwd ?? join(homedir(), "hulala-workspace", "workspace"))),
|
|
280
|
+
streamIntervalMs: Math.max(300, streamIntervalMs),
|
|
281
|
+
stateDir,
|
|
282
|
+
telegram,
|
|
283
|
+
lark
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/host/turn.ts
|
|
288
|
+
function textOfContent(content) {
|
|
289
|
+
if (!Array.isArray(content))
|
|
290
|
+
return "";
|
|
291
|
+
return content.flatMap((block) => {
|
|
292
|
+
if (block === null || typeof block !== "object")
|
|
293
|
+
return [];
|
|
294
|
+
const record = block;
|
|
295
|
+
return record.type === "text" && typeof record.text === "string" ? [record.text] : [];
|
|
296
|
+
}).join(`
|
|
297
|
+
`);
|
|
298
|
+
}
|
|
299
|
+
var TOOL_LABELS = {
|
|
300
|
+
bash: "运行命令",
|
|
301
|
+
job_output: "等待任务输出",
|
|
302
|
+
job_kill: "结束任务",
|
|
303
|
+
read: "读文件",
|
|
304
|
+
write: "写文件",
|
|
305
|
+
edit: "改文件",
|
|
306
|
+
multi_edit: "改文件",
|
|
307
|
+
apply_patch: "改文件",
|
|
308
|
+
grep: "搜索内容",
|
|
309
|
+
glob: "找文件",
|
|
310
|
+
ls: "列目录",
|
|
311
|
+
todo_write: "更新计划",
|
|
312
|
+
ask_user_question: "向你提问",
|
|
313
|
+
web_fetch: "取网页",
|
|
314
|
+
web_search: "搜索网页"
|
|
315
|
+
};
|
|
316
|
+
var TOOL_DETAIL = ["command", "path", "file_path", "pattern", "query", "url", "description"];
|
|
317
|
+
function describeTool(name, rawArguments) {
|
|
318
|
+
const label = TOOL_LABELS[name] ?? name;
|
|
319
|
+
if (typeof rawArguments !== "string" || rawArguments === "")
|
|
320
|
+
return { label };
|
|
321
|
+
let args;
|
|
322
|
+
try {
|
|
323
|
+
args = JSON.parse(rawArguments);
|
|
324
|
+
} catch {
|
|
325
|
+
return { label };
|
|
326
|
+
}
|
|
327
|
+
for (const field of TOOL_DETAIL) {
|
|
328
|
+
const value = args[field];
|
|
329
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
330
|
+
continue;
|
|
331
|
+
const line = value.replace(/\s+/g, " ").trim();
|
|
332
|
+
return { label, detail: line.length > 80 ? `${line.slice(0, 80)}…` : line };
|
|
333
|
+
}
|
|
334
|
+
return { label };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
class TurnTracker {
|
|
338
|
+
#streamed = "";
|
|
339
|
+
#durable = "";
|
|
340
|
+
#tools = 0;
|
|
341
|
+
#tool;
|
|
342
|
+
#attempt;
|
|
343
|
+
begin() {
|
|
344
|
+
this.#streamed = "";
|
|
345
|
+
this.#durable = "";
|
|
346
|
+
this.#tools = 0;
|
|
347
|
+
this.#tool = undefined;
|
|
348
|
+
this.#attempt = undefined;
|
|
349
|
+
}
|
|
350
|
+
get preview() {
|
|
351
|
+
if (this.text !== "")
|
|
352
|
+
return this.text;
|
|
353
|
+
if (this.#tool === undefined)
|
|
354
|
+
return "";
|
|
355
|
+
return this.#tool.detail === undefined ? `· ${this.#tool.label} …` : `· ${this.#tool.label}:${this.#tool.detail}`;
|
|
356
|
+
}
|
|
357
|
+
get text() {
|
|
358
|
+
return this.#durable !== "" ? this.#durable : this.#streamed;
|
|
359
|
+
}
|
|
360
|
+
get empty() {
|
|
361
|
+
return this.text === "";
|
|
362
|
+
}
|
|
363
|
+
get view() {
|
|
364
|
+
return { streamed: this.#streamed, durable: this.#durable, tools: this.#tools };
|
|
365
|
+
}
|
|
366
|
+
apply(frame) {
|
|
367
|
+
if (frame.type === "snapshot") {
|
|
368
|
+
if (frame.assistantStream?.activeAttempt !== undefined) {
|
|
369
|
+
this.#attempt = frame.assistantStream.activeAttempt.attemptId;
|
|
370
|
+
this.#streamed = "";
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (frame.type === "assistant-stream") {
|
|
375
|
+
const inner = frame.frame;
|
|
376
|
+
if (inner.type === "start") {
|
|
377
|
+
this.#attempt = inner.attemptId;
|
|
378
|
+
this.#streamed = "";
|
|
379
|
+
return "delta";
|
|
380
|
+
}
|
|
381
|
+
if (inner.type === "chunk" && inner.attemptId === this.#attempt) {
|
|
382
|
+
const chunk = inner.chunk;
|
|
383
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
384
|
+
this.#streamed += chunk.text;
|
|
385
|
+
return "delta";
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const event = frame.event;
|
|
392
|
+
if (event.type === "turn/start") {
|
|
393
|
+
this.begin();
|
|
394
|
+
return "begin";
|
|
395
|
+
}
|
|
396
|
+
if (event.type === "turn/end")
|
|
397
|
+
return "end";
|
|
398
|
+
if (event.type === "assistant/message") {
|
|
399
|
+
const content = event.data.message?.content;
|
|
400
|
+
const text = textOfContent(content).trim();
|
|
401
|
+
if (text !== "")
|
|
402
|
+
this.#durable = text;
|
|
403
|
+
return "final";
|
|
404
|
+
}
|
|
405
|
+
if (event.type === "tool/call") {
|
|
406
|
+
this.#tools += 1;
|
|
407
|
+
const data = event.data;
|
|
408
|
+
this.#tool = describeTool(typeof data.name === "string" ? data.name : "tool", data.arguments);
|
|
409
|
+
return "delta";
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/render/split.ts
|
|
416
|
+
var BUDGET = 3600;
|
|
417
|
+
var FENCE_TAIL = "\n```";
|
|
418
|
+
function fenceHead(language) {
|
|
419
|
+
return `\`\`\`${language}
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
function splitMessage(text, limit = BUDGET) {
|
|
423
|
+
if (text.length <= limit)
|
|
424
|
+
return [text];
|
|
425
|
+
const chunks = [];
|
|
426
|
+
let current = "";
|
|
427
|
+
let openFence;
|
|
428
|
+
const flush = () => {
|
|
429
|
+
if (current.trim() === "")
|
|
430
|
+
return;
|
|
431
|
+
chunks.push(openFence === undefined ? current : `${current}${FENCE_TAIL}`);
|
|
432
|
+
current = openFence === undefined ? "" : fenceHead(openFence);
|
|
433
|
+
};
|
|
434
|
+
for (const line of text.split(`
|
|
435
|
+
`)) {
|
|
436
|
+
const reserve = openFence === undefined ? 0 : FENCE_TAIL.length;
|
|
437
|
+
if (current !== "" && current.length + line.length + 1 + reserve > limit)
|
|
438
|
+
flush();
|
|
439
|
+
if (line.length > limit) {
|
|
440
|
+
const stride = Math.max(1, limit - reserve - 1);
|
|
441
|
+
for (let index = 0;index < line.length; index += stride) {
|
|
442
|
+
if (current !== "")
|
|
443
|
+
flush();
|
|
444
|
+
current = line.slice(index, index + stride);
|
|
445
|
+
flush();
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
current = current === "" || current.endsWith(`
|
|
450
|
+
`) ? `${current}${line}` : `${current}
|
|
451
|
+
${line}`;
|
|
452
|
+
if (line.startsWith("```")) {
|
|
453
|
+
openFence = openFence === undefined ? line.slice(3).trim() : undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (current.trim() !== "")
|
|
457
|
+
chunks.push(openFence === undefined ? current : `${current}${FENCE_TAIL}`);
|
|
458
|
+
return chunks;
|
|
459
|
+
}
|
|
460
|
+
function previewOf(text, limit = BUDGET) {
|
|
461
|
+
if (text.length <= limit)
|
|
462
|
+
return text;
|
|
463
|
+
const first = splitMessage(text, limit - 8)[0] ?? "";
|
|
464
|
+
return `${first.replace(/\n+$/, "")}
|
|
465
|
+
…`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/reply.ts
|
|
469
|
+
var MAX_FAILURES = 3;
|
|
470
|
+
var TYPING_INTERVAL_MS = 4000;
|
|
471
|
+
var PLACEHOLDER = "⋯ 正在思考";
|
|
472
|
+
function project(channel, raw) {
|
|
473
|
+
const text = channel.format(raw);
|
|
474
|
+
return { raw, text, parts: splitMessage(text, channel.maxTextLength) };
|
|
475
|
+
}
|
|
476
|
+
function preview(channel, raw) {
|
|
477
|
+
const text = channel.format(raw);
|
|
478
|
+
const head = previewOf(text, channel.maxTextLength);
|
|
479
|
+
return { raw, text: head, parts: [head] };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
class ReplyStream {
|
|
483
|
+
#channel;
|
|
484
|
+
#chat;
|
|
485
|
+
#options;
|
|
486
|
+
#messageId;
|
|
487
|
+
#latest;
|
|
488
|
+
#lastText = "";
|
|
489
|
+
#lastSentAt = 0;
|
|
490
|
+
#timer;
|
|
491
|
+
#typing;
|
|
492
|
+
#failures = 0;
|
|
493
|
+
#degraded = false;
|
|
494
|
+
#closed = false;
|
|
495
|
+
constructor(channel, chat, options) {
|
|
496
|
+
this.#channel = channel;
|
|
497
|
+
this.#chat = chat;
|
|
498
|
+
this.#options = options;
|
|
499
|
+
}
|
|
500
|
+
async begin() {
|
|
501
|
+
if (this.#messageId !== undefined || this.#closed)
|
|
502
|
+
return;
|
|
503
|
+
try {
|
|
504
|
+
const placeholder = project(this.#channel, PLACEHOLDER);
|
|
505
|
+
this.#messageId = await this.#channel.send(this.#chat, placeholder, { preview: false });
|
|
506
|
+
this.#lastText = placeholder.text;
|
|
507
|
+
this.#lastSentAt = Date.now();
|
|
508
|
+
this.#startTyping();
|
|
509
|
+
} catch {
|
|
510
|
+
this.#degraded = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
#startTyping() {
|
|
514
|
+
if (this.#options.typing === false || this.#typing !== undefined)
|
|
515
|
+
return;
|
|
516
|
+
const channel = this.#channel;
|
|
517
|
+
if (channel.typing === undefined)
|
|
518
|
+
return;
|
|
519
|
+
const beat = () => {
|
|
520
|
+
channel.typing?.(this.#chat).catch(() => {
|
|
521
|
+
return;
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
beat();
|
|
525
|
+
this.#typing = setInterval(beat, TYPING_INTERVAL_MS);
|
|
526
|
+
this.#typing.unref();
|
|
527
|
+
}
|
|
528
|
+
#stopTyping() {
|
|
529
|
+
if (this.#typing === undefined)
|
|
530
|
+
return;
|
|
531
|
+
clearInterval(this.#typing);
|
|
532
|
+
this.#typing = undefined;
|
|
533
|
+
}
|
|
534
|
+
async push(text) {
|
|
535
|
+
if (this.#closed || this.#degraded || text.trim() === "")
|
|
536
|
+
return;
|
|
537
|
+
this.#latest = text;
|
|
538
|
+
if (this.#timer !== undefined)
|
|
539
|
+
return;
|
|
540
|
+
const wait = this.#options.intervalMs - (Date.now() - this.#lastSentAt);
|
|
541
|
+
if (wait <= 0) {
|
|
542
|
+
await this.#flush();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.#timer = setTimeout(() => {
|
|
546
|
+
this.#timer = undefined;
|
|
547
|
+
this.#flush();
|
|
548
|
+
}, wait);
|
|
549
|
+
}
|
|
550
|
+
async#flush() {
|
|
551
|
+
const latest = this.#latest;
|
|
552
|
+
if (latest === undefined)
|
|
553
|
+
return;
|
|
554
|
+
const body = preview(this.#channel, latest);
|
|
555
|
+
if (body.text === this.#lastText)
|
|
556
|
+
return;
|
|
557
|
+
await this.#edit(body);
|
|
558
|
+
}
|
|
559
|
+
async#edit(body) {
|
|
560
|
+
if (this.#messageId === undefined)
|
|
561
|
+
return;
|
|
562
|
+
try {
|
|
563
|
+
await this.#channel.edit(this.#chat, this.#messageId, body, { preview: false });
|
|
564
|
+
this.#lastText = body.text;
|
|
565
|
+
this.#lastSentAt = Date.now();
|
|
566
|
+
this.#failures = 0;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
const wait = this.#channel.retryAfterMs?.(error);
|
|
569
|
+
if (wait !== undefined) {
|
|
570
|
+
this.#lastSentAt = Date.now() + wait;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
this.#failures += 1;
|
|
574
|
+
if (this.#failures >= MAX_FAILURES)
|
|
575
|
+
this.#degraded = true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async finish(text) {
|
|
579
|
+
this.#closed = true;
|
|
580
|
+
this.#stopTyping();
|
|
581
|
+
if (this.#timer !== undefined) {
|
|
582
|
+
clearTimeout(this.#timer);
|
|
583
|
+
this.#timer = undefined;
|
|
584
|
+
}
|
|
585
|
+
const body = text.trim();
|
|
586
|
+
if (body === "") {
|
|
587
|
+
if (this.#messageId !== undefined && !this.#degraded) {
|
|
588
|
+
await this.#safeEditBody(this.#messageId, "(本轮没有输出)");
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (this.#messageId !== undefined) {
|
|
593
|
+
if (await this.#safeEditBody(this.#messageId, body)) {
|
|
594
|
+
await this.#sendAll(body, 1);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
await this.#channel.delete(this.#chat, this.#messageId).catch(() => {
|
|
598
|
+
return;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
await this.#sendAll(body, 0);
|
|
602
|
+
}
|
|
603
|
+
async#sendAll(body, skip) {
|
|
604
|
+
const { parts } = project(this.#channel, body);
|
|
605
|
+
for (let index = skip;index < parts.length; index += 1) {
|
|
606
|
+
const rest = { raw: body, text: body, parts: parts.slice(index) };
|
|
607
|
+
try {
|
|
608
|
+
await this.#channel.send(this.#chat, rest);
|
|
609
|
+
return;
|
|
610
|
+
} catch {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async#safeEditBody(messageId, body) {
|
|
616
|
+
try {
|
|
617
|
+
await this.#channel.edit(this.#chat, messageId, project(this.#channel, body), { preview: false });
|
|
618
|
+
return true;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return this.#channel.isMissing?.(error) === false;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
dispose() {
|
|
624
|
+
this.#closed = true;
|
|
625
|
+
this.#stopTyping();
|
|
626
|
+
if (this.#timer !== undefined) {
|
|
627
|
+
clearTimeout(this.#timer);
|
|
628
|
+
this.#timer = undefined;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/router.ts
|
|
634
|
+
var COMMANDS = [
|
|
635
|
+
{ command: "help", description: "命令说明" },
|
|
636
|
+
{ command: "cwd", description: "选择或切换工作目录" },
|
|
637
|
+
{ command: "new", description: "在当前目录新建会话" },
|
|
638
|
+
{ command: "sessions", description: "列出会话" },
|
|
639
|
+
{ command: "switch", description: "切换到第 n 个会话" },
|
|
640
|
+
{ command: "status", description: "当前会话状态" },
|
|
641
|
+
{ command: "stop", description: "停止当前回合" }
|
|
642
|
+
];
|
|
643
|
+
var HELP = [
|
|
644
|
+
"把手机接到本机的 Hulala agent 上。",
|
|
645
|
+
"",
|
|
646
|
+
"/cwd 列出最近用过的目录,点一个就挂上去",
|
|
647
|
+
"/cwd <path> 直接指定目录(没有会话就新建)",
|
|
648
|
+
"/new 在当前目录新建会话",
|
|
649
|
+
"/sessions 最近会话",
|
|
650
|
+
"/switch <n> 切换到第 n 个",
|
|
651
|
+
"/status 会话、目录、运行状态",
|
|
652
|
+
"/stop 停止当前回合",
|
|
653
|
+
"",
|
|
654
|
+
"直接发文字就是提问;审批会以按钮出现,任何一端先点生效。"
|
|
655
|
+
].join(`
|
|
656
|
+
`);
|
|
657
|
+
function parseCommand(text) {
|
|
658
|
+
const trimmed = text.trim();
|
|
659
|
+
if (!trimmed.startsWith("/"))
|
|
660
|
+
return;
|
|
661
|
+
const space = trimmed.search(/\s/);
|
|
662
|
+
const head = (space === -1 ? trimmed : trimmed.slice(0, space)).slice(1);
|
|
663
|
+
const at = head.indexOf("@");
|
|
664
|
+
const name = (at === -1 ? head : head.slice(0, at)).toLowerCase();
|
|
665
|
+
const args = space === -1 ? "" : trimmed.slice(space).trim();
|
|
666
|
+
if (name === "")
|
|
667
|
+
return;
|
|
668
|
+
return { name, args };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/store/bindings.ts
|
|
672
|
+
import { mkdir, readFile as readFile2, rename, writeFile } from "node:fs/promises";
|
|
673
|
+
import { dirname, join as join2 } from "node:path";
|
|
674
|
+
function keyOf(key) {
|
|
675
|
+
return `${key.channel}:${key.chatId}:${key.threadId ?? ""}`;
|
|
676
|
+
}
|
|
677
|
+
function parseKey(key) {
|
|
678
|
+
const [channel, chatId, threadId] = key.split(":");
|
|
679
|
+
if (channel !== "telegram" && channel !== "lark")
|
|
680
|
+
return;
|
|
681
|
+
if (chatId === undefined || chatId === "")
|
|
682
|
+
return;
|
|
683
|
+
return {
|
|
684
|
+
channel,
|
|
685
|
+
chatId,
|
|
686
|
+
...threadId === undefined || threadId === "" ? {} : { threadId }
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
class Bindings {
|
|
691
|
+
#path;
|
|
692
|
+
#entries = new Map;
|
|
693
|
+
constructor(path) {
|
|
694
|
+
this.#path = path;
|
|
695
|
+
}
|
|
696
|
+
static async open(dir) {
|
|
697
|
+
const table = new Bindings(join2(dir, "bindings.json"));
|
|
698
|
+
try {
|
|
699
|
+
const parsed = JSON.parse(await readFile2(table.#path, "utf8"));
|
|
700
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
701
|
+
if (typeof entry?.sessionId === "string" && parseKey(key) !== undefined)
|
|
702
|
+
table.#entries.set(key, entry);
|
|
703
|
+
}
|
|
704
|
+
} catch {}
|
|
705
|
+
return table;
|
|
706
|
+
}
|
|
707
|
+
get(key) {
|
|
708
|
+
return this.#entries.get(keyOf(key))?.sessionId;
|
|
709
|
+
}
|
|
710
|
+
set(key, sessionId) {
|
|
711
|
+
const status = this.#entries.get(keyOf(key))?.statusMessageId;
|
|
712
|
+
this.#entries.set(keyOf(key), {
|
|
713
|
+
sessionId,
|
|
714
|
+
updatedAt: Date.now(),
|
|
715
|
+
...status === undefined ? {} : { statusMessageId: status }
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
statusMessageId(key) {
|
|
719
|
+
return this.#entries.get(keyOf(key))?.statusMessageId;
|
|
720
|
+
}
|
|
721
|
+
setStatusMessageId(key, messageId) {
|
|
722
|
+
const entry = this.#entries.get(keyOf(key));
|
|
723
|
+
if (entry === undefined)
|
|
724
|
+
return;
|
|
725
|
+
this.#entries.set(keyOf(key), { ...entry, statusMessageId: messageId });
|
|
726
|
+
}
|
|
727
|
+
delete(key) {
|
|
728
|
+
this.#entries.delete(keyOf(key));
|
|
729
|
+
}
|
|
730
|
+
chatsFor(sessionId) {
|
|
731
|
+
const keys = [];
|
|
732
|
+
for (const [key, entry] of this.#entries) {
|
|
733
|
+
if (entry.sessionId !== sessionId)
|
|
734
|
+
continue;
|
|
735
|
+
const parsed = parseKey(key);
|
|
736
|
+
if (parsed !== undefined)
|
|
737
|
+
keys.push(parsed);
|
|
738
|
+
}
|
|
739
|
+
return keys;
|
|
740
|
+
}
|
|
741
|
+
entries() {
|
|
742
|
+
return [...this.#entries.keys()].map(parseKey).filter((key) => key !== undefined);
|
|
743
|
+
}
|
|
744
|
+
async flush() {
|
|
745
|
+
const temporary = `${this.#path}.${String(process.pid)}.tmp`;
|
|
746
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 448 });
|
|
747
|
+
await writeFile(temporary, JSON.stringify(Object.fromEntries(this.#entries), null, 2), { mode: 384 });
|
|
748
|
+
await rename(temporary, this.#path);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/bridge.ts
|
|
753
|
+
function unwrap(result) {
|
|
754
|
+
if (!result.ok) {
|
|
755
|
+
const failure = result.error;
|
|
756
|
+
throw new Error(failure.message ?? failure.code ?? "Host request failed");
|
|
757
|
+
}
|
|
758
|
+
return result.value;
|
|
759
|
+
}
|
|
760
|
+
function whenAborted(signal, run2) {
|
|
761
|
+
if (signal === undefined)
|
|
762
|
+
return () => {
|
|
763
|
+
return;
|
|
764
|
+
};
|
|
765
|
+
const listener = () => {
|
|
766
|
+
run2();
|
|
767
|
+
};
|
|
768
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
769
|
+
if (signal.aborted)
|
|
770
|
+
run2();
|
|
771
|
+
return () => {
|
|
772
|
+
signal.removeEventListener("abort", listener);
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
class Bridge {
|
|
777
|
+
#config;
|
|
778
|
+
#channels;
|
|
779
|
+
#bindings;
|
|
780
|
+
#log;
|
|
781
|
+
#connect;
|
|
782
|
+
#mounts = new Map;
|
|
783
|
+
#pending = new Map;
|
|
784
|
+
#recent = new Map;
|
|
785
|
+
#cwdChoices = new Map;
|
|
786
|
+
#stopped = new AbortController;
|
|
787
|
+
#backend;
|
|
788
|
+
#connecting;
|
|
789
|
+
#sequence = 0;
|
|
790
|
+
#defaultSession;
|
|
791
|
+
constructor(options) {
|
|
792
|
+
this.#config = options.config;
|
|
793
|
+
this.#channels = new Map(options.channels.map((channel) => [channel.name, channel]));
|
|
794
|
+
this.#bindings = options.bindings;
|
|
795
|
+
this.#log = options.log;
|
|
796
|
+
this.#connect = options.connect ?? (async () => {
|
|
797
|
+
throw new Error("no Host backend: the bot runs as a Host plugin, not as a separate process");
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async start() {
|
|
801
|
+
await this.#ensureHost().catch((error) => {
|
|
802
|
+
this.#log.error(`Host is not reachable yet: ${error instanceof Error ? error.message : String(error)}`);
|
|
803
|
+
});
|
|
804
|
+
const signal = this.#stopped.signal;
|
|
805
|
+
await Promise.all([...this.#channels.values()].map(async (channel) => {
|
|
806
|
+
try {
|
|
807
|
+
await channel.start((event) => this.#dispatch(event), signal);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
this.#log.error(`${channel.name} stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
810
|
+
}
|
|
811
|
+
}));
|
|
812
|
+
}
|
|
813
|
+
async stop() {
|
|
814
|
+
this.#stopped.abort();
|
|
815
|
+
for (const mount of this.#mounts.values()) {
|
|
816
|
+
mount.abort.abort();
|
|
817
|
+
mount.reply?.dispose();
|
|
818
|
+
}
|
|
819
|
+
this.#mounts.clear();
|
|
820
|
+
await this.#bindings.flush().catch(() => {
|
|
821
|
+
return;
|
|
822
|
+
});
|
|
823
|
+
const backend = this.#backend;
|
|
824
|
+
this.#backend = undefined;
|
|
825
|
+
await backend?.close().catch(() => {
|
|
826
|
+
return;
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
#channel(chat) {
|
|
830
|
+
const channel = this.#channels.get(chat.channel);
|
|
831
|
+
if (channel === undefined)
|
|
832
|
+
throw new Error(`channel ${chat.channel} is not running`);
|
|
833
|
+
return channel;
|
|
834
|
+
}
|
|
835
|
+
async#dispatch(event) {
|
|
836
|
+
if (event.kind === "callback") {
|
|
837
|
+
await this.#onCallback(event);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (event.fromBot)
|
|
841
|
+
return;
|
|
842
|
+
const command = parseCommand(event.text);
|
|
843
|
+
if (command !== undefined) {
|
|
844
|
+
await this.#command(event, command.name, command.args);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
await this.#prompt(event);
|
|
848
|
+
}
|
|
849
|
+
async#ensureHost() {
|
|
850
|
+
if (this.#backend !== undefined)
|
|
851
|
+
return this.#backend;
|
|
852
|
+
this.#connecting ??= this.#connect();
|
|
853
|
+
let backend;
|
|
854
|
+
try {
|
|
855
|
+
backend = await this.#connecting;
|
|
856
|
+
} finally {
|
|
857
|
+
this.#connecting = undefined;
|
|
858
|
+
}
|
|
859
|
+
this.#backend = backend;
|
|
860
|
+
const events = backend.ctx.remote;
|
|
861
|
+
events.$on("approval/request", (request, next) => this.#onApproval(request, next));
|
|
862
|
+
events.$on("user-questions/request", (request, next) => this.#onQuestion(request, next));
|
|
863
|
+
await this.#restore();
|
|
864
|
+
return backend;
|
|
865
|
+
}
|
|
866
|
+
async#drop(reason) {
|
|
867
|
+
const backend = this.#backend;
|
|
868
|
+
if (backend === undefined)
|
|
869
|
+
return;
|
|
870
|
+
this.#log.error(`host connection lost: ${reason}`);
|
|
871
|
+
this.#backend = undefined;
|
|
872
|
+
this.#defaultSession = undefined;
|
|
873
|
+
for (const key of [...this.#pending.keys()])
|
|
874
|
+
this.#settle(key, "cancelled", "");
|
|
875
|
+
for (const mount of this.#mounts.values())
|
|
876
|
+
mount.abort.abort();
|
|
877
|
+
this.#mounts.clear();
|
|
878
|
+
await backend?.close().catch(() => {
|
|
879
|
+
return;
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
async#restore() {
|
|
883
|
+
for (const mount of this.#mounts.values())
|
|
884
|
+
mount.abort.abort();
|
|
885
|
+
this.#mounts.clear();
|
|
886
|
+
for (const key of this.#bindings.entries()) {
|
|
887
|
+
const sessionId = this.#bindings.get(key);
|
|
888
|
+
if (sessionId === undefined)
|
|
889
|
+
continue;
|
|
890
|
+
await this.#mount(key, sessionId).catch((error) => {
|
|
891
|
+
this.#log.error(`cannot mount ${keyOf(key)}: ${String(error)}`);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async#say(chat, text) {
|
|
896
|
+
const channel = this.#channel(chat);
|
|
897
|
+
try {
|
|
898
|
+
await channel.send(chat, project(channel, text), { preview: false });
|
|
899
|
+
} catch (error) {
|
|
900
|
+
this.#log.error(`reply to ${keyOf(chat)} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async#command(message, name, args) {
|
|
904
|
+
const chat = message.chat;
|
|
905
|
+
switch (name) {
|
|
906
|
+
case "start":
|
|
907
|
+
case "help": {
|
|
908
|
+
await this.#say(chat, HELP);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
case "cwd": {
|
|
912
|
+
if (args === "") {
|
|
913
|
+
await this.#offerDirectories(chat);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
await this.#switchDirectory(chat, args);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
case "new": {
|
|
920
|
+
await this.#newSession(chat);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
case "sessions": {
|
|
924
|
+
await this.#listSessions(chat);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
case "switch": {
|
|
928
|
+
await this.#switchSession(chat, args);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
case "status": {
|
|
932
|
+
await this.#say(chat, await this.#describe(chat));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
case "stop": {
|
|
936
|
+
await this.#stopTurn(chat);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
default: {
|
|
940
|
+
await this.#say(chat, `未知命令 /${name}
|
|
941
|
+
|
|
942
|
+
${HELP}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
async#summary(sessionId) {
|
|
947
|
+
const backend = await this.#ensureHost();
|
|
948
|
+
const items = unwrap(await backend.ctx.remote.session.list({}));
|
|
949
|
+
return items.items.find((item) => item.sessionId === sessionId);
|
|
950
|
+
}
|
|
951
|
+
async#describe(chat) {
|
|
952
|
+
const sessionId = this.#bindings.get(chat);
|
|
953
|
+
if (sessionId === undefined)
|
|
954
|
+
return "还没有挂载会话。发一条消息,我会在默认目录里建一个。";
|
|
955
|
+
const summary = await this.#summary(sessionId).catch(() => {
|
|
956
|
+
return;
|
|
957
|
+
});
|
|
958
|
+
const mount = this.#mounts.get(keyOf(chat));
|
|
959
|
+
const title = this.#titleOf(summary) ?? sessionId.slice(0, 8);
|
|
960
|
+
return [
|
|
961
|
+
`会话 ${title}`,
|
|
962
|
+
`目录 ${summary?.cwd ?? "(未知)"}`,
|
|
963
|
+
`状态 ${mount?.active === true ? "运行中" : "空闲"}`,
|
|
964
|
+
`id ${sessionId}`
|
|
965
|
+
].join(`
|
|
966
|
+
`);
|
|
967
|
+
}
|
|
968
|
+
#titleOf(summary) {
|
|
969
|
+
const projections = summary?.projections;
|
|
970
|
+
return typeof projections?.title === "string" ? projections.title : undefined;
|
|
971
|
+
}
|
|
972
|
+
async#recentDirectories() {
|
|
973
|
+
const backend = await this.#ensureHost();
|
|
974
|
+
const listed = unwrap(await backend.ctx.remote.session.list({}));
|
|
975
|
+
const items = listed.items.filter((item) => item.origin !== "subagent" && typeof item.cwd === "string").sort((left, right) => right.updatedAt - left.updatedAt);
|
|
976
|
+
const seen = new Set;
|
|
977
|
+
const paths = [];
|
|
978
|
+
for (const item of items) {
|
|
979
|
+
const path = item.cwd;
|
|
980
|
+
if (seen.has(path))
|
|
981
|
+
continue;
|
|
982
|
+
seen.add(path);
|
|
983
|
+
paths.push(path);
|
|
984
|
+
if (paths.length === 8)
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
return paths;
|
|
988
|
+
}
|
|
989
|
+
async#offerDirectories(chat) {
|
|
990
|
+
const currentId = this.#bindings.get(chat);
|
|
991
|
+
const current = currentId === undefined ? undefined : await this.#summary(currentId).catch(() => {
|
|
992
|
+
return;
|
|
993
|
+
});
|
|
994
|
+
const currentPath = current?.cwd;
|
|
995
|
+
let paths;
|
|
996
|
+
try {
|
|
997
|
+
paths = await this.#recentDirectories();
|
|
998
|
+
} catch (error) {
|
|
999
|
+
await this.#say(chat, `读不到目录列表:${error instanceof Error ? error.message : String(error)}`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (paths.length === 0) {
|
|
1003
|
+
await this.#say(chat, "还没有用过任何目录。发 /cwd <path> 指定一个。");
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
this.#cwdChoices.set(keyOf(chat), paths);
|
|
1007
|
+
const header = currentPath === undefined ? "当前还没有挂载会话。" : `当前目录:${currentPath}`;
|
|
1008
|
+
const keyboard = paths.map((path, index) => [{
|
|
1009
|
+
text: `${path === currentPath ? "✓ " : ""}${this.#shortPath(path)}`,
|
|
1010
|
+
data: `cwd:${String(index)}`
|
|
1011
|
+
}]);
|
|
1012
|
+
const channel = this.#channel(chat);
|
|
1013
|
+
try {
|
|
1014
|
+
await channel.send(chat, project(channel, `${header}
|
|
1015
|
+
|
|
1016
|
+
选择要挂载的目录:`), { keyboard, preview: false });
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
this.#log.error(`cwd picker failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
#shortPath(path) {
|
|
1022
|
+
const parts = path.split("/").filter((part) => part !== "");
|
|
1023
|
+
if (parts.length <= 2)
|
|
1024
|
+
return path;
|
|
1025
|
+
return `…/${parts.slice(-2).join("/")}`;
|
|
1026
|
+
}
|
|
1027
|
+
async#syncStatus(chat) {
|
|
1028
|
+
const channel = this.#channel(chat);
|
|
1029
|
+
if (!channel.pinnedStatus || channel.pin === undefined)
|
|
1030
|
+
return;
|
|
1031
|
+
const mount = this.#mounts.get(keyOf(chat));
|
|
1032
|
+
const sessionId = this.#bindings.get(chat);
|
|
1033
|
+
if (sessionId === undefined)
|
|
1034
|
+
return;
|
|
1035
|
+
const path = mount?.path ?? "(未知目录)";
|
|
1036
|
+
const title = mount?.title ?? sessionId.slice(0, 8);
|
|
1037
|
+
const text = `\uD83D\uDCCD ${path}
|
|
1038
|
+
${title} · ${mount?.active === true ? "运行中" : "空闲"}`;
|
|
1039
|
+
const body = project(channel, text);
|
|
1040
|
+
const existing = this.#bindings.statusMessageId(chat);
|
|
1041
|
+
if (existing !== undefined) {
|
|
1042
|
+
try {
|
|
1043
|
+
await channel.edit(chat, existing, body, { preview: false });
|
|
1044
|
+
return;
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (channel.isMissing?.(error) !== true) {
|
|
1047
|
+
this.#log.error(`status edit failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
try {
|
|
1053
|
+
const messageId = await channel.send(chat, body, { preview: false });
|
|
1054
|
+
if (messageId === undefined)
|
|
1055
|
+
return;
|
|
1056
|
+
const previous = existing;
|
|
1057
|
+
this.#bindings.setStatusMessageId(chat, messageId);
|
|
1058
|
+
await this.#bindings.flush().catch(() => {
|
|
1059
|
+
return;
|
|
1060
|
+
});
|
|
1061
|
+
if (previous !== undefined)
|
|
1062
|
+
await channel.unpin?.(chat, previous).catch(() => {
|
|
1063
|
+
return;
|
|
1064
|
+
});
|
|
1065
|
+
await channel.pin(chat, messageId).catch((error) => {
|
|
1066
|
+
this.#log.error(`pin failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1067
|
+
});
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
this.#log.error(`status message failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
async#switchDirectory(chat, raw) {
|
|
1073
|
+
const path = resolve2(expandHome(raw));
|
|
1074
|
+
const info = await stat(path).catch(() => {
|
|
1075
|
+
return;
|
|
1076
|
+
});
|
|
1077
|
+
if (info === undefined || !info.isDirectory()) {
|
|
1078
|
+
await this.#say(chat, `目录不存在:${path}`);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const backend = await this.#ensureHost();
|
|
1082
|
+
const remote = backend.ctx.remote;
|
|
1083
|
+
const workspaceId = unwrap(await remote.workspace.create({ path })).workspace.workspaceId;
|
|
1084
|
+
const listed = unwrap(await remote.session.list({}));
|
|
1085
|
+
const items = listed.items.filter((item) => item.origin !== "subagent" && item.cwd === path).sort((left, right) => right.updatedAt - left.updatedAt);
|
|
1086
|
+
const existing = items[0];
|
|
1087
|
+
const sessionId = existing === undefined ? unwrap(await remote.session.create({ workspaceId })).sessionId : existing.sessionId;
|
|
1088
|
+
await this.#mount(chat, sessionId);
|
|
1089
|
+
await this.#say(chat, existing === undefined ? `已在 ${path} 新建并挂载会话。` : `已挂载 ${path} 下最近的会话。`);
|
|
1090
|
+
}
|
|
1091
|
+
async#newSession(chat) {
|
|
1092
|
+
const backend = await this.#ensureHost();
|
|
1093
|
+
const remote = backend.ctx.remote;
|
|
1094
|
+
const current = this.#bindings.get(chat);
|
|
1095
|
+
const summary = current === undefined ? undefined : await this.#summary(current).catch(() => {
|
|
1096
|
+
return;
|
|
1097
|
+
});
|
|
1098
|
+
const path = summary?.cwd ?? this.#config.defaultCwd;
|
|
1099
|
+
const workspaceId = unwrap(await remote.workspace.create({ path })).workspace.workspaceId;
|
|
1100
|
+
const sessionId = unwrap(await remote.session.create({ workspaceId })).sessionId;
|
|
1101
|
+
await this.#mount(chat, sessionId);
|
|
1102
|
+
await this.#say(chat, `已新建会话,目录 ${path}。`);
|
|
1103
|
+
}
|
|
1104
|
+
async#listSessions(chat) {
|
|
1105
|
+
const backend = await this.#ensureHost();
|
|
1106
|
+
const listed = unwrap(await backend.ctx.remote.session.list({}));
|
|
1107
|
+
const items = listed.items.filter((item) => item.origin !== "subagent").sort((left, right) => right.updatedAt - left.updatedAt).slice(0, 10);
|
|
1108
|
+
if (items.length === 0) {
|
|
1109
|
+
await this.#say(chat, "还没有会话。发一条消息就会新建一个。");
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.#recent.set(keyOf(chat), items.map((item) => item.sessionId));
|
|
1113
|
+
const current = this.#bindings.get(chat);
|
|
1114
|
+
const lines = items.map((item, index) => {
|
|
1115
|
+
const title = this.#titleOf(item) ?? item.cwd?.split("/").pop() ?? "(未命名)";
|
|
1116
|
+
const marker = item.sessionId === current ? "*" : " ";
|
|
1117
|
+
return `${marker}${String(index + 1)}. ${title}${item.running ? " · 运行中" : ""}`;
|
|
1118
|
+
});
|
|
1119
|
+
await this.#say(chat, ["最近会话:", ...lines, "", "用 /switch <n> 切换。"].join(`
|
|
1120
|
+
`));
|
|
1121
|
+
}
|
|
1122
|
+
async#switchSession(chat, args) {
|
|
1123
|
+
const index = Number.parseInt(args, 10);
|
|
1124
|
+
const recent = this.#recent.get(keyOf(chat));
|
|
1125
|
+
if (recent === undefined) {
|
|
1126
|
+
await this.#say(chat, "先发 /sessions 看看有哪些会话。");
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const sessionId = Number.isSafeInteger(index) ? recent[index - 1] : undefined;
|
|
1130
|
+
if (sessionId === undefined) {
|
|
1131
|
+
await this.#say(chat, `没有第 ${args} 个会话。`);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
await this.#mount(chat, sessionId);
|
|
1135
|
+
await this.#say(chat, `已切换。
|
|
1136
|
+
|
|
1137
|
+
${await this.#describe(chat)}`);
|
|
1138
|
+
}
|
|
1139
|
+
async#stopTurn(chat) {
|
|
1140
|
+
const sessionId = this.#bindings.get(chat);
|
|
1141
|
+
if (sessionId === undefined) {
|
|
1142
|
+
await this.#say(chat, "还没有挂载会话。");
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const backend = await this.#ensureHost();
|
|
1146
|
+
unwrap(await backend.ctx.remote.session.cancel({ sessionId }));
|
|
1147
|
+
await this.#say(chat, "已请求停止。");
|
|
1148
|
+
}
|
|
1149
|
+
async#prompt(message) {
|
|
1150
|
+
const chat = message.chat;
|
|
1151
|
+
const text = message.text.trim();
|
|
1152
|
+
if (text === "")
|
|
1153
|
+
return;
|
|
1154
|
+
let sessionId = this.#bindings.get(chat);
|
|
1155
|
+
if (sessionId === undefined) {
|
|
1156
|
+
sessionId = await this.#default();
|
|
1157
|
+
if (sessionId === undefined) {
|
|
1158
|
+
await this.#say(chat, "无法创建会话:Host 里没有可用的工作区。");
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
await this.#mount(chat, sessionId);
|
|
1162
|
+
}
|
|
1163
|
+
const backend = await this.#ensureHost();
|
|
1164
|
+
try {
|
|
1165
|
+
unwrap(await backend.ctx.remote.session.prompt({
|
|
1166
|
+
requestId: randomUUID(),
|
|
1167
|
+
sessionId,
|
|
1168
|
+
mode: "queue",
|
|
1169
|
+
content: [{ type: "text", text }],
|
|
1170
|
+
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
1171
|
+
}));
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
await this.#drop(error instanceof Error ? error.message : String(error));
|
|
1174
|
+
await this.#say(chat, "Host 没有接受这次请求,已重新连接;请再发一次。");
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
async#default() {
|
|
1178
|
+
if (this.#defaultSession !== undefined)
|
|
1179
|
+
return this.#defaultSession;
|
|
1180
|
+
const backend = await this.#ensureHost();
|
|
1181
|
+
const remote = backend.ctx.remote;
|
|
1182
|
+
const path = this.#config.defaultCwd;
|
|
1183
|
+
const workspaceId = unwrap(await remote.workspace.create({ path })).workspace.workspaceId;
|
|
1184
|
+
const listed = unwrap(await remote.session.list({}));
|
|
1185
|
+
const items = listed.items.filter((item) => item.origin !== "subagent" && item.cwd === path).sort((left, right) => right.updatedAt - left.updatedAt);
|
|
1186
|
+
const sessionId = items[0]?.sessionId ?? unwrap(await remote.session.create({ workspaceId })).sessionId;
|
|
1187
|
+
this.#defaultSession = sessionId;
|
|
1188
|
+
return sessionId;
|
|
1189
|
+
}
|
|
1190
|
+
async#mount(chat, sessionId, known = {}) {
|
|
1191
|
+
const existing = this.#mounts.get(keyOf(chat));
|
|
1192
|
+
existing?.abort.abort();
|
|
1193
|
+
existing?.reply?.dispose();
|
|
1194
|
+
let path = known.path;
|
|
1195
|
+
let title = known.title;
|
|
1196
|
+
if (path === undefined || title === undefined) {
|
|
1197
|
+
const summary = await this.#summary(sessionId).catch(() => {
|
|
1198
|
+
return;
|
|
1199
|
+
});
|
|
1200
|
+
path ??= summary?.cwd;
|
|
1201
|
+
title ??= this.#titleOf(summary);
|
|
1202
|
+
}
|
|
1203
|
+
this.#mounts.set(keyOf(chat), {
|
|
1204
|
+
key: chat,
|
|
1205
|
+
sessionId,
|
|
1206
|
+
tracker: new TurnTracker,
|
|
1207
|
+
abort: new AbortController,
|
|
1208
|
+
reply: undefined,
|
|
1209
|
+
active: false,
|
|
1210
|
+
path,
|
|
1211
|
+
title
|
|
1212
|
+
});
|
|
1213
|
+
this.#bindings.set(chat, sessionId);
|
|
1214
|
+
await this.#bindings.flush().catch(() => {
|
|
1215
|
+
return;
|
|
1216
|
+
});
|
|
1217
|
+
this.#follow(this.#mounts.get(keyOf(chat)));
|
|
1218
|
+
this.#syncStatus(chat);
|
|
1219
|
+
}
|
|
1220
|
+
async#follow(mount) {
|
|
1221
|
+
let backend;
|
|
1222
|
+
try {
|
|
1223
|
+
backend = await this.#ensureHost();
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
this.#log.error(`follow failed: ${String(error)}`);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1229
|
+
if (mount.abort.signal.aborted)
|
|
1230
|
+
return;
|
|
1231
|
+
try {
|
|
1232
|
+
const stream = backend.ctx.remote.session.follow({
|
|
1233
|
+
address: { kind: "session", sessionId: mount.sessionId },
|
|
1234
|
+
maxMessages: 60,
|
|
1235
|
+
assistantStream: true,
|
|
1236
|
+
activate: true
|
|
1237
|
+
}, mount.abort.signal);
|
|
1238
|
+
for await (const frame of stream) {
|
|
1239
|
+
if (mount.abort.signal.aborted)
|
|
1240
|
+
return;
|
|
1241
|
+
await this.#onFrame(mount, frame);
|
|
1242
|
+
}
|
|
1243
|
+
this.#log.error(`follow ended without an abort; reopening (attempt ${String(attempt)})`);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
if (mount.abort.signal.aborted)
|
|
1246
|
+
return;
|
|
1247
|
+
this.#log.error(`follow failed (attempt ${String(attempt)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1248
|
+
}
|
|
1249
|
+
if (attempt >= 3) {
|
|
1250
|
+
await this.#drop(`follow for ${mount.sessionId} gave up`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
await new Promise((settle) => setTimeout(settle, 1000 * attempt));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
#replyFor(mount) {
|
|
1257
|
+
return new ReplyStream(this.#channel(mount.key), mount.key, { intervalMs: this.#config.streamIntervalMs });
|
|
1258
|
+
}
|
|
1259
|
+
async#onFrame(mount, frame) {
|
|
1260
|
+
const effect = mount.tracker.apply(frame);
|
|
1261
|
+
if (effect === undefined)
|
|
1262
|
+
return;
|
|
1263
|
+
if (effect === "begin") {
|
|
1264
|
+
mount.active = true;
|
|
1265
|
+
this.#syncStatus(mount.key);
|
|
1266
|
+
mount.reply?.dispose();
|
|
1267
|
+
mount.reply = this.#replyFor(mount);
|
|
1268
|
+
await mount.reply.begin();
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (effect === "delta") {
|
|
1272
|
+
if (mount.reply === undefined) {
|
|
1273
|
+
mount.active = true;
|
|
1274
|
+
mount.reply = this.#replyFor(mount);
|
|
1275
|
+
await mount.reply.begin();
|
|
1276
|
+
}
|
|
1277
|
+
const preview2 = mount.tracker.preview;
|
|
1278
|
+
if (preview2 !== "")
|
|
1279
|
+
await mount.reply.push(preview2);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (effect === "end") {
|
|
1283
|
+
const reply = mount.reply;
|
|
1284
|
+
mount.reply = undefined;
|
|
1285
|
+
mount.active = false;
|
|
1286
|
+
await reply?.finish(mount.tracker.text);
|
|
1287
|
+
this.#syncStatus(mount.key);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
async#onApproval(request, next) {
|
|
1291
|
+
const running = [...this.#mounts.values()].filter((mount) => mount.active);
|
|
1292
|
+
const watchers = running.length > 0 ? running : [...this.#mounts.values()];
|
|
1293
|
+
if (watchers.length === 0)
|
|
1294
|
+
return await next();
|
|
1295
|
+
this.#sequence += 1;
|
|
1296
|
+
const key = `a${String(this.#sequence)}`;
|
|
1297
|
+
const reason = typeof request.displayReason === "string" ? request.displayReason : request.reason;
|
|
1298
|
+
const summary = `\uD83D\uDEE1 需要批准 · ${request.toolName}${reason === undefined ? "" : `
|
|
1299
|
+
${reason}`}`;
|
|
1300
|
+
const cards = [];
|
|
1301
|
+
const outcome = new Promise((resolve3) => {
|
|
1302
|
+
this.#pending.set(key, { key, cards, settled: false, resolve: resolve3 });
|
|
1303
|
+
});
|
|
1304
|
+
for (const mount of watchers) {
|
|
1305
|
+
try {
|
|
1306
|
+
const channel = this.#channel(mount.key);
|
|
1307
|
+
const messageId = await channel.send(mount.key, project(channel, summary), {
|
|
1308
|
+
keyboard: [[
|
|
1309
|
+
{ text: "允许这一次", data: `approve:${key}` },
|
|
1310
|
+
{ text: "拒绝", data: `reject:${key}` }
|
|
1311
|
+
]],
|
|
1312
|
+
preview: false
|
|
1313
|
+
});
|
|
1314
|
+
if (messageId !== undefined)
|
|
1315
|
+
cards.push({ chat: mount.key, messageId });
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
this.#log.error(`approval card failed: ${String(error)}`);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const pending = this.#pending.get(key);
|
|
1321
|
+
if (pending === undefined || cards.length === 0) {
|
|
1322
|
+
this.#pending.delete(key);
|
|
1323
|
+
return await next();
|
|
1324
|
+
}
|
|
1325
|
+
const removeAbort = whenAborted(request.signal, () => {
|
|
1326
|
+
this.#settle(key, "cancelled", "");
|
|
1327
|
+
});
|
|
1328
|
+
try {
|
|
1329
|
+
return await outcome;
|
|
1330
|
+
} finally {
|
|
1331
|
+
removeAbort();
|
|
1332
|
+
this.#pending.delete(key);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
#settle(key, decision, by) {
|
|
1336
|
+
const pending = this.#pending.get(key);
|
|
1337
|
+
if (pending === undefined || pending.settled)
|
|
1338
|
+
return;
|
|
1339
|
+
pending.settled = true;
|
|
1340
|
+
this.#pending.delete(key);
|
|
1341
|
+
pending.resolve(decision);
|
|
1342
|
+
this.#closeCards(pending.cards, decision, by);
|
|
1343
|
+
}
|
|
1344
|
+
async#closeCards(cards, decision, by) {
|
|
1345
|
+
const text = decision === "allowed-once" ? `✅ 已允许${by === "" ? "" : `(${by})`}` : decision === "rejected" ? `⛔ 已拒绝${by === "" ? "" : `(${by})`}` : "☑️ 已在其他端处理";
|
|
1346
|
+
for (const card of cards) {
|
|
1347
|
+
try {
|
|
1348
|
+
const channel = this.#channel(card.chat);
|
|
1349
|
+
await channel.edit(card.chat, card.messageId, project(channel, text), { keyboard: [], preview: false });
|
|
1350
|
+
} catch {}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async#onQuestion(request, next) {
|
|
1354
|
+
const lines = request.questions.map((question) => {
|
|
1355
|
+
const head = `❓ ${question.question}`;
|
|
1356
|
+
const options = question.options ?? [];
|
|
1357
|
+
if (options.length === 0)
|
|
1358
|
+
return head;
|
|
1359
|
+
return [head, ...options.map((option) => ` · ${option.label}${option.description === undefined ? "" : ` — ${option.description}`}`)].join(`
|
|
1360
|
+
`);
|
|
1361
|
+
});
|
|
1362
|
+
for (const mount of this.#mounts.values()) {
|
|
1363
|
+
await this.#say(mount.key, [...lines, "", "请在这台机器的 TUI 或 Desktop 上回答。"].join(`
|
|
1364
|
+
`));
|
|
1365
|
+
}
|
|
1366
|
+
return await next();
|
|
1367
|
+
}
|
|
1368
|
+
async#onCallback(callback) {
|
|
1369
|
+
if (callback.data.startsWith("cwd:")) {
|
|
1370
|
+
await this.#onDirectoryChoice(callback);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
const channel = this.#channel(callback.chat);
|
|
1374
|
+
const separator = callback.data.indexOf(":");
|
|
1375
|
+
const action = separator === -1 ? callback.data : callback.data.slice(0, separator);
|
|
1376
|
+
const key = separator === -1 ? "" : callback.data.slice(separator + 1);
|
|
1377
|
+
const pending = this.#pending.get(key);
|
|
1378
|
+
if (pending === undefined || pending.settled) {
|
|
1379
|
+
await channel.answer(callback, "这条审批已经处理过了").catch(() => {
|
|
1380
|
+
return;
|
|
1381
|
+
});
|
|
1382
|
+
await channel.edit(callback.chat, callback.messageId, project(channel, "☑️ 已处理"), { keyboard: [], preview: false }).catch(() => {
|
|
1383
|
+
return;
|
|
1384
|
+
});
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
await channel.answer(callback).catch(() => {
|
|
1388
|
+
return;
|
|
1389
|
+
});
|
|
1390
|
+
this.#settle(key, action === "approve" ? "allowed-once" : "rejected", "手机");
|
|
1391
|
+
}
|
|
1392
|
+
async#onDirectoryChoice(callback) {
|
|
1393
|
+
const channel = this.#channel(callback.chat);
|
|
1394
|
+
const choices = this.#cwdChoices.get(keyOf(callback.chat));
|
|
1395
|
+
const index = Number.parseInt(callback.data.slice("cwd:".length), 10);
|
|
1396
|
+
const path = choices === undefined ? undefined : choices[index];
|
|
1397
|
+
if (path === undefined) {
|
|
1398
|
+
await channel.answer(callback, "这个列表已过期,重新发 /cwd").catch(() => {
|
|
1399
|
+
return;
|
|
1400
|
+
});
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
await channel.answer(callback).catch(() => {
|
|
1404
|
+
return;
|
|
1405
|
+
});
|
|
1406
|
+
await this.#switchDirectory(callback.chat, path);
|
|
1407
|
+
await channel.edit(callback.chat, callback.messageId, project(channel, `\uD83D\uDCCD ${path}`), { keyboard: [], preview: false }).catch(() => {
|
|
1408
|
+
return;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// src/log.ts
|
|
1414
|
+
import { appendFile, mkdir as mkdir2 } from "node:fs/promises";
|
|
1415
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
1416
|
+
function redact(text, secrets) {
|
|
1417
|
+
let out = text;
|
|
1418
|
+
for (const secret of secrets) {
|
|
1419
|
+
if (secret !== "")
|
|
1420
|
+
out = out.split(secret).join("<secret>");
|
|
1421
|
+
}
|
|
1422
|
+
return out;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
class Log {
|
|
1426
|
+
#file;
|
|
1427
|
+
#secrets;
|
|
1428
|
+
#ready;
|
|
1429
|
+
constructor(dir, secrets) {
|
|
1430
|
+
this.#file = join3(dir, "logs", `bridge-${new Date().toISOString().slice(0, 10)}.log`);
|
|
1431
|
+
this.#secrets = secrets;
|
|
1432
|
+
}
|
|
1433
|
+
write(level, message) {
|
|
1434
|
+
const line = `${new Date().toISOString()} ${level} ${redact(message, this.#secrets)}`;
|
|
1435
|
+
if (level === "error")
|
|
1436
|
+
console.error(line);
|
|
1437
|
+
else
|
|
1438
|
+
console.log(line);
|
|
1439
|
+
this.#ready ??= mkdir2(dirname2(this.#file), { recursive: true, mode: 448 }).then(() => {
|
|
1440
|
+
return;
|
|
1441
|
+
});
|
|
1442
|
+
this.#ready.then(() => appendFile(this.#file, `${line}
|
|
1443
|
+
`, { mode: 384 })).catch(() => {
|
|
1444
|
+
return;
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
info(message) {
|
|
1448
|
+
this.write("info", message);
|
|
1449
|
+
}
|
|
1450
|
+
error(message) {
|
|
1451
|
+
this.write("error", message);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// src/lark/api.ts
|
|
1456
|
+
class LarkError extends Error {
|
|
1457
|
+
method;
|
|
1458
|
+
code;
|
|
1459
|
+
status;
|
|
1460
|
+
constructor(method, message, code, status) {
|
|
1461
|
+
super(`${method}: ${message} (${String(code)})`);
|
|
1462
|
+
this.name = "LarkError";
|
|
1463
|
+
this.method = method;
|
|
1464
|
+
this.code = code;
|
|
1465
|
+
this.status = status;
|
|
1466
|
+
}
|
|
1467
|
+
get isTokenInvalid() {
|
|
1468
|
+
return this.code === 99991661 || this.code === 99991663;
|
|
1469
|
+
}
|
|
1470
|
+
get isThrottled() {
|
|
1471
|
+
return this.status === 429 || this.code === 99991400;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
class LarkApi {
|
|
1476
|
+
#appId;
|
|
1477
|
+
#appSecret;
|
|
1478
|
+
#baseUrl;
|
|
1479
|
+
#fetch;
|
|
1480
|
+
#token;
|
|
1481
|
+
constructor(options) {
|
|
1482
|
+
this.#appId = options.appId;
|
|
1483
|
+
this.#appSecret = options.appSecret;
|
|
1484
|
+
this.#baseUrl = options.baseUrl;
|
|
1485
|
+
this.#fetch = options.fetch ?? (options.proxy === undefined ? globalThis.fetch : createProxiedFetch(options.proxy));
|
|
1486
|
+
}
|
|
1487
|
+
async#accessToken() {
|
|
1488
|
+
const cached = this.#token;
|
|
1489
|
+
if (cached !== undefined && cached.expiresAt > Date.now())
|
|
1490
|
+
return cached.value;
|
|
1491
|
+
const envelope = await this.#request("auth/v3/tenant_access_token/internal", {
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
body: { app_id: this.#appId, app_secret: this.#appSecret },
|
|
1494
|
+
authenticated: false
|
|
1495
|
+
});
|
|
1496
|
+
const value = envelope.tenant_access_token ?? "";
|
|
1497
|
+
if (value === "")
|
|
1498
|
+
throw new Error("Lark returned no tenant access token");
|
|
1499
|
+
const lifetime = Number(envelope.expire ?? 7200);
|
|
1500
|
+
this.#token = { value, expiresAt: Date.now() + Math.max(60, lifetime - 120) * 1000 };
|
|
1501
|
+
return value;
|
|
1502
|
+
}
|
|
1503
|
+
async#request(path, options, retried = false) {
|
|
1504
|
+
const token = options.authenticated === false ? undefined : await this.#accessToken();
|
|
1505
|
+
const response = await this.#fetch(`${this.#baseUrl}/${path}${options.query === undefined ? "" : `?${options.query}`}`, {
|
|
1506
|
+
method: options.method,
|
|
1507
|
+
headers: {
|
|
1508
|
+
"content-type": "application/json",
|
|
1509
|
+
...token === undefined ? {} : { authorization: `Bearer ${token}` }
|
|
1510
|
+
},
|
|
1511
|
+
...options.body === undefined ? {} : { body: JSON.stringify(options.body) },
|
|
1512
|
+
signal: AbortSignal.timeout(30000)
|
|
1513
|
+
});
|
|
1514
|
+
const text = await response.text();
|
|
1515
|
+
let envelope;
|
|
1516
|
+
try {
|
|
1517
|
+
envelope = JSON.parse(text);
|
|
1518
|
+
} catch {
|
|
1519
|
+
throw new LarkError(path, `unreadable response (${String(response.status)})`, 0, response.status);
|
|
1520
|
+
}
|
|
1521
|
+
if (envelope.code !== 0) {
|
|
1522
|
+
const error = new LarkError(path, envelope.msg, envelope.code, response.status);
|
|
1523
|
+
if (error.isTokenInvalid && !retried) {
|
|
1524
|
+
this.#token = undefined;
|
|
1525
|
+
return await this.#request(path, options, true);
|
|
1526
|
+
}
|
|
1527
|
+
throw error;
|
|
1528
|
+
}
|
|
1529
|
+
return envelope;
|
|
1530
|
+
}
|
|
1531
|
+
async sendCard(chatId, content) {
|
|
1532
|
+
const envelope = await this.#request("im/v1/messages", {
|
|
1533
|
+
method: "POST",
|
|
1534
|
+
query: "receive_id_type=chat_id",
|
|
1535
|
+
body: { receive_id: chatId, msg_type: "interactive", content }
|
|
1536
|
+
});
|
|
1537
|
+
const messageId = envelope.data?.message_id;
|
|
1538
|
+
if (messageId === undefined)
|
|
1539
|
+
throw new Error("Lark returned no message id");
|
|
1540
|
+
return messageId;
|
|
1541
|
+
}
|
|
1542
|
+
async sendText(chatId, text) {
|
|
1543
|
+
const envelope = await this.#request("im/v1/messages", {
|
|
1544
|
+
method: "POST",
|
|
1545
|
+
query: "receive_id_type=chat_id",
|
|
1546
|
+
body: { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text }) }
|
|
1547
|
+
});
|
|
1548
|
+
const messageId = envelope.data?.message_id;
|
|
1549
|
+
if (messageId === undefined)
|
|
1550
|
+
throw new Error("Lark returned no message id");
|
|
1551
|
+
return messageId;
|
|
1552
|
+
}
|
|
1553
|
+
async patchCard(messageId, content) {
|
|
1554
|
+
await this.#request(`im/v1/messages/${encodeURIComponent(messageId)}`, {
|
|
1555
|
+
method: "PATCH",
|
|
1556
|
+
body: { content }
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
async deleteMessage(messageId) {
|
|
1560
|
+
await this.#request(`im/v1/messages/${encodeURIComponent(messageId)}`, { method: "DELETE" });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/lark/card.ts
|
|
1565
|
+
function buildCard(text, options = {}) {
|
|
1566
|
+
const elements = [{ tag: "markdown", content: text }];
|
|
1567
|
+
const buttons = options.buttons ?? [];
|
|
1568
|
+
if (buttons.length > 0) {
|
|
1569
|
+
elements.push({
|
|
1570
|
+
tag: "action",
|
|
1571
|
+
actions: buttons.flat().map((button) => ({
|
|
1572
|
+
tag: "button",
|
|
1573
|
+
text: { tag: "plain_text", content: button.text },
|
|
1574
|
+
type: "default",
|
|
1575
|
+
value: { data: button.data }
|
|
1576
|
+
}))
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
1581
|
+
...options.title === undefined ? {} : { header: { title: { tag: "plain_text", content: options.title } } },
|
|
1582
|
+
elements
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
function stripMentions(text) {
|
|
1586
|
+
return text.replace(/@_user_\d+/g, "").replace(/[ \t]+/g, " ").trim();
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// src/lark/channel.ts
|
|
1590
|
+
class LarkChannel {
|
|
1591
|
+
name = "lark";
|
|
1592
|
+
maxTextLength = 4000;
|
|
1593
|
+
pinnedStatus = false;
|
|
1594
|
+
#api;
|
|
1595
|
+
#transport;
|
|
1596
|
+
#config;
|
|
1597
|
+
#ledger;
|
|
1598
|
+
#log;
|
|
1599
|
+
#toasts = new Map;
|
|
1600
|
+
constructor(options) {
|
|
1601
|
+
this.#api = options.api;
|
|
1602
|
+
this.#transport = options.transport;
|
|
1603
|
+
this.#config = options.config;
|
|
1604
|
+
this.#ledger = options.ledger;
|
|
1605
|
+
this.#log = options.log;
|
|
1606
|
+
}
|
|
1607
|
+
format(text) {
|
|
1608
|
+
return text;
|
|
1609
|
+
}
|
|
1610
|
+
#allowed(chatId, userId) {
|
|
1611
|
+
const { allowedChats, allowedUsers } = this.#config;
|
|
1612
|
+
if (allowedChats.length === 0 && allowedUsers.length === 0)
|
|
1613
|
+
return false;
|
|
1614
|
+
if (allowedChats.length > 0 && allowedChats.includes(chatId))
|
|
1615
|
+
return true;
|
|
1616
|
+
if (allowedUsers.length > 0 && userId !== undefined && allowedUsers.includes(userId))
|
|
1617
|
+
return true;
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
#card(text, options) {
|
|
1621
|
+
return JSON.stringify(buildCard(text, { buttons: options.keyboard }));
|
|
1622
|
+
}
|
|
1623
|
+
async send(chat, body, options = {}) {
|
|
1624
|
+
let first;
|
|
1625
|
+
for (const part of body.parts) {
|
|
1626
|
+
const messageId = await this.#api.sendCard(chat.chatId, this.#card(part, options));
|
|
1627
|
+
first ??= messageId;
|
|
1628
|
+
}
|
|
1629
|
+
return first;
|
|
1630
|
+
}
|
|
1631
|
+
async edit(chat, messageId, body, options = {}) {
|
|
1632
|
+
await this.#api.patchCard(messageId, this.#card(body.parts[0] ?? body.text, options));
|
|
1633
|
+
}
|
|
1634
|
+
async delete(chat, messageId) {
|
|
1635
|
+
await this.#api.deleteMessage(messageId);
|
|
1636
|
+
this.#toasts.delete(messageId);
|
|
1637
|
+
}
|
|
1638
|
+
async answer(event, text) {
|
|
1639
|
+
if (text !== undefined && text !== "")
|
|
1640
|
+
this.#toasts.set(event.messageId, text);
|
|
1641
|
+
}
|
|
1642
|
+
retryAfterMs(error) {
|
|
1643
|
+
return error instanceof LarkError && error.isThrottled ? 1000 : undefined;
|
|
1644
|
+
}
|
|
1645
|
+
isMissing(error) {
|
|
1646
|
+
return error instanceof LarkError && /not found|not exist|已撤回|no message/i.test(error.message);
|
|
1647
|
+
}
|
|
1648
|
+
async start(onEvent, signal) {
|
|
1649
|
+
await this.#transport.start({
|
|
1650
|
+
message: (event) => {
|
|
1651
|
+
this.#onMessage(event, onEvent);
|
|
1652
|
+
},
|
|
1653
|
+
action: (event) => this.#onAction(event, onEvent)
|
|
1654
|
+
});
|
|
1655
|
+
await new Promise((resolve3) => {
|
|
1656
|
+
if (signal.aborted) {
|
|
1657
|
+
resolve3();
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
signal.addEventListener("abort", () => {
|
|
1661
|
+
resolve3();
|
|
1662
|
+
}, { once: true });
|
|
1663
|
+
});
|
|
1664
|
+
this.#transport.close();
|
|
1665
|
+
}
|
|
1666
|
+
async#onMessage(event, onEvent) {
|
|
1667
|
+
try {
|
|
1668
|
+
const message = event.message;
|
|
1669
|
+
if (message === undefined)
|
|
1670
|
+
return;
|
|
1671
|
+
const senderType = event.sender?.sender_type ?? "";
|
|
1672
|
+
if (senderType !== "user")
|
|
1673
|
+
return;
|
|
1674
|
+
const userId = event.sender?.sender_id?.open_id;
|
|
1675
|
+
if (userId === undefined)
|
|
1676
|
+
return;
|
|
1677
|
+
const chatId = message.chat_id;
|
|
1678
|
+
if (!this.#allowed(chatId, userId)) {
|
|
1679
|
+
this.#log.info(`ignored chat=${chatId} from=${userId} (not in allowlist)`);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const eventId = event.event_id ?? message.message_id;
|
|
1683
|
+
if (this.#ledger.seen(eventId))
|
|
1684
|
+
return;
|
|
1685
|
+
await this.#ledger.flush().catch(() => {
|
|
1686
|
+
return;
|
|
1687
|
+
});
|
|
1688
|
+
if (message.message_type !== "text") {
|
|
1689
|
+
this.#log.info(`ignored ${message.message_type} message in ${chatId}`);
|
|
1690
|
+
await this.#api.sendText(chatId, "只支持文字消息。").catch(() => {
|
|
1691
|
+
return;
|
|
1692
|
+
});
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const text = stripMentions(textOf(message.content));
|
|
1696
|
+
if (text === "")
|
|
1697
|
+
return;
|
|
1698
|
+
this.#log.info(`message chat=${chatId} from=${userId} len=${String(text.length)}`);
|
|
1699
|
+
await onEvent({
|
|
1700
|
+
kind: "message",
|
|
1701
|
+
chat: { channel: "lark", chatId },
|
|
1702
|
+
userId,
|
|
1703
|
+
text,
|
|
1704
|
+
fromBot: false,
|
|
1705
|
+
messageId: message.message_id
|
|
1706
|
+
});
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
this.#log.error(`lark message failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
async#onAction(event, onEvent) {
|
|
1712
|
+
const chatId = event.context?.open_chat_id ?? event.open_chat_id;
|
|
1713
|
+
const messageId = event.context?.open_message_id ?? event.open_message_id;
|
|
1714
|
+
const userId = event.operator?.open_id;
|
|
1715
|
+
const data = actionData(event);
|
|
1716
|
+
if (chatId === undefined || messageId === undefined || userId === undefined || data === undefined)
|
|
1717
|
+
return {};
|
|
1718
|
+
if (!this.#allowed(chatId, userId))
|
|
1719
|
+
return { toast: { type: "error", content: "没有权限" } };
|
|
1720
|
+
try {
|
|
1721
|
+
await onEvent({
|
|
1722
|
+
kind: "callback",
|
|
1723
|
+
chat: { channel: "lark", chatId },
|
|
1724
|
+
userId,
|
|
1725
|
+
callbackId: messageId,
|
|
1726
|
+
messageId,
|
|
1727
|
+
data
|
|
1728
|
+
});
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
this.#log.error(`callback failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1731
|
+
}
|
|
1732
|
+
const toast = this.#toasts.get(messageId);
|
|
1733
|
+
this.#toasts.delete(messageId);
|
|
1734
|
+
return toast === undefined ? {} : { toast: { type: "success", content: toast } };
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
function textOf(content) {
|
|
1738
|
+
if (content === undefined)
|
|
1739
|
+
return "";
|
|
1740
|
+
try {
|
|
1741
|
+
const parsed = JSON.parse(content);
|
|
1742
|
+
return typeof parsed.text === "string" ? parsed.text : "";
|
|
1743
|
+
} catch {
|
|
1744
|
+
return "";
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
function actionData(event) {
|
|
1748
|
+
const value = event.action?.value;
|
|
1749
|
+
if (value === undefined || value === null)
|
|
1750
|
+
return;
|
|
1751
|
+
if (typeof value === "string")
|
|
1752
|
+
return value;
|
|
1753
|
+
if (typeof value === "object" && "data" in value) {
|
|
1754
|
+
const data = value.data;
|
|
1755
|
+
return typeof data === "string" ? data : undefined;
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/lark/ws.ts
|
|
1761
|
+
function createSdkTransport(options) {
|
|
1762
|
+
let client;
|
|
1763
|
+
return {
|
|
1764
|
+
async start(handlers) {
|
|
1765
|
+
const sdk = await import("@larksuiteoapi/node-sdk");
|
|
1766
|
+
const dispatcher = new sdk.EventDispatcher({});
|
|
1767
|
+
dispatcher.register({
|
|
1768
|
+
"im.message.receive_v1": (event) => {
|
|
1769
|
+
handlers.message(event);
|
|
1770
|
+
},
|
|
1771
|
+
"card.action.trigger": async (event) => await handlers.action(event)
|
|
1772
|
+
});
|
|
1773
|
+
const ws = new sdk.WSClient({
|
|
1774
|
+
appId: options.appId,
|
|
1775
|
+
appSecret: options.appSecret,
|
|
1776
|
+
domain: options.domain === "lark" ? sdk.Domain.Lark : sdk.Domain.Feishu,
|
|
1777
|
+
loggerLevel: sdk.LoggerLevel.warn,
|
|
1778
|
+
...options.onError === undefined ? {} : { onError: options.onError }
|
|
1779
|
+
});
|
|
1780
|
+
client = ws;
|
|
1781
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
1782
|
+
},
|
|
1783
|
+
close() {
|
|
1784
|
+
client?.close({ force: true });
|
|
1785
|
+
client = undefined;
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// src/store/ledger.ts
|
|
1791
|
+
import { mkdir as mkdir3, readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1792
|
+
import { dirname as dirname3 } from "node:path";
|
|
1793
|
+
|
|
1794
|
+
class UpdateLedger {
|
|
1795
|
+
#path;
|
|
1796
|
+
#ttlMs;
|
|
1797
|
+
#capacity;
|
|
1798
|
+
#entries = [];
|
|
1799
|
+
#seen = new Set;
|
|
1800
|
+
#dirty = false;
|
|
1801
|
+
#flushing;
|
|
1802
|
+
constructor(path, options = {}) {
|
|
1803
|
+
this.#path = path;
|
|
1804
|
+
this.#ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
|
|
1805
|
+
this.#capacity = options.capacity ?? 4096;
|
|
1806
|
+
}
|
|
1807
|
+
static async open(path, options = {}) {
|
|
1808
|
+
const ledger = new UpdateLedger(path, options);
|
|
1809
|
+
try {
|
|
1810
|
+
const parsed = JSON.parse(await readFile3(path, "utf8"));
|
|
1811
|
+
ledger.#entries = (parsed.entries ?? []).filter((entry) => typeof entry.id === "string" && Number.isSafeInteger(entry.at));
|
|
1812
|
+
} catch {}
|
|
1813
|
+
ledger.#prune(Date.now());
|
|
1814
|
+
ledger.#seen = new Set(ledger.#entries.map((entry) => entry.id));
|
|
1815
|
+
return ledger;
|
|
1816
|
+
}
|
|
1817
|
+
#prune(now) {
|
|
1818
|
+
const fresh = this.#entries.filter((entry) => now - entry.at < this.#ttlMs);
|
|
1819
|
+
fresh.sort((left, right) => left.at - right.at);
|
|
1820
|
+
this.#entries = fresh.slice(-this.#capacity);
|
|
1821
|
+
}
|
|
1822
|
+
seen(id) {
|
|
1823
|
+
const key = String(id);
|
|
1824
|
+
if (this.#seen.has(key))
|
|
1825
|
+
return true;
|
|
1826
|
+
this.#seen.add(key);
|
|
1827
|
+
this.#entries.push({ id: key, at: Date.now() });
|
|
1828
|
+
this.#dirty = true;
|
|
1829
|
+
this.#prune(Date.now());
|
|
1830
|
+
if (this.#entries.length > this.#capacity) {
|
|
1831
|
+
this.#seen = new Set(this.#entries.map((entry) => entry.id));
|
|
1832
|
+
}
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
get cursor() {
|
|
1836
|
+
let highest = 0;
|
|
1837
|
+
for (const entry of this.#entries) {
|
|
1838
|
+
const value = Number(entry.id);
|
|
1839
|
+
if (Number.isSafeInteger(value))
|
|
1840
|
+
highest = Math.max(highest, value);
|
|
1841
|
+
}
|
|
1842
|
+
return highest;
|
|
1843
|
+
}
|
|
1844
|
+
async flush() {
|
|
1845
|
+
if (!this.#dirty)
|
|
1846
|
+
return this.#flushing ?? Promise.resolve();
|
|
1847
|
+
if (this.#flushing !== undefined)
|
|
1848
|
+
return this.#flushing;
|
|
1849
|
+
this.#dirty = false;
|
|
1850
|
+
this.#flushing = (async () => {
|
|
1851
|
+
const temporary = `${this.#path}.${String(process.pid)}.tmp`;
|
|
1852
|
+
await mkdir3(dirname3(this.#path), { recursive: true, mode: 448 });
|
|
1853
|
+
await writeFile2(temporary, JSON.stringify({ entries: this.#entries }), { mode: 384 });
|
|
1854
|
+
await rename2(temporary, this.#path);
|
|
1855
|
+
})().finally(() => {
|
|
1856
|
+
this.#flushing = undefined;
|
|
1857
|
+
});
|
|
1858
|
+
return this.#flushing;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/store/lock.ts
|
|
1863
|
+
import { createHash } from "node:crypto";
|
|
1864
|
+
import { mkdir as mkdir4, readFile as readFile4, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
1865
|
+
import { homedir as homedir2 } from "node:os";
|
|
1866
|
+
import { join as join4 } from "node:path";
|
|
1867
|
+
function lockDirectory(env = process.env) {
|
|
1868
|
+
const override = env.HULALA_BOT_LOCK_DIR;
|
|
1869
|
+
if (override !== undefined && override !== "")
|
|
1870
|
+
return override;
|
|
1871
|
+
return join4(homedir2(), ".hulala-bot");
|
|
1872
|
+
}
|
|
1873
|
+
function lockPath(identity, env = process.env) {
|
|
1874
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
1875
|
+
return join4(lockDirectory(env), `${digest}.lock`);
|
|
1876
|
+
}
|
|
1877
|
+
var held = new Set;
|
|
1878
|
+
async function acquireLock(identity, env = process.env) {
|
|
1879
|
+
const path = lockPath(identity, env);
|
|
1880
|
+
if (held.has(path))
|
|
1881
|
+
throw new Error(`this bot is already running in another instance here (${path})`);
|
|
1882
|
+
await mkdir4(lockDirectory(env), { recursive: true, mode: 448 });
|
|
1883
|
+
let owner;
|
|
1884
|
+
try {
|
|
1885
|
+
const parsed = Number.parseInt((await readFile4(path, "utf8")).trim(), 10);
|
|
1886
|
+
if (Number.isSafeInteger(parsed) && parsed > 0)
|
|
1887
|
+
owner = parsed;
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
if (error.code !== "ENOENT")
|
|
1890
|
+
throw error;
|
|
1891
|
+
}
|
|
1892
|
+
if (owner !== undefined && owner !== process.pid) {
|
|
1893
|
+
let alive = false;
|
|
1894
|
+
try {
|
|
1895
|
+
process.kill(owner, 0);
|
|
1896
|
+
alive = true;
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
alive = error.code === "EPERM";
|
|
1899
|
+
}
|
|
1900
|
+
if (alive) {
|
|
1901
|
+
throw new Error(`this bot is already connected from another Host (pid ${String(owner)}); stop that process or remove ${path} if it is stale`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
await writeFile3(path, `${String(process.pid)}
|
|
1905
|
+
`, { mode: 384 });
|
|
1906
|
+
held.add(path);
|
|
1907
|
+
return async () => {
|
|
1908
|
+
if (!held.delete(path))
|
|
1909
|
+
return;
|
|
1910
|
+
await rm(path, { force: true });
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// src/telegram/api.ts
|
|
1915
|
+
class TelegramError extends Error {
|
|
1916
|
+
method;
|
|
1917
|
+
description;
|
|
1918
|
+
code;
|
|
1919
|
+
retryAfter;
|
|
1920
|
+
constructor(method, description, code, retryAfter) {
|
|
1921
|
+
super(`${method}: ${description} (${String(code)})`);
|
|
1922
|
+
this.name = "TelegramError";
|
|
1923
|
+
this.method = method;
|
|
1924
|
+
this.description = description;
|
|
1925
|
+
this.code = code;
|
|
1926
|
+
this.retryAfter = retryAfter;
|
|
1927
|
+
}
|
|
1928
|
+
get isFlood() {
|
|
1929
|
+
return this.code === 429 || this.retryAfter !== undefined;
|
|
1930
|
+
}
|
|
1931
|
+
get isBadRequest() {
|
|
1932
|
+
return this.code === 400;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
class TelegramApi {
|
|
1937
|
+
#token;
|
|
1938
|
+
#baseUrl;
|
|
1939
|
+
#fetch;
|
|
1940
|
+
constructor(options) {
|
|
1941
|
+
this.#token = options.token;
|
|
1942
|
+
this.#baseUrl = options.baseUrl;
|
|
1943
|
+
this.#fetch = options.fetch ?? (options.proxy === undefined ? globalThis.fetch : createProxiedFetch(options.proxy));
|
|
1944
|
+
}
|
|
1945
|
+
async call(method, params = {}, signal) {
|
|
1946
|
+
const response = await this.#fetch(`${this.#baseUrl}/bot${this.#token}/${method}`, {
|
|
1947
|
+
method: "POST",
|
|
1948
|
+
headers: { "content-type": "application/json" },
|
|
1949
|
+
body: JSON.stringify(params),
|
|
1950
|
+
signal: signal === undefined ? AbortSignal.timeout(40000) : AbortSignal.any([signal, AbortSignal.timeout(40000)])
|
|
1951
|
+
});
|
|
1952
|
+
const body = await response.json();
|
|
1953
|
+
if (!body.ok) {
|
|
1954
|
+
throw new TelegramError(method, body.description ?? "request failed", response.status, body.parameters?.retry_after);
|
|
1955
|
+
}
|
|
1956
|
+
return body.result;
|
|
1957
|
+
}
|
|
1958
|
+
static decorate(params, options) {
|
|
1959
|
+
if (options.parseMode !== undefined)
|
|
1960
|
+
params.parse_mode = options.parseMode;
|
|
1961
|
+
if (options.disablePreview === true) {
|
|
1962
|
+
params.link_preview_options = { is_disabled: true };
|
|
1963
|
+
}
|
|
1964
|
+
if (options.threadId !== undefined)
|
|
1965
|
+
params.message_thread_id = options.threadId;
|
|
1966
|
+
if (options.keyboard !== undefined)
|
|
1967
|
+
params.reply_markup = { inline_keyboard: options.keyboard };
|
|
1968
|
+
return params;
|
|
1969
|
+
}
|
|
1970
|
+
async sendMessage(chatId, text, options = {}) {
|
|
1971
|
+
const result = await this.call("sendMessage", TelegramApi.decorate({ chat_id: chatId, text }, options));
|
|
1972
|
+
return result.message_id;
|
|
1973
|
+
}
|
|
1974
|
+
async editMessageText(chatId, messageId, text, options = {}) {
|
|
1975
|
+
await this.call("editMessageText", TelegramApi.decorate({ chat_id: chatId, message_id: messageId, text }, options));
|
|
1976
|
+
}
|
|
1977
|
+
async deleteMessage(chatId, messageId) {
|
|
1978
|
+
await this.call("deleteMessage", { chat_id: chatId, message_id: messageId });
|
|
1979
|
+
}
|
|
1980
|
+
async answerCallbackQuery(id, text) {
|
|
1981
|
+
await this.call("answerCallbackQuery", text === undefined ? { callback_query_id: id } : { callback_query_id: id, text });
|
|
1982
|
+
}
|
|
1983
|
+
async pinChatMessage(chatId, messageId) {
|
|
1984
|
+
await this.call("pinChatMessage", { chat_id: chatId, message_id: messageId, disable_notification: true });
|
|
1985
|
+
}
|
|
1986
|
+
async unpinChatMessage(chatId, messageId) {
|
|
1987
|
+
await this.call("unpinChatMessage", { chat_id: chatId, message_id: messageId });
|
|
1988
|
+
}
|
|
1989
|
+
async sendChatAction(chatId, threadId) {
|
|
1990
|
+
await this.call("sendChatAction", {
|
|
1991
|
+
chat_id: chatId,
|
|
1992
|
+
action: "typing",
|
|
1993
|
+
...threadId === undefined ? {} : { message_thread_id: threadId }
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
async setMyCommands(commands) {
|
|
1997
|
+
await this.call("setMyCommands", { commands });
|
|
1998
|
+
}
|
|
1999
|
+
async getUpdates(offset, timeoutSeconds, signal) {
|
|
2000
|
+
const raw = await this.call("getUpdates", {
|
|
2001
|
+
offset,
|
|
2002
|
+
timeout: timeoutSeconds,
|
|
2003
|
+
allowed_updates: ["message", "callback_query"]
|
|
2004
|
+
}, signal);
|
|
2005
|
+
const updates = [];
|
|
2006
|
+
for (const update of raw) {
|
|
2007
|
+
const message = update.message;
|
|
2008
|
+
if (message !== undefined && typeof message.text === "string") {
|
|
2009
|
+
updates.push({
|
|
2010
|
+
kind: "message",
|
|
2011
|
+
updateId: update.update_id,
|
|
2012
|
+
message: {
|
|
2013
|
+
messageId: message.message_id,
|
|
2014
|
+
chat: message.chat,
|
|
2015
|
+
fromId: message.from?.id,
|
|
2016
|
+
text: message.text,
|
|
2017
|
+
threadId: message.is_topic_message === true ? message.message_thread_id : undefined,
|
|
2018
|
+
isBot: message.from?.is_bot === true
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
const callback = update.callback_query;
|
|
2024
|
+
if (callback?.message !== undefined && typeof callback.data === "string") {
|
|
2025
|
+
updates.push({
|
|
2026
|
+
kind: "callback",
|
|
2027
|
+
updateId: update.update_id,
|
|
2028
|
+
callback: {
|
|
2029
|
+
id: callback.id,
|
|
2030
|
+
chatId: callback.message.chat.id,
|
|
2031
|
+
messageId: callback.message.message_id,
|
|
2032
|
+
data: callback.data,
|
|
2033
|
+
fromId: callback.from?.id,
|
|
2034
|
+
threadId: callback.message.is_topic_message === true ? callback.message.message_thread_id : undefined
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return updates;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/render/markdown.ts
|
|
2044
|
+
var RESERVED = new Set("_*[]()~`>#+-=|{}.!\\".split(""));
|
|
2045
|
+
function escapeMarkdownV2(text) {
|
|
2046
|
+
let out = "";
|
|
2047
|
+
for (const character of text)
|
|
2048
|
+
out += RESERVED.has(character) ? `\\${character}` : character;
|
|
2049
|
+
return out;
|
|
2050
|
+
}
|
|
2051
|
+
function escapeCode(text) {
|
|
2052
|
+
return text.replace(/[`\\]/g, (character) => `\\${character}`);
|
|
2053
|
+
}
|
|
2054
|
+
function splitFences(text) {
|
|
2055
|
+
const regions = [];
|
|
2056
|
+
let cursor = 0;
|
|
2057
|
+
while (cursor < text.length) {
|
|
2058
|
+
const open = text.indexOf("```", cursor);
|
|
2059
|
+
if (open === -1)
|
|
2060
|
+
break;
|
|
2061
|
+
if (open > cursor)
|
|
2062
|
+
regions.push({ kind: "text", text: text.slice(cursor, open) });
|
|
2063
|
+
const lineEnd = text.indexOf(`
|
|
2064
|
+
`, open);
|
|
2065
|
+
const language = lineEnd === -1 ? "" : text.slice(open + 3, lineEnd).trim();
|
|
2066
|
+
const bodyStart = lineEnd === -1 ? open + 3 : lineEnd + 1;
|
|
2067
|
+
const close = text.indexOf("```", bodyStart);
|
|
2068
|
+
if (close === -1) {
|
|
2069
|
+
regions.push({ kind: "code", language, text: text.slice(bodyStart) });
|
|
2070
|
+
return regions;
|
|
2071
|
+
}
|
|
2072
|
+
regions.push({ kind: "code", language, text: text.slice(bodyStart, close) });
|
|
2073
|
+
cursor = close + 3;
|
|
2074
|
+
}
|
|
2075
|
+
if (cursor < text.length)
|
|
2076
|
+
regions.push({ kind: "text", text: text.slice(cursor) });
|
|
2077
|
+
return regions;
|
|
2078
|
+
}
|
|
2079
|
+
function toMarkdownV2(text) {
|
|
2080
|
+
return splitFences(text).map((region) => region.kind === "text" ? escapeMarkdownV2(region.text) : `\`\`\`${region.language}
|
|
2081
|
+
${escapeCode(region.text)}\`\`\``).join("");
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// src/telegram/channel.ts
|
|
2085
|
+
function numeric(value, label) {
|
|
2086
|
+
const parsed = Number(value);
|
|
2087
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0)
|
|
2088
|
+
throw new Error(`not a Telegram ${label}: ${value}`);
|
|
2089
|
+
return parsed;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
class TelegramChannel {
|
|
2093
|
+
name = "telegram";
|
|
2094
|
+
maxTextLength = 4000;
|
|
2095
|
+
pinnedStatus = true;
|
|
2096
|
+
#api;
|
|
2097
|
+
#config;
|
|
2098
|
+
#ledger;
|
|
2099
|
+
#log;
|
|
2100
|
+
#commands;
|
|
2101
|
+
#plain;
|
|
2102
|
+
constructor(options) {
|
|
2103
|
+
this.#api = options.api;
|
|
2104
|
+
this.#config = options.config;
|
|
2105
|
+
this.#ledger = options.ledger;
|
|
2106
|
+
this.#log = options.log;
|
|
2107
|
+
this.#commands = options.commands;
|
|
2108
|
+
this.#plain = options.config.parseMode === "plain";
|
|
2109
|
+
}
|
|
2110
|
+
format(text) {
|
|
2111
|
+
return this.#plain ? text : toMarkdownV2(text);
|
|
2112
|
+
}
|
|
2113
|
+
#allowed(chatId, userId) {
|
|
2114
|
+
const { allowedChats, allowedUsers } = this.#config;
|
|
2115
|
+
if (allowedChats.length === 0 && allowedUsers.length === 0)
|
|
2116
|
+
return false;
|
|
2117
|
+
if (allowedChats.length > 0 && allowedChats.includes(chatId))
|
|
2118
|
+
return true;
|
|
2119
|
+
if (allowedUsers.length > 0 && userId !== undefined && allowedUsers.includes(userId))
|
|
2120
|
+
return true;
|
|
2121
|
+
return false;
|
|
2122
|
+
}
|
|
2123
|
+
#key(chatId, threadId) {
|
|
2124
|
+
return {
|
|
2125
|
+
channel: "telegram",
|
|
2126
|
+
chatId: String(chatId),
|
|
2127
|
+
...threadId === undefined ? {} : { threadId: String(threadId) }
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
#transport(chat, options) {
|
|
2131
|
+
return {
|
|
2132
|
+
...this.#plain ? {} : { parseMode: "MarkdownV2" },
|
|
2133
|
+
...options.keyboard === undefined ? {} : { keyboard: this.#keyboard(options.keyboard) },
|
|
2134
|
+
...options.preview === false ? { disablePreview: true } : {},
|
|
2135
|
+
...chat.threadId === undefined ? {} : { threadId: numeric(chat.threadId, "thread id") }
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
#keyboard(keyboard) {
|
|
2139
|
+
return keyboard.map((row) => row.map((button) => ({ text: button.text, callback_data: button.data })));
|
|
2140
|
+
}
|
|
2141
|
+
async send(chat, body, options = {}) {
|
|
2142
|
+
const chatId = numeric(chat.chatId, "chat id");
|
|
2143
|
+
let parts = body.parts;
|
|
2144
|
+
let first;
|
|
2145
|
+
let index = 0;
|
|
2146
|
+
while (index < parts.length) {
|
|
2147
|
+
try {
|
|
2148
|
+
const messageId = await this.#api.sendMessage(chatId, parts[index], this.#transport(chat, options));
|
|
2149
|
+
first ??= messageId;
|
|
2150
|
+
index += 1;
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
if (this.#plain || !(error instanceof TelegramError) || !error.isBadRequest)
|
|
2153
|
+
throw error;
|
|
2154
|
+
this.#log.error(`markdown rejected, retrying plain: ${error.description}`);
|
|
2155
|
+
this.#plain = true;
|
|
2156
|
+
parts = splitMessage(body.raw, this.maxTextLength);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
return first === undefined ? undefined : String(first);
|
|
2160
|
+
}
|
|
2161
|
+
async edit(chat, messageId, body, options = {}) {
|
|
2162
|
+
const chatId = numeric(chat.chatId, "chat id");
|
|
2163
|
+
const id = numeric(messageId, "message id");
|
|
2164
|
+
const attempt = async (text) => {
|
|
2165
|
+
await this.#api.editMessageText(chatId, id, text, this.#transport(chat, options));
|
|
2166
|
+
};
|
|
2167
|
+
try {
|
|
2168
|
+
await attempt(this.#plain ? splitMessage(body.raw, this.maxTextLength)[0] ?? body.raw : body.parts[0] ?? body.text);
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
if (error instanceof TelegramError && error.isBadRequest && !this.#plain) {
|
|
2171
|
+
this.#plain = true;
|
|
2172
|
+
try {
|
|
2173
|
+
await attempt(splitMessage(body.raw, this.maxTextLength)[0] ?? body.raw);
|
|
2174
|
+
return;
|
|
2175
|
+
} catch (retry) {
|
|
2176
|
+
if (isNotModified(retry))
|
|
2177
|
+
return;
|
|
2178
|
+
throw retry;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
if (isNotModified(error))
|
|
2182
|
+
return;
|
|
2183
|
+
throw error;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
async delete(chat, messageId) {
|
|
2187
|
+
try {
|
|
2188
|
+
await this.#api.deleteMessage(numeric(chat.chatId, "chat id"), numeric(messageId, "message id"));
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
if (!isMissing(error))
|
|
2191
|
+
throw error;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
async answer(event, text) {
|
|
2195
|
+
await this.#api.answerCallbackQuery(event.callbackId, text);
|
|
2196
|
+
}
|
|
2197
|
+
async pin(chat, messageId) {
|
|
2198
|
+
await this.#api.pinChatMessage(numeric(chat.chatId, "chat id"), numeric(messageId, "message id"));
|
|
2199
|
+
}
|
|
2200
|
+
async unpin(chat, messageId) {
|
|
2201
|
+
await this.#api.unpinChatMessage(numeric(chat.chatId, "chat id"), numeric(messageId, "message id"));
|
|
2202
|
+
}
|
|
2203
|
+
async typing(chat) {
|
|
2204
|
+
await this.#api.sendChatAction(numeric(chat.chatId, "chat id"), chat.threadId === undefined ? undefined : numeric(chat.threadId, "thread id"));
|
|
2205
|
+
}
|
|
2206
|
+
retryAfterMs(error) {
|
|
2207
|
+
return error instanceof TelegramError && error.isFlood ? (error.retryAfter ?? 1) * 1000 : undefined;
|
|
2208
|
+
}
|
|
2209
|
+
isMissing(error) {
|
|
2210
|
+
return isMissing(error);
|
|
2211
|
+
}
|
|
2212
|
+
async start(onEvent, signal) {
|
|
2213
|
+
this.#api.setMyCommands(this.#commands.map((command) => ({ command: command.command, description: command.description }))).catch((error) => {
|
|
2214
|
+
this.#log.error(`setMyCommands failed: ${String(error)}`);
|
|
2215
|
+
});
|
|
2216
|
+
let offset = this.#ledger.cursor === 0 ? 0 : this.#ledger.cursor + 1;
|
|
2217
|
+
let failures = 0;
|
|
2218
|
+
while (!signal.aborted) {
|
|
2219
|
+
try {
|
|
2220
|
+
const updates = await this.#api.getUpdates(offset, this.#config.pollTimeoutSeconds, signal);
|
|
2221
|
+
failures = 0;
|
|
2222
|
+
for (const update of updates) {
|
|
2223
|
+
offset = update.updateId + 1;
|
|
2224
|
+
if (this.#ledger.seen(update.updateId))
|
|
2225
|
+
continue;
|
|
2226
|
+
this.#log.info(`update ${String(update.updateId)} ${describe(update)}`);
|
|
2227
|
+
const event = this.#eventOf(update);
|
|
2228
|
+
if (event === undefined)
|
|
2229
|
+
continue;
|
|
2230
|
+
try {
|
|
2231
|
+
await onEvent(event);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2234
|
+
this.#log.error(`update ${String(update.updateId)} failed: ${message}`);
|
|
2235
|
+
if (event.kind === "message") {
|
|
2236
|
+
await this.send(event.chat, {
|
|
2237
|
+
raw: "Host 现在不可用,这次请求没有送出去。确认 Host 在运行后再发一次。",
|
|
2238
|
+
text: "Host 现在不可用,这次请求没有送出去。确认 Host 在运行后再发一次。",
|
|
2239
|
+
parts: ["Host 现在不可用,这次请求没有送出去。确认 Host 在运行后再发一次。"]
|
|
2240
|
+
}).catch(() => {
|
|
2241
|
+
return;
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
await this.#ledger.flush().catch(() => {
|
|
2247
|
+
return;
|
|
2248
|
+
});
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
if (signal.aborted)
|
|
2251
|
+
return;
|
|
2252
|
+
failures += 1;
|
|
2253
|
+
const wait = Math.min(30000, 1000 * 2 ** Math.min(failures, 5));
|
|
2254
|
+
this.#log.error(`polling failed (${String(failures)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
2255
|
+
await new Promise((settle) => setTimeout(settle, wait));
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
#eventOf(update) {
|
|
2260
|
+
if (update.kind === "callback") {
|
|
2261
|
+
const callback = update.callback;
|
|
2262
|
+
if (!this.#allowed(callback.chatId, callback.fromId)) {
|
|
2263
|
+
this.#log.info(`ignored callback chat=${String(callback.chatId)} from=${String(callback.fromId ?? 0)} (not in allowlist)`);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
return {
|
|
2267
|
+
kind: "callback",
|
|
2268
|
+
chat: this.#key(callback.chatId, callback.threadId),
|
|
2269
|
+
userId: String(callback.fromId ?? ""),
|
|
2270
|
+
callbackId: callback.id,
|
|
2271
|
+
messageId: String(callback.messageId),
|
|
2272
|
+
data: callback.data
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
const message = update.message;
|
|
2276
|
+
if (!this.#allowed(message.chat.id, message.fromId)) {
|
|
2277
|
+
this.#log.info(`ignored message chat=${String(message.chat.id)} from=${String(message.fromId ?? 0)} (not in allowlist)`);
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
return {
|
|
2281
|
+
kind: "message",
|
|
2282
|
+
chat: this.#key(message.chat.id, message.threadId),
|
|
2283
|
+
userId: String(message.fromId ?? ""),
|
|
2284
|
+
text: message.text,
|
|
2285
|
+
fromBot: message.isBot,
|
|
2286
|
+
messageId: String(message.messageId)
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
function isNotModified(error) {
|
|
2291
|
+
return error instanceof TelegramError && error.isBadRequest && /not modified/i.test(error.description);
|
|
2292
|
+
}
|
|
2293
|
+
function isMissing(error) {
|
|
2294
|
+
return error instanceof TelegramError && error.isBadRequest && /not found|MESSAGE_ID_INVALID|message to edit not found|message can't be deleted/i.test(error.description);
|
|
2295
|
+
}
|
|
2296
|
+
function describe(update) {
|
|
2297
|
+
if (update.kind === "callback") {
|
|
2298
|
+
return `callback chat=${String(update.callback.chatId)} data=${update.callback.data}`;
|
|
2299
|
+
}
|
|
2300
|
+
const message = update.message;
|
|
2301
|
+
const command = message.text.trim().startsWith("/") ? " command" : ` prompt len=${String(message.text.length)}`;
|
|
2302
|
+
return `message chat=${String(message.chat.id)} from=${String(message.fromId ?? 0)}${command}`;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// src/host/local-remote.ts
|
|
2306
|
+
async function envelope(run2) {
|
|
2307
|
+
try {
|
|
2308
|
+
return { ok: true, value: await run2() };
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
return { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
function createLocalRemote(ctx) {
|
|
2314
|
+
const services = ctx;
|
|
2315
|
+
const lifetime = new AbortController;
|
|
2316
|
+
return {
|
|
2317
|
+
session: {
|
|
2318
|
+
list: (request) => envelope(() => services.sessionController.list(request, lifetime.signal)),
|
|
2319
|
+
create: (request) => envelope(() => services.sessionController.create(request)),
|
|
2320
|
+
prompt: (request, signal) => envelope(() => services.sessionController.prompt(request, signal ?? lifetime.signal)),
|
|
2321
|
+
cancel: (request) => envelope(() => services.sessionController.cancel(request)),
|
|
2322
|
+
follow: (request, signal) => services.sessionController.follow(request, signal ?? lifetime.signal)
|
|
2323
|
+
},
|
|
2324
|
+
workspace: {
|
|
2325
|
+
create: (request) => envelope(() => services.workspaceController.create(request))
|
|
2326
|
+
},
|
|
2327
|
+
$on: (name, handler) => ctx.on(name, handler)
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// src/host/plugin.ts
|
|
2332
|
+
var name = "hulala-bot";
|
|
2333
|
+
var inject = ["sessionController", "workspaceController"];
|
|
2334
|
+
var Config = z.object({
|
|
2335
|
+
stateDir: z.string()
|
|
2336
|
+
});
|
|
2337
|
+
function apply(ctx, config = {}) {
|
|
2338
|
+
const stateDir = config.stateDir ?? botStateDir();
|
|
2339
|
+
let stop;
|
|
2340
|
+
const starting = start(ctx, stateDir).then((value) => {
|
|
2341
|
+
stop = value;
|
|
2342
|
+
}).catch((error) => {
|
|
2343
|
+
console.error(`hulala-bot: ${error instanceof Error ? error.message : String(error)}`);
|
|
2344
|
+
});
|
|
2345
|
+
ctx.effect(() => () => {
|
|
2346
|
+
starting.then(async () => {
|
|
2347
|
+
await stop?.();
|
|
2348
|
+
});
|
|
2349
|
+
}, "hulala-bot: bridge");
|
|
2350
|
+
}
|
|
2351
|
+
async function start(ctx, stateDir) {
|
|
2352
|
+
const config = await loadConfig(process.env, stateDir);
|
|
2353
|
+
const log = new Log(config.stateDir, [config.telegram?.token ?? "", config.lark?.appSecret ?? ""]);
|
|
2354
|
+
const channels = [];
|
|
2355
|
+
const releases = [];
|
|
2356
|
+
if (config.telegram !== undefined) {
|
|
2357
|
+
try {
|
|
2358
|
+
releases.push(await acquireLock(`telegram:${config.telegram.token}`));
|
|
2359
|
+
const api = new TelegramApi({
|
|
2360
|
+
token: config.telegram.token,
|
|
2361
|
+
baseUrl: config.telegram.baseUrl,
|
|
2362
|
+
proxy: config.telegram.proxy
|
|
2363
|
+
});
|
|
2364
|
+
channels.push(new TelegramChannel({
|
|
2365
|
+
api,
|
|
2366
|
+
config: config.telegram,
|
|
2367
|
+
ledger: await UpdateLedger.open(join5(config.stateDir, "receipts", "telegram.json")),
|
|
2368
|
+
log,
|
|
2369
|
+
commands: COMMANDS
|
|
2370
|
+
}));
|
|
2371
|
+
} catch (error) {
|
|
2372
|
+
log.error(`telegram not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
if (config.lark !== undefined) {
|
|
2376
|
+
try {
|
|
2377
|
+
releases.push(await acquireLock(`lark:${config.lark.appId}`));
|
|
2378
|
+
const api = new LarkApi({
|
|
2379
|
+
appId: config.lark.appId,
|
|
2380
|
+
appSecret: config.lark.appSecret,
|
|
2381
|
+
baseUrl: config.lark.baseUrl
|
|
2382
|
+
});
|
|
2383
|
+
channels.push(new LarkChannel({
|
|
2384
|
+
api,
|
|
2385
|
+
transport: createSdkTransport({
|
|
2386
|
+
appId: config.lark.appId,
|
|
2387
|
+
appSecret: config.lark.appSecret,
|
|
2388
|
+
domain: config.lark.domain,
|
|
2389
|
+
onError: (error) => {
|
|
2390
|
+
log.error(`lark connection: ${error.message}`);
|
|
2391
|
+
}
|
|
2392
|
+
}),
|
|
2393
|
+
config: config.lark,
|
|
2394
|
+
ledger: await UpdateLedger.open(join5(config.stateDir, "receipts", "lark.json")),
|
|
2395
|
+
log
|
|
2396
|
+
}));
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
log.error(`lark not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
if (channels.length === 0) {
|
|
2402
|
+
await releaseAll(releases);
|
|
2403
|
+
throw new Error(`no channel started (see ${join5(config.stateDir, "logs")})`);
|
|
2404
|
+
}
|
|
2405
|
+
const remote = createLocalRemote(ctx);
|
|
2406
|
+
const connect2 = async () => ({
|
|
2407
|
+
ctx: { remote },
|
|
2408
|
+
close: async () => {
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
const bindings = await Bindings.open(config.stateDir);
|
|
2413
|
+
const bridge = new Bridge({ config, channels, bindings, log, connect: connect2 });
|
|
2414
|
+
log.info(`bot starting inside the Host; channels ${channels.map((channel) => channel.name).join(", ")}; state ${config.stateDir}`);
|
|
2415
|
+
await bridge.start();
|
|
2416
|
+
return async () => {
|
|
2417
|
+
await Promise.race([bridge.stop(), new Promise((settle) => setTimeout(settle, 5000))]);
|
|
2418
|
+
await releaseAll(releases);
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
async function releaseAll(releases) {
|
|
2422
|
+
for (const release of releases)
|
|
2423
|
+
await release().catch(() => {
|
|
2424
|
+
return;
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
export {
|
|
2428
|
+
name,
|
|
2429
|
+
inject,
|
|
2430
|
+
apply,
|
|
2431
|
+
Config
|
|
2432
|
+
};
|