@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/index.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// src/init.ts
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join as join2 } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
// src/telegram/proxy.ts
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import https from "node:https";
|
|
16
|
+
import tls from "node:tls";
|
|
17
|
+
function normalizeProxy(value) {
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(value);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`proxy must be an http:// or https:// URL, not ${JSON.stringify(value)}`);
|
|
23
|
+
}
|
|
24
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
25
|
+
throw new Error(`proxy must be an http:// or https:// URL, not ${JSON.stringify(value)}`);
|
|
26
|
+
}
|
|
27
|
+
if (url.hostname === "") {
|
|
28
|
+
throw new Error(`proxy has no host: ${JSON.stringify(value)}`);
|
|
29
|
+
}
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|
|
32
|
+
function tunnel(proxy, host, port, secure, done) {
|
|
33
|
+
const proxySecure = proxy.protocol === "https:";
|
|
34
|
+
const authorization = proxy.username === "" ? undefined : `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`;
|
|
35
|
+
const request = (proxySecure ? https : http).request({
|
|
36
|
+
host: proxy.hostname,
|
|
37
|
+
port: Number(proxy.port || (proxySecure ? 443 : 80)),
|
|
38
|
+
method: "CONNECT",
|
|
39
|
+
path: `${host}:${String(port)}`,
|
|
40
|
+
headers: {
|
|
41
|
+
host: `${host}:${String(port)}`,
|
|
42
|
+
...authorization === undefined ? {} : { "proxy-authorization": authorization }
|
|
43
|
+
},
|
|
44
|
+
timeout: 20000
|
|
45
|
+
});
|
|
46
|
+
request.once("connect", (response, socket) => {
|
|
47
|
+
if (response.statusCode !== 200) {
|
|
48
|
+
socket.destroy();
|
|
49
|
+
const detail = response.statusMessage === undefined ? "" : ` ${response.statusMessage}`;
|
|
50
|
+
done(new Error(`proxy CONNECT failed: ${String(response.statusCode)}${detail}`));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!secure) {
|
|
54
|
+
done(null, socket);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const wrapped = tls.connect({ socket, servername: host });
|
|
58
|
+
wrapped.once("secureConnect", () => done(null, wrapped));
|
|
59
|
+
wrapped.once("error", (error) => done(error));
|
|
60
|
+
});
|
|
61
|
+
request.once("error", (error) => done(error));
|
|
62
|
+
request.once("timeout", () => request.destroy(new Error("proxy CONNECT timed out")));
|
|
63
|
+
request.end();
|
|
64
|
+
}
|
|
65
|
+
function connect(proxy, options, secure, callback) {
|
|
66
|
+
const host = options.host ?? "localhost";
|
|
67
|
+
const port = Number(options.port ?? (secure ? 443 : 80));
|
|
68
|
+
tunnel(proxy, host, port, secure, (error, socket) => {
|
|
69
|
+
if (callback === undefined)
|
|
70
|
+
return;
|
|
71
|
+
if (socket === undefined)
|
|
72
|
+
callback(error ?? new Error("no socket from the proxy"), undefined);
|
|
73
|
+
else
|
|
74
|
+
callback(error, socket);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class HttpTunnelAgent extends http.Agent {
|
|
80
|
+
#proxy;
|
|
81
|
+
constructor(proxy) {
|
|
82
|
+
super({ keepAlive: false });
|
|
83
|
+
this.#proxy = proxy;
|
|
84
|
+
}
|
|
85
|
+
createConnection(options, callback) {
|
|
86
|
+
return connect(this.#proxy, options, false, callback);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class HttpsTunnelAgent extends https.Agent {
|
|
91
|
+
#proxy;
|
|
92
|
+
constructor(proxy) {
|
|
93
|
+
super({ keepAlive: false });
|
|
94
|
+
this.#proxy = proxy;
|
|
95
|
+
}
|
|
96
|
+
createConnection(options, callback) {
|
|
97
|
+
return connect(this.#proxy, options, true, callback);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/config.ts
|
|
102
|
+
var run = promisify(execFile);
|
|
103
|
+
function hulalaHome(env = process.env) {
|
|
104
|
+
return env.HULALA_HOME ?? join(homedir(), ".hulala");
|
|
105
|
+
}
|
|
106
|
+
function botStateDir(env = process.env) {
|
|
107
|
+
const dshHome = env.DSH_HOME;
|
|
108
|
+
if (dshHome !== undefined && dshHome !== "")
|
|
109
|
+
return join(dshHome, "config", "hulala-bot");
|
|
110
|
+
return join(hulalaHome(env), "config", "hulala-bot");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/init.ts
|
|
114
|
+
function parse(argv) {
|
|
115
|
+
const telegram = {};
|
|
116
|
+
const lark = {};
|
|
117
|
+
let defaultCwd;
|
|
118
|
+
let interactive = stdin.isTTY;
|
|
119
|
+
let touched = false;
|
|
120
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
121
|
+
const argument = argv[index];
|
|
122
|
+
const value = () => {
|
|
123
|
+
const next = argv[index + 1];
|
|
124
|
+
if (next === undefined)
|
|
125
|
+
throw new Error(`${argument} needs a value`);
|
|
126
|
+
index += 1;
|
|
127
|
+
return next;
|
|
128
|
+
};
|
|
129
|
+
const ids = () => value().split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
130
|
+
if (argument === "--telegram-token-ref") {
|
|
131
|
+
telegram.tokenRef = value();
|
|
132
|
+
touched = true;
|
|
133
|
+
} else if (argument === "--telegram-token-env") {
|
|
134
|
+
telegram.tokenEnv = value();
|
|
135
|
+
touched = true;
|
|
136
|
+
} else if (argument === "--telegram-allowed-user" || argument === "--telegram-allowed-users") {
|
|
137
|
+
for (const entry of ids()) {
|
|
138
|
+
const id = Number(entry);
|
|
139
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
140
|
+
throw new Error(`not a Telegram user id: ${entry}`);
|
|
141
|
+
telegram.allowedUsers = [...telegram.allowedUsers ?? [], id];
|
|
142
|
+
}
|
|
143
|
+
touched = true;
|
|
144
|
+
} else if (argument === "--telegram-allowed-chat" || argument === "--telegram-allowed-chats") {
|
|
145
|
+
for (const entry of ids()) {
|
|
146
|
+
const id = Number(entry);
|
|
147
|
+
if (!Number.isSafeInteger(id))
|
|
148
|
+
throw new Error(`not a Telegram chat id: ${entry}`);
|
|
149
|
+
telegram.allowedChats = [...telegram.allowedChats ?? [], id];
|
|
150
|
+
}
|
|
151
|
+
touched = true;
|
|
152
|
+
} else if (argument === "--proxy") {
|
|
153
|
+
telegram.proxy = normalizeProxy(value());
|
|
154
|
+
touched = true;
|
|
155
|
+
} else if (argument === "--parse-mode") {
|
|
156
|
+
const mode = value();
|
|
157
|
+
if (mode !== "markdown" && mode !== "plain")
|
|
158
|
+
throw new Error("--parse-mode is markdown or plain");
|
|
159
|
+
telegram.parseMode = mode;
|
|
160
|
+
touched = true;
|
|
161
|
+
} else if (argument === "--lark-app-id") {
|
|
162
|
+
lark.appId = value();
|
|
163
|
+
touched = true;
|
|
164
|
+
} else if (argument === "--lark-app-secret-ref") {
|
|
165
|
+
lark.appSecretRef = value();
|
|
166
|
+
touched = true;
|
|
167
|
+
} else if (argument === "--lark-app-secret-env") {
|
|
168
|
+
lark.appSecretEnv = value();
|
|
169
|
+
touched = true;
|
|
170
|
+
} else if (argument === "--lark-allowed-user" || argument === "--lark-allowed-users") {
|
|
171
|
+
lark.allowedUsers = [...lark.allowedUsers ?? [], ...ids()];
|
|
172
|
+
touched = true;
|
|
173
|
+
} else if (argument === "--lark-allowed-chat" || argument === "--lark-allowed-chats") {
|
|
174
|
+
lark.allowedChats = [...lark.allowedChats ?? [], ...ids()];
|
|
175
|
+
touched = true;
|
|
176
|
+
} else if (argument === "--lark-domain") {
|
|
177
|
+
const domain = value();
|
|
178
|
+
if (domain !== "feishu" && domain !== "lark")
|
|
179
|
+
throw new Error("--lark-domain is feishu or lark");
|
|
180
|
+
lark.domain = domain;
|
|
181
|
+
touched = true;
|
|
182
|
+
} else if (argument === "--default-cwd") {
|
|
183
|
+
defaultCwd = value();
|
|
184
|
+
touched = true;
|
|
185
|
+
} else if (argument === "--no-input")
|
|
186
|
+
interactive = false;
|
|
187
|
+
else if (argument === "--init")
|
|
188
|
+
continue;
|
|
189
|
+
else
|
|
190
|
+
throw new Error(`unknown option ${argument}`);
|
|
191
|
+
}
|
|
192
|
+
const hasTelegram = Object.keys(telegram).length > 0;
|
|
193
|
+
const hasLark = Object.keys(lark).length > 0;
|
|
194
|
+
return {
|
|
195
|
+
interactive: interactive && !touched,
|
|
196
|
+
options: {
|
|
197
|
+
...defaultCwd === undefined ? {} : { defaultCwd },
|
|
198
|
+
...hasTelegram ? { telegram } : {},
|
|
199
|
+
...hasLark ? { lark } : {}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function ask(rl, question) {
|
|
204
|
+
return (await rl.question(question)).trim();
|
|
205
|
+
}
|
|
206
|
+
async function prompt() {
|
|
207
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
208
|
+
try {
|
|
209
|
+
const tokenRef = await ask(rl, "Telegram keychain reference (blank to skip): ");
|
|
210
|
+
let telegram;
|
|
211
|
+
if (tokenRef !== "") {
|
|
212
|
+
const allowedUsers = (await ask(rl, "Allowed Telegram user ids, comma separated: ")).split(",").map((entry) => Number(entry.trim())).filter((id) => Number.isSafeInteger(id) && id > 0);
|
|
213
|
+
telegram = {
|
|
214
|
+
tokenRef: tokenRef === "" ? "telegram/bot-token" : tokenRef,
|
|
215
|
+
allowedUsers,
|
|
216
|
+
allowedChats: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const appId = await ask(rl, "Lark app id (cli_…), blank to skip: ");
|
|
220
|
+
let lark;
|
|
221
|
+
if (appId !== "") {
|
|
222
|
+
const appSecretRef = await ask(rl, "Lark app-secret keychain reference [lark/app-secret]: ");
|
|
223
|
+
const allowedUsers = (await ask(rl, "Allowed Lark open ids (ou_…), comma separated: ")).split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
224
|
+
lark = {
|
|
225
|
+
appId,
|
|
226
|
+
appSecretRef: appSecretRef === "" ? "lark/app-secret" : appSecretRef,
|
|
227
|
+
allowedUsers,
|
|
228
|
+
allowedChats: [],
|
|
229
|
+
domain: "feishu"
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const cwdAnswer = await ask(rl, "Directory the first message mounts [~]: ");
|
|
233
|
+
return {
|
|
234
|
+
...cwdAnswer === "" ? {} : { defaultCwd: cwdAnswer },
|
|
235
|
+
...telegram === undefined ? {} : { telegram },
|
|
236
|
+
...lark === undefined ? {} : { lark }
|
|
237
|
+
};
|
|
238
|
+
} finally {
|
|
239
|
+
rl.close();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function hasAllowlist(section) {
|
|
243
|
+
const users = Array.isArray(section.allowedUsers) ? section.allowedUsers : [];
|
|
244
|
+
const chats = Array.isArray(section.allowedChats) ? section.allowedChats : [];
|
|
245
|
+
return users.length > 0 || chats.length > 0;
|
|
246
|
+
}
|
|
247
|
+
function mergeSection(previous, next) {
|
|
248
|
+
const { allowedUsers, allowedChats, ...rest } = next;
|
|
249
|
+
return {
|
|
250
|
+
...previous,
|
|
251
|
+
...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
|
|
252
|
+
...Array.isArray(allowedUsers) && allowedUsers.length > 0 ? { allowedUsers } : {},
|
|
253
|
+
...Array.isArray(allowedChats) && allowedChats.length > 0 ? { allowedChats } : {}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function validateTelegram(section) {
|
|
257
|
+
if (typeof section.tokenRef !== "string" || section.tokenRef === "") {
|
|
258
|
+
throw new Error("--telegram-token-ref is required for the Telegram channel");
|
|
259
|
+
}
|
|
260
|
+
if (!hasAllowlist(section)) {
|
|
261
|
+
throw new Error("refusing to write an empty Telegram allowlist: pass --telegram-allowed-user <id>");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function validateLark(section) {
|
|
265
|
+
if (typeof section.appId !== "string" || !/^cli_[0-9a-fA-F]{16}$/.test(section.appId)) {
|
|
266
|
+
throw new Error("--lark-app-id must look like cli_<16 hex>");
|
|
267
|
+
}
|
|
268
|
+
if (typeof section.appSecretRef !== "string" || section.appSecretRef === "") {
|
|
269
|
+
throw new Error("--lark-app-secret-ref is required for the Lark channel");
|
|
270
|
+
}
|
|
271
|
+
if (!hasAllowlist(section)) {
|
|
272
|
+
throw new Error("refusing to write an empty Lark allowlist: pass --lark-allowed-user <ou_…>");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function runInit(argv, env = process.env) {
|
|
276
|
+
const parsed = parse(argv);
|
|
277
|
+
const options = parsed.interactive ? await prompt() : parsed.options;
|
|
278
|
+
const stateDir = botStateDir(env);
|
|
279
|
+
const file = join2(stateDir, "config.json");
|
|
280
|
+
let existing = {};
|
|
281
|
+
try {
|
|
282
|
+
existing = JSON.parse(await readFile(file, "utf8"));
|
|
283
|
+
} catch {}
|
|
284
|
+
const document = { ...existing };
|
|
285
|
+
if (options.defaultCwd !== undefined)
|
|
286
|
+
document.defaultCwd = options.defaultCwd;
|
|
287
|
+
if (options.telegram !== undefined) {
|
|
288
|
+
document.telegram = mergeSection(existing.telegram, options.telegram);
|
|
289
|
+
}
|
|
290
|
+
if (options.lark !== undefined) {
|
|
291
|
+
document.lark = mergeSection(existing.lark, options.lark);
|
|
292
|
+
}
|
|
293
|
+
if (document.telegram === undefined && document.lark === undefined) {
|
|
294
|
+
throw new Error("nothing to configure: pass --telegram-token-ref … or --lark-app-id … (see --help)");
|
|
295
|
+
}
|
|
296
|
+
if (document.telegram !== undefined)
|
|
297
|
+
validateTelegram(document.telegram);
|
|
298
|
+
if (document.lark !== undefined)
|
|
299
|
+
validateLark(document.lark);
|
|
300
|
+
if (document.defaultCwd === undefined)
|
|
301
|
+
document.defaultCwd = "~";
|
|
302
|
+
await mkdir(stateDir, { recursive: true, mode: 448 });
|
|
303
|
+
await writeFile(file, JSON.stringify(document, null, 2) + `
|
|
304
|
+
`, { mode: 384 });
|
|
305
|
+
console.log(`wrote ${file}`);
|
|
306
|
+
const refs = [options.telegram?.tokenRef, options.lark?.appSecretRef].filter((ref) => typeof ref === "string" && ref !== "");
|
|
307
|
+
console.log("store the credentials in the keychain, never in this file:");
|
|
308
|
+
for (const ref of refs)
|
|
309
|
+
printKeychain(ref);
|
|
310
|
+
console.log("restart the Host (or the bot process) to pick it up");
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
function printKeychain(ref) {
|
|
314
|
+
const [service = "telegram", account = "bot-token"] = ref.split("/");
|
|
315
|
+
if (process.platform === "darwin") {
|
|
316
|
+
console.log(` security add-generic-password -U -s ${service} -a ${account} -w '…'`);
|
|
317
|
+
} else {
|
|
318
|
+
console.log(` secret-tool store --label '${service}' service ${service} account ${account}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/index.ts
|
|
323
|
+
var arguments_ = process.argv.slice(2);
|
|
324
|
+
if (arguments_.includes("--help") || arguments_.length === 0) {
|
|
325
|
+
console.log([
|
|
326
|
+
"hulala-bot — your agent on Telegram and Lark",
|
|
327
|
+
"",
|
|
328
|
+
"This package runs inside a Host as a plugin. The only command here writes",
|
|
329
|
+
"the configuration that plugin needs:",
|
|
330
|
+
"",
|
|
331
|
+
" hulala-bot --init --no-input \\",
|
|
332
|
+
" --telegram-token-ref telegram/bot-token --telegram-allowed-user <id>",
|
|
333
|
+
" hulala-bot --init --no-input \\",
|
|
334
|
+
" --lark-app-id cli_<16 hex> --lark-app-secret-ref lark/app-secret \\",
|
|
335
|
+
" --lark-allowed-user ou_<id> [--lark-domain feishu|lark]",
|
|
336
|
+
"",
|
|
337
|
+
"Both channels may be configured in one file; --init rewrites only the",
|
|
338
|
+
"channel whose flags it was given. Telegram options: --proxy, --parse-mode.",
|
|
339
|
+
"Global options: --default-cwd <path>.",
|
|
340
|
+
"",
|
|
341
|
+
"Install it with one of:",
|
|
342
|
+
" dsh plugin --profile tui add @colinagent/hulala-bot",
|
|
343
|
+
" hulala plugin --profile tui add @colinagent/hulala-bot",
|
|
344
|
+
" hulala install bot # the same thing, on a Hulala install"
|
|
345
|
+
].join(`
|
|
346
|
+
`));
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
if (arguments_.includes("--init")) {
|
|
350
|
+
try {
|
|
351
|
+
process.exit(await runInit(arguments_.slice(arguments_.indexOf("--init") + 1)));
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
console.error(`unknown arguments: ${arguments_.join(" ")}
|
|
358
|
+
try --help`);
|
|
359
|
+
process.exit(1);
|