@colinagent/hulala-bot 0.0.0-stage → 0.3.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ dsh-telegram is licensed under the GNU General Public License, version 3 only
2
+ (GPL-3.0-only).
3
+
4
+ The full license text is available at:
5
+ https://www.gnu.org/licenses/gpl-3.0.txt
6
+
7
+ Copyright (c) 2026 Colin
package/README.md CHANGED
@@ -1,3 +1,248 @@
1
- # Temporary Holding Version
1
+ # hulala-bot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把这个机器上的 DeepSeek Harness agent 接到 **Telegram** 和 **飞书 / Lark** 上。手机发一句话,本机的 Host 执行,回复流式回到同一个对话;`/cwd` 切换这个对话挂载哪个会话——和 TUI 里切换会话是同一件事。
4
+
5
+ DSH 原生和呼啦啦都用同一种方式接:**装成 Host 插件**,进 profile,随 Host 起落。
6
+
7
+ ## 它是什么
8
+
9
+ **一个 Host 插件**,改不到 DSH 一行代码。它跑在 Host 进程里,直接调宿主服务:
10
+
11
+ ```
12
+ ctx.sessionController.list / create / prompt / follow → 会话与流式
13
+ ctx.workspaceController.create → 工作目录
14
+ ctx.on('approval/request', (req, next) => …) → 审批
15
+ ```
16
+
17
+ 没有端口、token、cookie、WebSocket(飞书那条长连接是飞书自己的事件通道,不是本机服务):插件就在 Host 里,`/cwd`、流式回复、审批都不需要跨进程通信。会话历史、cwd、模型、权限以 Host 为真源;这里只保存「哪个对话挂了哪个会话」。
18
+
19
+ **一条代码,两个渠道**:`src/channel.ts` 是唯一的渠道契约,Telegram 和飞书各自实现它(id 形状、markdown 方言、流式方式、按钮回调都不同),`src/bridge.ts` 里的会话逻辑两边共用。两个渠道可以同时开,各自维护允许名单和会话挂载;Telegram 的对话和飞书的对话是两个独立会话,不合并身份。
20
+
21
+ **多端同时在线是正常的**:TUI 和手机可以挂同一个会话,两边看到同一段历史。审批由 Host 内应答;如果两端都开着,先答的生效,另一端收到「已在其他端处理」。
22
+
23
+ ## 安装
24
+
25
+ 包是 `@colinagent/hulala-bot`,两种 Host 装的是同一个。
26
+
27
+ ### 呼啦啦(一条命令)
28
+
29
+ ```sh
30
+ hulala install bot
31
+ # 等价于:hulala plugin --profile tui add @colinagent/hulala-bot
32
+ hulala plugin --profile tui exec hulala-bot --init
33
+ # 重启 Host
34
+ ```
35
+
36
+ ### DSH(Web / Desktop / TUI)
37
+
38
+ ```sh
39
+ dsh plugin --profile tui add @colinagent/hulala-bot
40
+ dsh plugin --profile tui exec hulala-bot --init
41
+ # 重启 Host
42
+ ```
43
+
44
+ `--init` 只写允许名单、`defaultCwd` 和凭据在钥匙串里的**引用**,不写 token / app secret;允许名单为空时拒绝写入——没有它,任何人都能驱动 agent。
45
+
46
+ 没有终端时用参数(两条渠道可以只配一条):
47
+
48
+ ```sh
49
+ # Telegram
50
+ hulala plugin --profile tui exec hulala-bot --init --no-input \
51
+ --telegram-token-ref telegram/bot-token \
52
+ --telegram-allowed-user 123456789 \
53
+ --default-cwd ~/hulala-workspace/workspace
54
+
55
+ # 飞书 / Lark
56
+ hulala plugin --profile tui exec hulala-bot --init --no-input \
57
+ --lark-app-id cli_0123456789abcdef \
58
+ --lark-app-secret-ref lark/app-secret \
59
+ --lark-allowed-user ou_0123456789abcdef0123456789abcdef
60
+ ```
61
+
62
+ `--init` 只改它拿到的那条渠道,另一条原样保留;允许名单/其它字段也按已有内容合并,所以改一个代理不会把白名单清掉。
63
+
64
+ 装完核对:
65
+
66
+ ```sh
67
+ hulala plugin --profile tui list # 依赖里有 @colinagent/hulala-bot
68
+ ```
69
+
70
+ 卸载:`hulala plugin --profile tui remove @colinagent/hulala-bot`,然后重启 Host。
71
+
72
+ **自动识别**:如果你在自己的 DSH 里也装了同一个插件,呼啦啦启动时会读 `~/.dsh/profiles/<同名>/package.json`,把用户额外装的 bundle 链接进自己的组合,所以不需要再装一遍。反过来不成立——呼啦啦里装的插件不会出现在 DSH 里。
73
+
74
+ ## 准备
75
+
76
+ ### Telegram
77
+
78
+ 1. 一个 bot:跟 [@BotFather](https://t.me/BotFather) 说 `/newbot`,拿到 token。
79
+ 2. 你自己的 Telegram user id(可以问 [@userinfobot](https://t.me/userinfobot))。
80
+ 3. 网络到不了 `api.telegram.org` 时配 `proxy`(见下)。
81
+
82
+ token 放系统钥匙串:
83
+
84
+ ```bash
85
+ security add-generic-password -U -s telegram -a bot-token -w '123456:ABC…' # macOS
86
+ secret-tool store --label 'telegram bot' service telegram account bot-token # Linux
87
+ ```
88
+
89
+ 也可以临时用环境变量:`TELEGRAM_BOT_TOKEN=…`。
90
+
91
+ ### 飞书 / Lark
92
+
93
+ 配置由用户自己在开发者后台完成,这里只说明需要什么:
94
+
95
+ 1. 在 [飞书开放平台](https://open.feishu.cn)(国际版是 [Lark](https://open.larksuite.com))建一个**企业自建应用**,拿到 App ID(`cli_` + 16 位十六进制)和 App Secret。
96
+ 2. **开启机器人能力**。
97
+ 3. **权限**:至少 `im:message`(读发给机器人的消息)与 `im:message:send_as_bot`(以应用身份发消息、更新自己发的卡片)。后台会按实际接口提示还缺哪一项。
98
+ 4. **事件与回调 → 订阅方式:使用长连接接收事件**。订阅 `im.message.receive_v1`,并添加卡片回调 `card.action.trigger`(审批按钮靠它回来)。**不要**配 webhook 地址:长连接是唯一不需要公网入口的方式。
99
+ 5. 发布一个版本,把可用范围设成你自己(或团队),然后在飞书里搜到机器人,单聊或拉进群(群里必须 @ 它)。
100
+
101
+ App secret 同样放钥匙串:
102
+
103
+ ```bash
104
+ security add-generic-password -U -s lark -a app-secret -w '…'
105
+ ```
106
+
107
+ 国际版在配置里加 `"domain": "lark"`,Open API 会指向 `open.larksuite.com`。
108
+
109
+ **怎么拿到自己的 open id**:先随便给机器人发一条消息,日志里会出现
110
+ `ignored chat=oc_… from=ou_… (not in allowlist)`,把 `ou_…` 填进 `allowedUsers` 再重启。
111
+
112
+ ## 配置在哪
113
+
114
+ 状态目录是 `$DSH_HOME/config/hulala-bot/`(DSH 是 `~/.dsh`,呼啦啦是 `~/.hulala/harness`)。
115
+
116
+ `config.json`、`bindings.json`、`receipts/`、`logs/` 都在里面,权限 `0600`/`0700`。连接锁不在其中:它按凭据摘要放在 `~/.hulala-bot/<hash>.lock`,所以 DSH 和呼啦啦两边都装了插件时,同一时刻也只有一个 Host 在轮询/连长连接。
117
+
118
+ 默认读同目录的 `config.json`(可用 `HULALA_BOT_CONFIG` 覆盖):
119
+
120
+ ```json
121
+ {
122
+ "defaultCwd": "~/hulala-workspace/workspace",
123
+ "streamIntervalMs": 800,
124
+ "telegram": {
125
+ "tokenRef": "telegram/bot-token",
126
+ "allowedUsers": [123456789],
127
+ "parseMode": "markdown",
128
+ "proxy": "http://127.0.0.1:7897"
129
+ },
130
+ "lark": {
131
+ "appId": "cli_0123456789abcdef",
132
+ "appSecretRef": "lark/app-secret",
133
+ "domain": "feishu",
134
+ "allowedUsers": ["ou_0123456789abcdef0123456789abcdef"]
135
+ }
136
+ }
137
+ ```
138
+
139
+ 顶层:
140
+
141
+ | 字段 | 说明 |
142
+ |---|---|
143
+ | `defaultCwd` | 第一次说话时在哪个目录建会话 |
144
+ | `streamIntervalMs` | 同一条消息两次编辑的最小间隔,默认 800ms(飞书的卡片更新有频率限制,别调太低) |
145
+
146
+ `telegram`:
147
+
148
+ | 字段 | 说明 |
149
+ |---|---|
150
+ | `tokenRef` / `tokenEnv` | 钥匙串引用或环境变量名 |
151
+ | `allowedUsers` | Telegram user id。与 `allowedChats` 都为空时拒绝启动 |
152
+ | `allowedChats` | chat id(群/频道场景) |
153
+ | `parseMode` | `markdown`(代码块正常显示,失败自动退回纯文本)或 `plain` |
154
+ | `proxy` | HTTP/HTTPS 代理,形如 `http://127.0.0.1:7897`;支持 `用户:密码@`。不读 `HTTPS_PROXY`,只认这一项 |
155
+ | `baseUrl` | 只给测试用:把 Bot API 指向假服务器 |
156
+ | `pollTimeoutSeconds` | 长轮询等待秒数,默认 25 |
157
+
158
+ `lark`:
159
+
160
+ | 字段 | 说明 |
161
+ |---|---|
162
+ | `appId` | `cli_` + 16 位十六进制;格式不对会在启动前报出来,而不是静默连不上 |
163
+ | `appSecretRef` / `appSecretEnv` | 钥匙串引用或环境变量名 |
164
+ | `domain` | `feishu`(默认)或 `lark`(国际版) |
165
+ | `allowedUsers` | 允许驱动的 open id(`ou_…`)。与 `allowedChats` 都为空时拒绝启动 |
166
+ | `allowedChats` | 允许驱动的 chat id(`oc_…`) |
167
+ | `baseUrl` | 只给测试用 |
168
+
169
+ `proxy` 只接受 `http://` 或 `https://`(Clash、Surge、V2RayN 的 HTTP 端口都是这个形状);SOCKS5 不支持。请求走 CONNECT 隧道,配置写错会在启动时直接报错,而不是悄悄直连。
170
+
171
+ 状态文件同目录:`bindings.json`(挂载表,键是 `渠道:chat id[:主题]`)、`receipts/`(入站去重账本,24h TTL,两个渠道各一份)、`logs/`。全部 `0600`,凭据在写日志前就被替换成 `<secret>`。
172
+
173
+ ## 运行
174
+
175
+ 装好之后随 Host 起落,不需要单独启动。日志里出现
176
+ `bot starting inside the Host; channels telegram, lark` 就是两条都在跑了。
177
+
178
+ **一个凭据只能有一个连接,跨 home 生效**:锁按 token / app id 的摘要放在
179
+ `~/.hulala-bot/<hash>.lock`。DSH(`~/.dsh`)和呼啦啦(`~/.hulala/harness`)两边都装了插件时,第二个会报
180
+ `already connected from another Host (pid …)` 并待命,而不是两边抢同一条事件流、随机丢消息。
181
+
182
+ ### 网络:DNS 污染与代理
183
+
184
+ `api.telegram.org` 在部分网络下会被污染到无关 IP,直连必然失败。Node 的 `fetch` 默认**不读** `HTTP_PROXY`/`HTTPS_PROXY`;配 `telegram.proxy` 就够,不用给 Host 加环境变量。
185
+
186
+ ## 命令
187
+
188
+ 两个渠道命令一样:
189
+
190
+ | 命令 | 行为 |
191
+ |---|---|
192
+ | `/cwd` | 弹出最近用过的目录,点一个就挂上去 |
193
+ | `/cwd <path>` | 换挂载:该目录下最近的会话,没有就新建 |
194
+ | `/new` | 在当前目录新建会话 |
195
+ | `/sessions` | 最近 10 个会话,`*` 表示当前 |
196
+ | `/switch <n>` | 切到第 n 个 |
197
+ | `/status` | 会话、目录、运行状态 |
198
+ | `/stop` | 停止当前回合 |
199
+ | `/help` | 上面这些 |
200
+
201
+ 直接发文字就是提问。第一次说话会自动在 `defaultCwd` 里建一个会话并挂上。
202
+
203
+ ## 两个渠道的差异
204
+
205
+ | | Telegram | 飞书 / Lark |
206
+ |---|---|---|
207
+ | 传输 | Bot API 长轮询 | 长连接(WebSocket) |
208
+ | 流式 | 反复编辑同一条消息 | 反复 patch 同一张卡片(飞书**不能**编辑文本消息) |
209
+ | 按钮 | inline keyboard | 卡片 `action` 按钮 + `card.action.trigger` 回调 |
210
+ | 置顶状态栏 | 有(`📍 <目录>`) | 无;用 `/status` 看 |
211
+ | "正在输入" | 有 | 飞书没有这个 API |
212
+ | 单条上限 | 4096(内部按 4000 切) | 4000 字符一张卡片,超了续卡片 |
213
+ | 群聊 | 直接收 | 必须 @ 机器人,文本里的 `@_user_1` 会被去掉 |
214
+
215
+ ## 行为细节
216
+
217
+ - **一条回合一条消息**:回合开始发占位,之后反复编辑;回合结束用会话日志里的正式文本替换。超长续消息,并在代码块边界处断开、补齐围栏。
218
+ - **工具调用**:还没有文本时显示 `· bash …`,让手机知道在干什么。
219
+ - **编辑退避**:连续 3 次编辑失败就关掉流式,回合结束一次性发送;限流按服务器给的等待时间退避。
220
+ - **入站去重**:Telegram 的 update 在 ack 丢失时会重投 24 小时,飞书长连接重连也可能重放,同一条消息不会喂给 agent 两次。
221
+ - **审批**:卡片只有「允许这一次 / 拒绝」。请求里没有命令行文本,只有工具名与理由;要显示具体命令需要按 `callId` 去会话日志取。跨端取消只在 Host 内的客户端之间生效,插件模式下手机上的卡片不会被远程收走(另一端点过之后会显示「已在其他端处理」)。
222
+ - **提问**:`ask_user_question` 会在手机上显示问题和选项,但只做通知——回答请回到 TUI/Desktop。
223
+
224
+ ## 安全边界
225
+
226
+ - 白名单是唯一的口令。允许名单为空时该渠道拒绝启动。
227
+ - 消息正文、工具输出、贴出的路径都会经过 Telegram / 飞书的服务器。敏感仓库请用 `allowedUsers` 限制到私人会话,并考虑关掉流式预览。
228
+ - 这个插件不给 Host 增加任何权限:它能做的事,TUI 也能做。
229
+ - 权限策略仍由 Host 的 permission preset 决定,这里不改写、不绕过。
230
+
231
+ ## 与 Host 版本的关系
232
+
233
+ 包按 `package.json` 的 `engines.dsh` 声明范围(当前 `>=0.2.2-rc.1 <0.3.0`)。装载器在**导入模块之前**比较,不满足就跳过该层并在 `skippedBundles` 里报出包名、版本和原因,Host 的其余部分照常启动。DSH 包是 peer 依赖,解析到宿主那一份,不会出现第二个注册表。
234
+
235
+ 安装本身要求 `pnpm` 在 PATH 上——DSH 的 `dsh plugin` 与呼啦啦的 `hulala plugin`(以及 `hulala install bot`)都转发给它。
236
+
237
+ ## 开发
238
+
239
+ ```bash
240
+ node --test "tests/*.test.ts" # 106 个用例
241
+ node scripts/spike-host.ts <dsh-bin> <dsh-home> <profile> # 对真 Host 跑一轮
242
+ ```
243
+
244
+ 飞书的 REST 调用用注入的 `fetch` 测;长连接用假的 transport 测,测试不需要租户、不需要网络。
245
+
246
+ ## 许可
247
+
248
+ GPL-3.0-only。它链接的是 GPL 的 DeepSeek Harness / Hulala,不是专有代码。
@@ -0,0 +1,10 @@
1
+ # Bundled into a DSH profile by
2
+ # dsh plugin --profile <name> add @colinagent/hulala-bot
3
+ #
4
+ # The bundle is the install unit; this layer is what actually mounts the bot
5
+ # inside the Host. `inject` in src/host/plugin.ts keeps the row disabled until
6
+ # the session and workspace controllers exist, so no ordering assumption lives
7
+ # in this file.
8
+ - insert:
9
+ - id: hulala-bot
10
+ name: '@colinagent/hulala-bot/plugin'
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);