herdrchat 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -14
- package/README.zh.md +17 -14
- package/bin/cli.mjs +242 -7
- package/bin/herdrchat.mjs +13 -1
- package/config.env.example +2 -1
- package/docs/guide.md +20 -14
- package/lib/chats/contract.mjs +5 -2
- package/lib/chats/feishu/client.mjs +22 -3
- package/lib/chats/feishu/index.mjs +20 -5
- package/lib/chats/telegram/index.mjs +8 -3
- package/lib/core/config.mjs +23 -4
- package/lib/core/engine.mjs +54 -16
- package/lib/core/menu.mjs +3 -1
- package/lib/core/pairing.mjs +35 -0
- package/package.json +2 -2
- package/skills/herdrchat/SKILL.md +4 -2
- package/skills/herdrchat/references/feishu.md +15 -6
- package/skills/herdrchat/references/herdr.md +33 -0
- package/skills/herdrchat/references/ops.md +1 -1
- package/skills/herdrchat/references/telegram.md +9 -7
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# herdrchat
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The chat client for hc ([herdr](https://herdr.dev)): see and answer every hc terminal from Feishu or Telegram.
|
|
6
6
|
|
|
7
7
|
[English](README.md) · [简体中文](README.zh.md)
|
|
8
8
|
|
|
@@ -12,13 +12,15 @@ A chat front-end for [herdr](https://herdr.dev): every terminal it runs, in Feis
|
|
|
12
12
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
|
+
**herdr-app is hc's desktop and phone client; herdrchat is hc's chat client.**
|
|
16
|
+
|
|
15
17
|
[herdr](https://herdr.dev) runs your coding agents (Claude Code, codex, pi, …) in
|
|
16
|
-
terminals that stay alive: **hc** (herdr-core) keeps them running,
|
|
17
|
-
them in spaces and tabs. herdr-app
|
|
18
|
+
terminals that stay alive: **hc** (herdr-core) keeps them running, and herdr-app shows
|
|
19
|
+
them in spaces and tabs. But to use herdr-app away from your desk, your phone has to
|
|
18
20
|
reach the machine that runs hc, so you first need a VPN or a private network, and working
|
|
19
21
|
with an agent on a small phone screen is clumsy.
|
|
20
22
|
|
|
21
|
-
herdrchat
|
|
23
|
+
herdrchat shows the same terminals in a chat app you already have on your phone. It connects out to Feishu or Telegram by
|
|
22
24
|
itself, so there is nothing to set up on your network: no public IP, no VPN. Each
|
|
23
25
|
herdr-app tab gets its own Feishu group, or a topic in your private chat with the
|
|
24
26
|
Telegram bot. You read what the agent says while it works, tap buttons to answer its
|
|
@@ -32,8 +34,9 @@ until herdrchat learns to read their conversations too.
|
|
|
32
34
|
## You need
|
|
33
35
|
|
|
34
36
|
- **herdr**: herdrchat is built on it and does nothing without it. Install it from
|
|
35
|
-
[herdr.dev](https://herdr.dev) first
|
|
36
|
-
one
|
|
37
|
+
[herdr.dev](https://herdr.dev) first (or let your coding agent do it with you:
|
|
38
|
+
`/herdrchat setup` offers to); its **hc** must run on this machine, with at least one
|
|
39
|
+
session.
|
|
37
40
|
- **Node.js 20** or newer, on **macOS or Linux**.
|
|
38
41
|
- **Feishu**: a Feishu team (not Lark) where you may create a custom app and publish it.
|
|
39
42
|
**Telegram**: a Telegram account. Use one app, or both at once.
|
|
@@ -58,17 +61,21 @@ reach the agent.
|
|
|
58
61
|
**With your agent:** ask it `/herdrchat setup`. It asks which chat app, then walks you
|
|
59
62
|
through everything, clicking in the Feishu console for you when it can.
|
|
60
63
|
|
|
61
|
-
**
|
|
64
|
+
**In the terminal** (the same steps, asked one by one; the agent can also click through
|
|
65
|
+
the Feishu console for you, with the [Ego Lite](https://lite.ego.app/) browser, which the
|
|
66
|
+
terminal cannot; without Ego it tells you what to click):
|
|
62
67
|
|
|
63
68
|
```bash
|
|
64
|
-
herdrchat
|
|
65
|
-
# fill in that file (each line says where its value comes from), then:
|
|
66
|
-
herdrchat start # starts it and waits until it is connected
|
|
67
|
-
herdrchat install # once it works: start at login, come back after a crash
|
|
69
|
+
herdrchat setup
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
It asks which chat app, takes the bot's token (or the Feishu app's keys) and checks them
|
|
73
|
+
at once, picks your hc sessions (`default` unless you say otherwise), starts the bot, and
|
|
74
|
+
shows a 6-digit code: send it to the bot and it knows you are its owner. Last, it offers
|
|
75
|
+
to keep it running as a service. No file to edit, no id to copy.
|
|
76
|
+
|
|
77
|
+
Making the Telegram bot or the Feishu app, and doing it all by editing the settings file
|
|
78
|
+
instead: the [guide](docs/guide.md#set-up-telegram).
|
|
72
79
|
|
|
73
80
|
## Use
|
|
74
81
|
|
|
@@ -86,7 +93,7 @@ start a terminal, `/kill` to end one.
|
|
|
86
93
|
| `/herdrchat feishu <task>` | Does a task in the Feishu developer console (with the [Ego Lite](https://lite.ego.app/) browser, or step by step). |
|
|
87
94
|
| `/herdrchat telegram <task>` | The Telegram bot, from @BotFather to the first topic. |
|
|
88
95
|
|
|
89
|
-
On the command line: `herdrchat init`, `start`, `stop`, `status`, `logs`, `install`,
|
|
96
|
+
On the command line: `herdrchat setup`, `pair`, `init`, `start`, `stop`, `status`, `logs`, `install`,
|
|
90
97
|
`uninstall`, `version`; `herdrchat` alone lists them. Add `--profile <name>` to run a
|
|
91
98
|
second chat app beside the first.
|
|
92
99
|
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# herdrchat
|
|
4
4
|
|
|
5
|
-
[herdr](https://herdr.dev)
|
|
5
|
+
hc([herdr](https://herdr.dev))的聊天客户端:在飞书或 Telegram 里查看和回复 hc 的每一个终端。
|
|
6
6
|
|
|
7
7
|
[English](README.md) · [简体中文](README.zh.md)
|
|
8
8
|
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
|
+
**herdr-app 是 hc 的电脑和手机客户端;herdrchat 是 hc 的聊天客户端。**
|
|
16
|
+
|
|
15
17
|
[herdr](https://herdr.dev) 让你的编程 agent(Claude Code、codex、pi……)跑在不会断的终端里:
|
|
16
|
-
**hc**(herdr-core
|
|
17
|
-
|
|
18
|
+
**hc**(herdr-core)让它们一直跑着,herdr-app 按空间和标签页把它们展示出来。
|
|
19
|
+
但想在外面用 herdr-app,手机得能连到跑 hc 的机器,所以要先折腾 VPN 或内网;而且在手机的小屏幕上和 agent 交互,体验并不好。
|
|
18
20
|
|
|
19
|
-
herdrchat
|
|
21
|
+
herdrchat 把同样这些终端,放进你手机上本来就有的聊天软件里。它自己主动连到飞书或 Telegram,网络上什么都不用配:不要公网 IP,也不要 VPN。
|
|
20
22
|
herdr-app 的每个标签页,在飞书里是一个群,在 Telegram 里是和 bot 私聊中的一个话题:
|
|
21
23
|
agent 干活时说的话实时可见,它问你的问题点按钮就能回答,图片双向都能传,所有终端汇总在一张总览里。
|
|
22
24
|
新开的群或话题会先补上之前的对话;只有 agent 需要你、或者做完的时候手机才会响
|
|
@@ -27,8 +29,8 @@ agent 干活时说的话实时可见,它问你的问题点按钮就能回答
|
|
|
27
29
|
|
|
28
30
|
## 需要什么
|
|
29
31
|
|
|
30
|
-
- **herdr**:herdrchat 是基于它做的,没有它什么都做不了。请先从 [herdr.dev](https://herdr.dev)
|
|
31
|
-
|
|
32
|
+
- **herdr**:herdrchat 是基于它做的,没有它什么都做不了。请先从 [herdr.dev](https://herdr.dev) 安装
|
|
33
|
+
(也可以让编程 agent 帮你装:`/herdrchat setup` 会主动问你);它的 **hc** 要在这台机器上跑着,至少有一个 session。
|
|
32
34
|
- **Node.js 20** 或更新版本,系统是 **macOS 或 Linux**。
|
|
33
35
|
- **飞书**:一个能创建并发布企业自建应用的飞书团队(暂不支持 Lark 国际版)。
|
|
34
36
|
**Telegram**:一个 Telegram 账号。可以只用一个,也可以两个同时用。
|
|
@@ -49,18 +51,19 @@ skill 会教你的编程 agent(Claude Code、Codex……)替你配置和照
|
|
|
49
51
|
## 配置
|
|
50
52
|
|
|
51
53
|
**让 agent 帮你:** 对它说 `/herdrchat setup`。它会先问你用哪个聊天软件,然后一步步带你配好,
|
|
52
|
-
|
|
54
|
+
飞书后台的操作,装了 [Ego Lite](https://lite.ego.app/) 浏览器它会替你点;没装的话,它会问你要不要装,不装就一步步告诉你点哪里。
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
**在终端里**(步骤和 agent 一样,一个个问你;区别是 agent 还能替你在飞书后台点,终端做不到):
|
|
55
57
|
|
|
56
58
|
```bash
|
|
57
|
-
herdrchat
|
|
58
|
-
# 填好那个文件(每一行上面都写了值从哪来),然后:
|
|
59
|
-
herdrchat start # 启动,并等到真的连上
|
|
60
|
-
herdrchat install # 跑通以后:装成系统服务,开机自动启动,崩溃自动重启
|
|
59
|
+
herdrchat setup
|
|
61
60
|
```
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
它会问你用哪个聊天软件,让你粘贴 bot 的 token(或飞书应用的 App ID 和 Secret)并当场检查,
|
|
63
|
+
帮你选好要同步的 hc session(不改就是 `default`),然后启动 bot,并给你一个 6 位数字:
|
|
64
|
+
把它发给 bot,bot 就认出你是主人。最后问你要不要装成系统服务。不用改文件,也不用复制任何 id。
|
|
65
|
+
|
|
66
|
+
怎么建 Telegram bot 或飞书应用,以及想自己改配置文件的做法,见[详细说明](docs/guide.md#set-up-telegram)(英文)。
|
|
64
67
|
|
|
65
68
|
## 使用
|
|
66
69
|
|
|
@@ -77,7 +80,7 @@ herdrchat install # 跑通以后:装成系统服务,开机自
|
|
|
77
80
|
| `/herdrchat feishu <任务>` | 替你在飞书开发者后台完成操作(用 [Ego Lite](https://lite.ego.app/) 浏览器,或者一步步带你点)。 |
|
|
78
81
|
| `/herdrchat telegram <任务>` | Telegram bot:从 @BotFather 一直到第一个话题。 |
|
|
79
82
|
|
|
80
|
-
命令行:`herdrchat init`、`start`、`stop`、`status`、`logs`、`install`、`uninstall`、`version`;
|
|
83
|
+
命令行:`herdrchat setup`、`pair`、`init`、`start`、`stop`、`status`、`logs`、`install`、`uninstall`、`version`;
|
|
81
84
|
只输入 `herdrchat` 会列出全部。加上 `--profile <名字>`,可以再开一个聊天软件。
|
|
82
85
|
|
|
83
86
|
## 更新
|
package/bin/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// herdrchat's command line: set up, start, stop and look at one profile.
|
|
3
3
|
//
|
|
4
4
|
// herdrchat <command> [--profile <name>]
|
|
5
|
+
// setup a few questions, and it is set up, started, paired with you and kept running
|
|
5
6
|
// init make the profile's config.env from the example when it is missing
|
|
6
7
|
// install run it as a service of the system (launchd, systemd): it starts at
|
|
7
8
|
// login and comes back after a crash; start, stop, status go through it
|
|
@@ -21,11 +22,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
22
|
|
|
22
23
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
24
|
const GUIDE = 'https://github.com/zhangxiangliang/herdrchat/blob/main/docs/guide.md';
|
|
24
|
-
const HERDR = 'herdrchat is built on herdr: it mirrors the terminals that hc (herdr-core) runs.\nIt needs hc on this machine: install herdr first, from https://herdr.dev';
|
|
25
|
-
const USAGE = `herdrchat: herdr
|
|
25
|
+
const HERDR = 'herdrchat is built on herdr: it mirrors the terminals that hc (herdr-core) runs.\nIt needs hc on this machine: install herdr first, from https://herdr.dev\n(or ask your coding agent `/herdrchat setup`: it can install herdr with you).';
|
|
26
|
+
const USAGE = `herdrchat: the chat client for hc (herdr), in Feishu or Telegram. Needs hc (https://herdr.dev).
|
|
26
27
|
|
|
27
28
|
herdrchat <command> [--profile <name>]
|
|
28
29
|
|
|
30
|
+
setup set it up by answering a few questions (the easy way in)
|
|
31
|
+
pair make you the bot's owner: it shows a code to send to the bot
|
|
29
32
|
init make the profile's config.env (--chat feishu|telegram picks the app)
|
|
30
33
|
start start (or restart) it: the service, or else its own hc terminal
|
|
31
34
|
stop stop it
|
|
@@ -36,7 +39,7 @@ herdrchat <command> [--profile <name>]
|
|
|
36
39
|
run run in the foreground (what start keeps running)
|
|
37
40
|
version which herdrchat this is
|
|
38
41
|
|
|
39
|
-
First time: herdrchat init, fill in the file
|
|
42
|
+
First time: herdrchat setup. (Or by hand: herdrchat init, fill in the file, herdrchat start.)
|
|
40
43
|
A second chat app: add --profile <name> to every command.
|
|
41
44
|
Environment: HERDRCHAT_PROFILE (same as --profile), HERDRCHAT_HOME (one folder for a
|
|
42
45
|
profile's settings and state).
|
|
@@ -85,7 +88,8 @@ if (helpAsked || !command) {
|
|
|
85
88
|
if (process.env.HERDRCHAT_PROFILE && !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(process.env.HERDRCHAT_PROFILE)) fail(`HERDRCHAT_PROFILE "${process.env.HERDRCHAT_PROFILE}": use letters, digits, - and _ only.`);
|
|
86
89
|
|
|
87
90
|
// after the profile is set: lib/core/config.mjs reads it when it loads
|
|
88
|
-
const { CONFIG_FILE, STATE_DIR, chatOf, loadConfig, makeDirs, placesOf } = await import('../lib/core/config.mjs');
|
|
91
|
+
const { CONFIG_FILE, STATE_DIR, chatOf, loadConfig, makeDirs, placesOf, readState, setConfigValue, writeState } = await import('../lib/core/config.mjs');
|
|
92
|
+
const { startPairing, PAIR_MINUTES } = await import('../lib/core/pairing.mjs');
|
|
89
93
|
const { chatModule, chatNames } = await import('../lib/chats/index.mjs');
|
|
90
94
|
const { coreUp, hc, sessionsHere } = await import('../lib/core/hc.mjs');
|
|
91
95
|
const { serviceOf } = await import('../lib/core/service.mjs');
|
|
@@ -107,7 +111,10 @@ function readyConfig() {
|
|
|
107
111
|
// a session not on this machine would be made by hc as the bot asks for it: a typo
|
|
108
112
|
const here = sessionsHere();
|
|
109
113
|
const unknown = cfg.sessions.filter((s) => !here.includes(s));
|
|
110
|
-
if (unknown.length)
|
|
114
|
+
if (unknown.length) {
|
|
115
|
+
const what = cfg.sessionsSet ? `HERDR_SESSIONS in ${CONFIG_FILE} names ${unknown.join(', ')}, which ${unknown.length > 1 ? 'are' : 'is'} not an hc session on this machine` : `HERDR_SESSIONS is empty, so herdrchat follows hc's default session, which is not on this machine`;
|
|
116
|
+
fail(`${what}.\nThe sessions here: ${here.join(', ') || 'none: install and start herdr first (https://herdr.dev)'}.${here.length ? ` Put the ones to mirror in HERDR_SESSIONS in ${CONFIG_FILE}.` : ''}`, 1);
|
|
117
|
+
}
|
|
111
118
|
return cfg;
|
|
112
119
|
}
|
|
113
120
|
// a day's log file: the local date, as the log names its files
|
|
@@ -181,7 +188,235 @@ async function terminalState(session) {
|
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
// ---------- setup: questions instead of a file to fill ----------
|
|
192
|
+
|
|
193
|
+
async function prompter() {
|
|
194
|
+
const readline = await import('node:readline/promises');
|
|
195
|
+
let rl = null;
|
|
196
|
+
const open = () => { rl ||= readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); return rl; };
|
|
197
|
+
const shut = () => { rl?.close(); rl = null; };
|
|
198
|
+
const ask = async (q, fallback = '') => (await open().question(fallback ? `${q} [${fallback}] ` : `${q} `)).trim() || fallback;
|
|
199
|
+
// A token or a secret is typed with nothing shown, not even dots: the terminal is put in
|
|
200
|
+
// raw mode and each key is read here, so neither the terminal nor readline echoes it.
|
|
201
|
+
const askSecret = (q) => new Promise((resolve) => {
|
|
202
|
+
shut();
|
|
203
|
+
process.stdout.write(`${q} `);
|
|
204
|
+
const stdin = process.stdin;
|
|
205
|
+
stdin.setRawMode(true);
|
|
206
|
+
stdin.resume();
|
|
207
|
+
let value = '';
|
|
208
|
+
const onData = (buf) => {
|
|
209
|
+
for (const ch of buf.toString('utf8')) {
|
|
210
|
+
if (ch === '\u0003') { // Ctrl+C
|
|
211
|
+
stdin.setRawMode(false);
|
|
212
|
+
process.stdout.write('\n');
|
|
213
|
+
process.exit(130);
|
|
214
|
+
}
|
|
215
|
+
if (ch === '\r' || ch === '\n' || ch === '\u0004') {
|
|
216
|
+
stdin.setRawMode(false);
|
|
217
|
+
stdin.pause();
|
|
218
|
+
stdin.off('data', onData);
|
|
219
|
+
process.stdout.write('\n');
|
|
220
|
+
resolve(value.trim());
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
224
|
+
else if (ch >= ' ') value += ch;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
stdin.on('data', onData);
|
|
228
|
+
});
|
|
229
|
+
return { ask, askSecret, close: shut };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ok = (text) => console.log(`✔ ${text}`);
|
|
233
|
+
const bad = (text) => console.log(`✘ ${text}`);
|
|
234
|
+
const tell = (text) => console.log(` ${text}`);
|
|
235
|
+
|
|
236
|
+
async function telegramMe(token) {
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(15000) });
|
|
239
|
+
const body = await res.json();
|
|
240
|
+
return body.ok ? body.result : { error: body.description || `HTTP ${res.status}` };
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return { error: err.message };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function feishuCheck(appId, appSecret) {
|
|
247
|
+
try {
|
|
248
|
+
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app_id: appId, app_secret: appSecret }), signal: AbortSignal.timeout(15000) });
|
|
249
|
+
const body = await res.json();
|
|
250
|
+
return body.code === 0 ? { ok: true } : { error: body.msg || `code ${body.code}` };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { error: err.message };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
184
256
|
const commands = {
|
|
257
|
+
async setup() {
|
|
258
|
+
if (!process.stdin.isTTY) fail(`herdrchat setup asks questions, so run it in a terminal. Without one: herdrchat init${again}, fill in the file, herdrchat start${again}.`);
|
|
259
|
+
console.log(`herdrchat setup${again}: a few questions, then it runs.\n`);
|
|
260
|
+
// 1. herdr
|
|
261
|
+
const hcPath = findHc();
|
|
262
|
+
if (!hcPath) {
|
|
263
|
+
bad(`hc is not on PATH. ${HERDR}`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const { execFileSync } = await import('node:child_process');
|
|
268
|
+
ok(`herdr is here (${execFileSync(hcPath, ['--version'], { encoding: 'utf8' }).trim()})`);
|
|
269
|
+
const p = await prompter();
|
|
270
|
+
try {
|
|
271
|
+
// 2. the chat app
|
|
272
|
+
makeDirs();
|
|
273
|
+
const had = fs.existsSync(CONFIG_FILE) ? loadConfig() : null;
|
|
274
|
+
let chatName = chatFlag || had?.chat;
|
|
275
|
+
if (!chatName || (!had && !chatFlag)) {
|
|
276
|
+
const pick = await p.ask('Which chat app? 1) Feishu 2) Telegram', had?.chat === 'telegram' ? '2' : '1');
|
|
277
|
+
chatName = pick === '2' || /^t/i.test(pick) ? 'telegram' : 'feishu';
|
|
278
|
+
}
|
|
279
|
+
if (!had) {
|
|
280
|
+
const example = fs.readFileSync(path.join(ROOT, 'config.env.example'), 'utf8').replace(/^HERDRCHAT_CHAT=.*$/m, `HERDRCHAT_CHAT=${chatName}`);
|
|
281
|
+
fs.writeFileSync(CONFIG_FILE, example, { mode: 0o600 });
|
|
282
|
+
} else {
|
|
283
|
+
setConfigValue(CONFIG_FILE, 'HERDRCHAT_CHAT', chatName);
|
|
284
|
+
}
|
|
285
|
+
ok(`${chatName}, settings in ${CONFIG_FILE}`);
|
|
286
|
+
const env = loadConfig().env;
|
|
287
|
+
// 3. its keys, checked now
|
|
288
|
+
if (chatName === 'telegram') {
|
|
289
|
+
let token = env.TELEGRAM_BOT_TOKEN || '';
|
|
290
|
+
if (!token) tell('Make a bot first: in Telegram, message @BotFather, send /newbot, pick a name; it gives you a token.');
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (!token) token = await p.askSecret('Paste the token (it is not shown):');
|
|
293
|
+
const me = await telegramMe(token);
|
|
294
|
+
if (me.error) {
|
|
295
|
+
bad(`Telegram says: ${me.error}. Check the token and paste it again.`);
|
|
296
|
+
token = '';
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
setConfigValue(CONFIG_FILE, 'TELEGRAM_BOT_TOKEN', token);
|
|
300
|
+
ok(`the token works: @${me.username}`);
|
|
301
|
+
if (!me.has_topics_enabled) {
|
|
302
|
+
bad('topic mode is off, and herdrchat makes one topic per terminal.');
|
|
303
|
+
tell('In the @BotFather chat tap Open (its mini app), pick your bot, turn on Threaded Mode.');
|
|
304
|
+
await p.ask('Press Enter when it is on.');
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
ok('topic mode is on');
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
let appId = env.FEISHU_APP_ID || '';
|
|
312
|
+
let appSecret = env.FEISHU_APP_SECRET || '';
|
|
313
|
+
if (!appId || !appSecret) {
|
|
314
|
+
tell('Make the Feishu app first, in https://open.feishu.cn/app: a custom app (企业自建应用) with the bot ability');
|
|
315
|
+
tell('and the scopes in the guide, "Set up: Feishu": ' + GUIDE + '#set-up-feishu');
|
|
316
|
+
tell('(your coding agent can click through it for you: /herdrchat feishu create the app)');
|
|
317
|
+
}
|
|
318
|
+
for (;;) {
|
|
319
|
+
if (!appId) appId = await p.ask('App ID (cli_…, on the app\'s 凭证与基础信息 page):');
|
|
320
|
+
if (!appSecret) appSecret = await p.askSecret('App Secret (it is not shown):');
|
|
321
|
+
const check = await feishuCheck(appId, appSecret);
|
|
322
|
+
if (check.error) {
|
|
323
|
+
bad(`Feishu says: ${check.error}. Check the App ID and Secret and give them again.`);
|
|
324
|
+
appId = '';
|
|
325
|
+
appSecret = '';
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
setConfigValue(CONFIG_FILE, 'FEISHU_APP_ID', appId);
|
|
329
|
+
setConfigValue(CONFIG_FILE, 'FEISHU_APP_SECRET', appSecret);
|
|
330
|
+
ok('the App ID and Secret work');
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// 4. which sessions
|
|
335
|
+
const here = sessionsHere();
|
|
336
|
+
if (!here.length) {
|
|
337
|
+
bad('herdr is installed but no hc session runs on this machine, so there is nothing to mirror yet.');
|
|
338
|
+
tell('Open herdr-app, or start hc as herdr\'s docs say (https://herdr.dev), then run herdrchat setup' + again + ' again: what is done so far is kept.');
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const named = loadConfig().sessionsSet ? loadConfig().sessions.join(',') : '';
|
|
343
|
+
const fallback = named || (here.includes('default') ? 'default' : here[0] || 'default');
|
|
344
|
+
tell(`hc sessions on this machine: ${here.join(', ') || 'none yet'}. "default" with herdr-app reaches all it shows.`);
|
|
345
|
+
for (;;) {
|
|
346
|
+
const pick = (await p.ask('Which sessions to mirror (comma-separated)?', fallback)).split(',').map((x) => x.trim()).filter(Boolean);
|
|
347
|
+
const unknown = pick.filter((x) => !here.includes(x));
|
|
348
|
+
if (unknown.length) {
|
|
349
|
+
bad(`not on this machine: ${unknown.join(', ')}`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
setConfigValue(CONFIG_FILE, 'HERDR_SESSIONS', pick.join(',') === 'default' ? '' : pick.join(','));
|
|
353
|
+
ok(`following ${pick.join(', ')}`);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
// 5. start
|
|
357
|
+
console.log('');
|
|
358
|
+
await commands.start();
|
|
359
|
+
if (process.exitCode) return;
|
|
360
|
+
if (chatName === 'feishu') {
|
|
361
|
+
tell('Now, with the bot running, in the Feishu developer console: 事件与回调 -> long connection (长连接),');
|
|
362
|
+
tell('add the event im.message.receive_v1 and the callback card.action.trigger, then publish a version.');
|
|
363
|
+
await p.ask('Press Enter when the version is published.');
|
|
364
|
+
}
|
|
365
|
+
// 6. pair: the owner is whoever sends the code (the same step the agent runs)
|
|
366
|
+
await commands.pair();
|
|
367
|
+
if (process.exitCode) return;
|
|
368
|
+
// 7. keep it running
|
|
369
|
+
const svc = service();
|
|
370
|
+
if (svc && !svc.installed()) {
|
|
371
|
+
const yes = await p.ask('Keep it running as a service (starts at login, comes back after a crash)? [Y/n]', 'Y');
|
|
372
|
+
if (/^y/i.test(yes)) await commands.install();
|
|
373
|
+
}
|
|
374
|
+
console.log(`\nDone. herdrchat status${again} shows how it is; herdrchat logs${again} what it did.`);
|
|
375
|
+
if (chatName === 'feishu') console.log('Send /auth to the bot once to let it mute its groups, so your phone rings only when it should.');
|
|
376
|
+
} finally {
|
|
377
|
+
p.close();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
// The owner, without copying an id: a 6-digit code sent to the bot. It needs no terminal,
|
|
382
|
+
// so `herdrchat setup` and the agent's `/herdrchat setup` both use it. Sending the code
|
|
383
|
+
// also proves the whole path works: the bot runs, the chat app delivers, events are on.
|
|
384
|
+
async pair() {
|
|
385
|
+
const cfg = loadConfig();
|
|
386
|
+
const ownerKey = cfg.chat === 'telegram' ? 'TELEGRAM_OWNER_ID' : 'FEISHU_OWNER_OPEN_ID';
|
|
387
|
+
if (cfg.env[ownerKey]) {
|
|
388
|
+
ok(`the owner is set already (${ownerKey} in ${CONFIG_FILE}); to change it, empty that line and pair again`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const svc = service();
|
|
392
|
+
const running = svc?.installed() ? svc.state().running : (await terminalState(cfg.sessions[0])) === 'live';
|
|
393
|
+
if (!running) fail(`The bot is not running, and it is the bot that reads the code. Run herdrchat start${again} first.`);
|
|
394
|
+
let where = 'your chat with the bot';
|
|
395
|
+
if (cfg.chat === 'telegram') {
|
|
396
|
+
const me = await telegramMe(cfg.env.TELEGRAM_BOT_TOKEN || '');
|
|
397
|
+
if (me.username) where = `your chat with @${me.username} (https://t.me/${me.username}), tap Start if Telegram shows it,`;
|
|
398
|
+
} else {
|
|
399
|
+
where = 'your chat with the bot (in Feishu, search the app\'s name, open it, and message it)';
|
|
400
|
+
}
|
|
401
|
+
const code = startPairing({ read: readState, write: writeState });
|
|
402
|
+
console.log(`\nOpen ${where} and send it this code:\n\n ${code}\n`);
|
|
403
|
+
tell(`It works for ${PAIR_MINUTES} minutes. Waiting…`);
|
|
404
|
+
const since = new Date().toISOString();
|
|
405
|
+
const until = Date.now() + PAIR_MINUTES * 60 * 1000;
|
|
406
|
+
while (Date.now() < until && !loadConfig().env[ownerKey]) await new Promise((r) => setTimeout(r, 2000));
|
|
407
|
+
if (!loadConfig().env[ownerKey]) {
|
|
408
|
+
bad(`no code came in ${PAIR_MINUTES} minutes. Run herdrchat pair${again} for a new one.`);
|
|
409
|
+
if (cfg.chat === 'feishu') tell('When the bot never answered at all: in the developer console, check the event im.message.receive_v1 is added on the long connection, and that a version with it is published.');
|
|
410
|
+
else tell('When the bot never answered at all: herdrchat logs' + again + ' says why.');
|
|
411
|
+
process.exitCode = 1;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
ok('paired: you are the owner. It starts again as yours…');
|
|
415
|
+
const back = await waitConnected(since);
|
|
416
|
+
if (!back.ok) return failedToConnect('It paired', back);
|
|
417
|
+
ok(`connected; your terminals' ${cfg.chat === 'telegram' ? 'topics' : 'groups'} appear in a moment`);
|
|
418
|
+
},
|
|
419
|
+
|
|
185
420
|
init() {
|
|
186
421
|
const chatName = chatFlag || 'feishu';
|
|
187
422
|
if (!chatNames().includes(chatName)) fail(`--chat ${chatName}: the chat apps are ${chatNames().join(', ')}`);
|
|
@@ -195,7 +430,7 @@ const commands = {
|
|
|
195
430
|
const example = fs.readFileSync(path.join(ROOT, 'config.env.example'), 'utf8').replace(/^HERDRCHAT_CHAT=.*$/m, `HERDRCHAT_CHAT=${chatName}`);
|
|
196
431
|
fs.writeFileSync(CONFIG_FILE, example, { mode: 0o600 });
|
|
197
432
|
const keys = (chatModule(chatName).requires || []).join(', ');
|
|
198
|
-
console.log(`Made ${CONFIG_FILE} (only you can read it) for ${chatName}.\nOpen it and fill in ${keys}
|
|
433
|
+
console.log(`Made ${CONFIG_FILE} (only you can read it) for ${chatName}.\nOpen it and fill in ${keys} (HERDR_SESSIONS is hc's default session unless you name others), then run \`herdrchat start${again}\`.\nThe guide walks through it: ${GUIDE}`);
|
|
199
434
|
},
|
|
200
435
|
|
|
201
436
|
// The service file is written again each time (a moved node or hc is picked up), and
|
|
@@ -270,7 +505,7 @@ const commands = {
|
|
|
270
505
|
if (!here.includes(session)) sessionWords.push(`${session} (no such session here; this machine has: ${here.join(', ') || 'none'})`);
|
|
271
506
|
else sessionWords.push(`${session} (${(await coreUp(session)) ? 'core up' : 'core not running'})`);
|
|
272
507
|
}
|
|
273
|
-
console.log(`sessions ${sessionWords.join(', ')
|
|
508
|
+
console.log(`sessions ${sessionWords.join(', ')}${cfg.sessionsSet ? '' : ' (hc\'s default session: HERDR_SESSIONS is empty)'}`);
|
|
274
509
|
// how it runs: the service, an hc terminal, or not at all
|
|
275
510
|
const svc = hcThere ? service() : null;
|
|
276
511
|
if (!hcThere) {
|
package/bin/herdrchat.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Usage: node bin/herdrchat.mjs (`herdrchat start` keeps it running)
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, writeState } from '../lib/core/config.mjs';
|
|
8
|
+
import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, setConfigValue, writeState } from '../lib/core/config.mjs';
|
|
9
|
+
import { tryPairing } from '../lib/core/pairing.mjs';
|
|
9
10
|
import { createEngine } from '../lib/core/engine.mjs';
|
|
10
11
|
import { followEvents } from '../lib/core/events.mjs';
|
|
11
12
|
import { cores, getTerminal, hc, keys, machines, nameTab, put, run, screenTail, send } from '../lib/core/hc.mjs';
|
|
@@ -34,6 +35,17 @@ const chat = createChat(cfg.chat, {
|
|
|
34
35
|
stateDir: STATE_DIR,
|
|
35
36
|
lane: (k, label, fn) => engine.laneOf(k, label, fn),
|
|
36
37
|
scopeOf: (k) => engine.scopeOfKey(k),
|
|
38
|
+
// the pairing code from `herdrchat setup`: its sender becomes the owner, written to
|
|
39
|
+
// config.env; the bot then starts again (its service or terminal brings it back) as theirs
|
|
40
|
+
claimOwner: (text, key, id) => {
|
|
41
|
+
const result = tryPairing(store, text);
|
|
42
|
+
if (result === 'ok') {
|
|
43
|
+
setConfigValue(cfg.configFile, key, id);
|
|
44
|
+
log(`paired: ${key} set; starting again as the owner's`);
|
|
45
|
+
setTimeout(() => process.exit(0), 2000);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
37
49
|
});
|
|
38
50
|
|
|
39
51
|
// A bot that is down is a phone that hears nothing, so down time is said in the direct
|
package/config.env.example
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
# (`herdrchat init --profile telegram --chat telegram`).
|
|
8
8
|
HERDRCHAT_CHAT=feishu
|
|
9
9
|
|
|
10
|
-
# The hc sessions to mirror, comma-separated (machines they reach come along).
|
|
10
|
+
# The hc sessions to mirror, comma-separated (machines they reach come along). Empty is
|
|
11
|
+
# hc's own session, `default`: with herdr-app it reaches every session herdr-app shows.
|
|
11
12
|
# `ls ~/.local/state/herdr-core` lists the sessions on this machine.
|
|
12
13
|
HERDR_SESSIONS=
|
|
13
14
|
|
package/docs/guide.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
How herdrchat works and every setting, for when the README is not enough.
|
|
4
4
|
|
|
5
|
-
herdrchat is
|
|
6
|
-
app, Feishu or Telegram. Both sit on the same core (hc events, a reader per agent,
|
|
5
|
+
herdrchat is hc's chat client, as herdr-app is its desktop and phone client: every
|
|
6
|
+
herdr-app tab, followed and answered in a chat app, Feishu or Telegram. Both sit on the same core (hc events, a reader per agent,
|
|
7
7
|
dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack and
|
|
8
8
|
Discord may follow the same way. A profile is one chat app (`HERDRCHAT_CHAT`); to use
|
|
9
9
|
both, run a profile each.
|
|
@@ -85,7 +85,7 @@ In the profile's `config.env` (`herdrchat status` prints where it is):
|
|
|
85
85
|
| Key | For | Default | What it does |
|
|
86
86
|
|---|---|---|---|
|
|
87
87
|
| `HERDRCHAT_CHAT` | both | `feishu` | the chat app this profile mirrors into: `feishu` or `telegram` |
|
|
88
|
-
| `HERDR_SESSIONS` | both |
|
|
88
|
+
| `HERDR_SESSIONS` | both | `default` | the hc sessions to mirror, comma-separated; machines they reach come along. `default` is hc's own session, which with herdr-app reaches every session it shows |
|
|
89
89
|
| `HERDR_MUTED_SPACES` | both | `all` | spaces whose rooms are quiet: only "needs you" and "done" ring; empty for none |
|
|
90
90
|
| `HERDR_SILENT_SPACES` | both | (none) | spaces where nothing rings, not even "needs you" |
|
|
91
91
|
| `HERDR_HISTORY` | both | `file,folded` | what a new room gets of the conversation before it: `file`, `folded`, both, or empty |
|
|
@@ -228,6 +228,11 @@ delivered (the terminal has ended) says so plainly.
|
|
|
228
228
|
dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
|
|
229
229
|
a folded post). Dialog cards have buttons.
|
|
230
230
|
|
|
231
|
+
`herdrchat setup` does the steps below by asking: it checks the token or the Feishu keys
|
|
232
|
+
at once, picks the sessions, starts the bot and pairs you as its owner with a 6-digit
|
|
233
|
+
code you send to the bot, then offers the service. The steps say what happens, and how
|
|
234
|
+
to do it by hand. When the owner is missing later, `herdrchat pair` shows a new code.
|
|
235
|
+
|
|
231
236
|
## Set up: Telegram
|
|
232
237
|
|
|
233
238
|
1. In Telegram, message **@BotFather**: `/newbot`, give it a name and a username ending
|
|
@@ -235,10 +240,10 @@ delivered (the terminal has ended) says so plainly.
|
|
|
235
240
|
2. Turn on topics for the bot: in the BotFather chat tap **Open** (its mini app), pick the
|
|
236
241
|
bot, turn on **Threaded Mode**. Without it the bot cannot make topics.
|
|
237
242
|
3. `herdrchat init --chat telegram` (with `--profile <name>` too if you also run Feishu)
|
|
238
|
-
prints where its `config.env` is. Fill in `TELEGRAM_BOT_TOKEN
|
|
239
|
-
(`ls ~/.local/state/herdr-core` lists
|
|
240
|
-
4. `herdrchat start`, then
|
|
241
|
-
|
|
243
|
+
prints where its `config.env` is. Fill in `TELEGRAM_BOT_TOKEN`. `HERDR_SESSIONS`
|
|
244
|
+
can stay empty for hc's default session (`ls ~/.local/state/herdr-core` lists yours).
|
|
245
|
+
4. `herdrchat start`, then `herdrchat pair`: it shows a 6-digit code. Open the bot (tap
|
|
246
|
+
Start) and send it the code; the bot makes you its owner and starts again. One topic per
|
|
242
247
|
tab appears in your private chat with the bot (Telegram takes about one message a
|
|
243
248
|
second, so many tabs take a moment).
|
|
244
249
|
5. Once it works: `herdrchat install` (see "Running as a service").
|
|
@@ -249,8 +254,9 @@ If Telegram is your only app, use the default profile with `--chat telegram`. Us
|
|
|
249
254
|
## Set up: Feishu
|
|
250
255
|
|
|
251
256
|
Feishu only: Lark (the international version) is not supported yet. Steps 1, 2, 5 and 6
|
|
252
|
-
are in the Feishu developer console (`https://open.feishu.cn/app`)
|
|
253
|
-
(`/herdrchat setup`)
|
|
257
|
+
are in the Feishu developer console (`https://open.feishu.cn/app`). The herdrchat skill
|
|
258
|
+
(`/herdrchat setup`) clicks them for you with the [Ego Lite](https://lite.ego.app/)
|
|
259
|
+
browser, or tells you what to click without it; or follow the steps here yourself.
|
|
254
260
|
|
|
255
261
|
1. Create a custom app (企业自建应用) and add the bot ability (机器人).
|
|
256
262
|
2. Permissions: import the scopes listed in `skills/herdrchat/references/feishu.md`,
|
|
@@ -258,8 +264,8 @@ are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrcha
|
|
|
258
264
|
reading the messages sent to it, making and renaming groups, reactions, images both
|
|
259
265
|
ways, and group tags.
|
|
260
266
|
3. `herdrchat init` prints where its `config.env` is. Fill in the app's App ID and App
|
|
261
|
-
Secret (the app's 凭证与基础信息 page)
|
|
262
|
-
~/.local/state/herdr-core` lists
|
|
267
|
+
Secret (the app's 凭证与基础信息 page). `HERDR_SESSIONS` can stay empty for hc's
|
|
268
|
+
default session (`ls ~/.local/state/herdr-core` lists yours).
|
|
263
269
|
4. `herdrchat start`. The bot must be running for the next step: the console checks for
|
|
264
270
|
a live connection.
|
|
265
271
|
5. Events and callbacks (事件与回调): for events, choose the long connection mode (长连接)
|
|
@@ -267,9 +273,9 @@ are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrcha
|
|
|
267
273
|
mode too and add `card.action.trigger` (the dialog buttons).
|
|
268
274
|
6. Publish a version. If the page does not say 本次发布免审核, your team's admin must
|
|
269
275
|
approve it first.
|
|
270
|
-
7.
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
7. `herdrchat pair`: it shows a 6-digit code. In Feishu, search the app's name, open the
|
|
277
|
+
bot and send it the code; the bot makes you its owner and starts again. One group per
|
|
278
|
+
tab appears. When the code gets no answer, step 5 or 6 is not done.
|
|
273
279
|
8. Optional: send `/auth` to the bot so it can mute its groups for you ("Optional: let
|
|
274
280
|
the bot act as you"), and add the bot menu (`skills/herdrchat/references/feishu.md`,
|
|
275
281
|
"The bot menu").
|
package/lib/chats/contract.mjs
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// working | closed | needs | done | good | bad.
|
|
13
13
|
//
|
|
14
14
|
// ctx: {cfg, store: {read, write}, log, logWarn, logError, stateDir,
|
|
15
|
-
// lane(k, label, fn): run fn in the room's lane, scopeOf(k): its session or machine
|
|
15
|
+
// lane(k, label, fn): run fn in the room's lane, scopeOf(k): its session or machine,
|
|
16
|
+
// claimOwner(text, key, id) -> 'ok' | 'wrong' | 'none': with no owner yet, a message
|
|
17
|
+
// that may be the pairing code `herdrchat setup` shows; on 'ok' the id is written
|
|
18
|
+
// to config.env as `key` and the bot starts again as that owner's}
|
|
16
19
|
|
|
17
20
|
export const CHAT_MEMBERS = {
|
|
18
21
|
name: 'string: the app, as in HERDRCHAT_CHAT',
|
|
@@ -21,7 +24,7 @@ export const CHAT_MEMBERS = {
|
|
|
21
24
|
roomWord: 'string: what the user calls a room in this app (group, topic), for what the bot says',
|
|
22
25
|
liveBytes: 'number: how much text one editable card holds',
|
|
23
26
|
split: '(markdown) -> string[]: one message\'s worth each',
|
|
24
|
-
start: '({onMessage, onAction, onReady}): connect; onMessage({room|null, where, message, text, images}) for the owner\'s messages (room null: the direct chat), onAction({value, message, answer}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks',
|
|
27
|
+
start: '({onMessage, onAction, onReady}): connect; onMessage({room|null, where, message, text, images}) for the owner\'s messages (room null: the direct chat), onAction({value, message, answer, ticks?}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks; ticks: {<option n>: true|false}, a multi-select\'s boxes as ticked on the card itself (a form, sent with Next), when the app has them',
|
|
25
28
|
command: '(cmd, args, where) -> boolean: one of its own direct-chat commands, handled',
|
|
26
29
|
openRoom: '({name, intro}) -> room',
|
|
27
30
|
renameRoom: '(room, name)',
|
|
@@ -102,14 +102,24 @@ function button(text, value, type = 'default') {
|
|
|
102
102
|
return { tag: 'button', type, width: 'fill', text: { tag: 'plain_text', content: short }, behaviors: [{ type: 'callback', value }] };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// A multi-select's boxes are checkers in a form: with no behaviors a checker is ticked on
|
|
106
|
+
// the phone only, so ticking makes no call and shows no loading; Next submits the form,
|
|
107
|
+
// and each box comes back in form_value under its name, box_<n> (ticksOf reads them).
|
|
108
|
+
export const BOX = 'box_';
|
|
105
109
|
export function menuCard({ title, markdown, menu, value }) {
|
|
106
110
|
const elements = [{ tag: 'markdown', content: markdown }];
|
|
111
|
+
const boxes = [];
|
|
107
112
|
for (const o of menu.options) {
|
|
108
113
|
if (o === menu.free) continue;
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (menu.multi && o.checked !== null) {
|
|
115
|
+
boxes.push({ tag: 'checker', name: `${BOX}${o.n}`, checked: o.checked, text: { tag: 'plain_text', content: `${o.n}. ${o.label}` } });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
elements.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
|
|
119
|
+
}
|
|
120
|
+
if (menu.multi) {
|
|
121
|
+
elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, 'primary'), name: 'next', form_action_type: 'submit' }] });
|
|
111
122
|
}
|
|
112
|
-
if (menu.multi) elements.push(button('Next ⇥', { ...value, pick: 'tab' }, 'primary'));
|
|
113
123
|
if (menu.free) {
|
|
114
124
|
elements.push({
|
|
115
125
|
tag: 'form',
|
|
@@ -306,3 +316,12 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, on
|
|
|
306
316
|
});
|
|
307
317
|
return ws;
|
|
308
318
|
}
|
|
319
|
+
|
|
320
|
+
// The boxes as ticked, from a submitted form: {<n>: true|false}, or null when form_value
|
|
321
|
+
// has none of them (then nothing can be known, and nothing should be pressed). A checker's
|
|
322
|
+
// value is read as true when it is true or "true".
|
|
323
|
+
export function ticksOf(formValue) {
|
|
324
|
+
const entries = Object.entries(formValue || {}).filter(([name]) => name.startsWith(BOX));
|
|
325
|
+
if (!entries.length) return null;
|
|
326
|
+
return Object.fromEntries(entries.map(([name, v]) => [Number(name.slice(BOX.length)), v === true || v === 'true']));
|
|
327
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// avatars, and, after /auth, acting as you (muted groups, your labels, pins, @ mentions).
|
|
4
4
|
// The core (lib/core/engine.mjs) calls only what is returned here; see "The chat side"
|
|
5
5
|
// there for what each call means.
|
|
6
|
-
import { createClient, bindTag, chunk, createChat, createTag, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, renameChat, replyMention, sendCard, sendFile, sendMarkdown, setChatAvatar, startEvents, tagName, textCard, unbindTag, updateCard, uploadImage } from './client.mjs';
|
|
6
|
+
import { ticksOf, createClient, bindTag, chunk, createChat, createTag, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, renameChat, replyMention, sendCard, sendFile, sendMarkdown, setChatAvatar, startEvents, tagName, textCard, unbindTag, updateCard, uploadImage } from './client.mjs';
|
|
7
7
|
import { COLORS, avatarPng, letterOf } from './avatar.mjs';
|
|
8
8
|
import { overviewCard, overviewText } from './overview.mjs';
|
|
9
9
|
import { createUserAuth } from './userauth.mjs';
|
|
@@ -28,7 +28,7 @@ const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
|
28
28
|
// room's terminal belongs to (its tag and label).
|
|
29
29
|
export const requires = ['FEISHU_APP_ID', 'FEISHU_APP_SECRET'];
|
|
30
30
|
|
|
31
|
-
export function create({ cfg, store, log, logWarn, logError, stateDir, lane, scopeOf }) {
|
|
31
|
+
export function create({ cfg, store, log, logWarn, logError, stateDir, lane, scopeOf, claimOwner = () => 'none' }) {
|
|
32
32
|
const client = createClient(cfg);
|
|
33
33
|
const userAuth = createUserAuth(cfg, stateDir);
|
|
34
34
|
const { read, write } = store;
|
|
@@ -440,8 +440,13 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
440
440
|
const msg = data.message;
|
|
441
441
|
const openId = data.sender?.sender_id?.open_id;
|
|
442
442
|
if (!cfg.ownerOpenId) {
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
if (msg.chat_type !== 'p2p') return;
|
|
444
|
+
const said = messageParts(msg.message_type, msg.content)?.text || '';
|
|
445
|
+
const paired = said.trim().startsWith('/') ? 'none' : claimOwner(said, 'FEISHU_OWNER_OPEN_ID', openId);
|
|
446
|
+
log(`owner not set; message from open_id ${openId} (pairing: ${paired})`);
|
|
447
|
+
if (paired === 'ok') await reply(msg.chat_id, '✅ Paired: you are this bot\'s owner now. It starts again, and your terminals\' groups appear in a moment.\n\nThen: type in a terminal\'s group to talk to it; here, /ls shows them all, /new starts one, /auth lets the bot mute its groups.');
|
|
448
|
+
else if (paired === 'wrong') await reply(msg.chat_id, 'That is not the code. Send the 6 digits `herdrchat setup` shows.');
|
|
449
|
+
else await reply(msg.chat_id, `Hello! This bot has no owner yet. Run \`herdrchat setup${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\` and send me the code it shows; or add this line to ${cfg.configFile || 'config.env'} yourself and run ${startAgain(cfg)}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\``);
|
|
445
450
|
return;
|
|
446
451
|
}
|
|
447
452
|
if (openId !== cfg.ownerOpenId) {
|
|
@@ -461,7 +466,17 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
461
466
|
// callback response: a toast and, when the card changes, its new form.
|
|
462
467
|
const handleAction = async (data) => {
|
|
463
468
|
if (data.operator?.open_id !== cfg.ownerOpenId) return { toast: { type: 'error', content: 'Only the owner can answer.' } };
|
|
464
|
-
const
|
|
469
|
+
const value = data.action?.value || {};
|
|
470
|
+
const form = data.action?.form_value;
|
|
471
|
+
let ticks = null;
|
|
472
|
+
if (value.pick === 'tab' && form) {
|
|
473
|
+
ticks = ticksOf(form);
|
|
474
|
+
// what each box sent, by type only (never what it says): to see the shape Feishu uses
|
|
475
|
+
log(`card form: ${Object.entries(form).map(([n, v]) => `${n}=${typeof v}:${typeof v === 'boolean' ? v : String(v).length}`).join(' ') || 'empty'}`);
|
|
476
|
+
// no box came back: the ticks are unknown, and a wrong answer is worse than none
|
|
477
|
+
if (!ticks) return { toast: { type: 'error', content: 'No ticked box came through, so nothing was sent. Tick at least one, or answer it in herdr-app.' } };
|
|
478
|
+
}
|
|
479
|
+
const out = await onAction({ value, message: data.context?.open_message_id, answer: String(form?.answer || ''), ticks });
|
|
465
480
|
const res = { toast: { type: out.toast.type, content: out.toast.text } };
|
|
466
481
|
if (out.card?.kind === 'done') res.card = { type: 'raw', data: doneCard(out.card.dialog.doneTitle, out.card.dialog.markdown, out.card.done) };
|
|
467
482
|
if (out.card?.kind === 'dialog') res.card = { type: 'raw', data: menuCard({ title: out.card.dialog.title, markdown: out.card.dialog.markdown, menu: out.card.dialog.menu, value: out.card.value }) };
|
|
@@ -53,7 +53,7 @@ const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
|
53
53
|
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
54
54
|
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
55
55
|
|
|
56
|
-
export function create({ cfg, store, log, logWarn, logError }) {
|
|
56
|
+
export function create({ cfg, store, log, logWarn, logError, claimOwner = () => 'none' }) {
|
|
57
57
|
const env = cfg.env || {};
|
|
58
58
|
const owner = String(env.TELEGRAM_OWNER_ID || '').trim();
|
|
59
59
|
const tg = createClient(env.TELEGRAM_BOT_TOKEN, { log: logWarn });
|
|
@@ -331,8 +331,13 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
331
331
|
const from = String(m.from?.id || '');
|
|
332
332
|
const where = `${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`;
|
|
333
333
|
if (!owner) {
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
if (m.chat.type !== 'private') return;
|
|
335
|
+
// Telegram sends /start when someone first opens the bot: a command is never a guess
|
|
336
|
+
const paired = String(m.text || '').startsWith('/') ? 'none' : claimOwner(m.text, 'TELEGRAM_OWNER_ID', from);
|
|
337
|
+
log(`owner not set; message from user id ${from} (pairing: ${paired})`);
|
|
338
|
+
if (paired === 'ok') await reply(where, '✅ Paired: you are this bot\'s owner now. It starts again, and your terminals\' topics appear in a moment.\n\nThen: type in a terminal\'s topic to talk to it; here, /ls shows them all, /new starts one.');
|
|
339
|
+
else if (paired === 'wrong') await reply(where, 'That is not the code. Send the 6 digits `herdrchat setup` shows.');
|
|
340
|
+
else await reply(where, `Hello! This bot has no owner yet. Run \`herdrchat setup${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\` and send me the code it shows; or add this line to ${cfg.configFile || 'config.env'} yourself and run ${startAgain(cfg)}:\n\n\`TELEGRAM_OWNER_ID=${from}\``);
|
|
336
341
|
return;
|
|
337
342
|
}
|
|
338
343
|
if (from !== owner || String(m.chat.id) !== owner) {
|
package/lib/core/config.mjs
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// FEISHU_OWNER_OPEN_ID your open_id for that app; the only person it listens to
|
|
12
12
|
// TELEGRAM_BOT_TOKEN herdrchat's Telegram bot (topic mode on, in @BotFather)
|
|
13
13
|
// TELEGRAM_OWNER_ID your Telegram user id; the only person it listens to
|
|
14
|
-
// HERDR_SESSIONS hc sessions to mirror, comma-separated
|
|
14
|
+
// HERDR_SESSIONS hc sessions to mirror, comma-separated; empty: `default`,
|
|
15
|
+
// hc's own (with herdr-app it reaches every session it shows)
|
|
15
16
|
// HERDR_MUTED_SPACES spaces whose rooms are quiet, so only "needs you" and
|
|
16
17
|
// "done" ring (Feishu: groups muted, once /auth is done,
|
|
17
18
|
// and an @; Telegram: silent posts and a reply that
|
|
@@ -76,6 +77,20 @@ function parseEnv(file) {
|
|
|
76
77
|
return env;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
// Sets one key in a config.env, keeping every other line (and its comments) as it was:
|
|
81
|
+
// the key's line is replaced, or the key is added at the end. The file stays private.
|
|
82
|
+
export function setConfigValue(file, key, value) {
|
|
83
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
|
|
84
|
+
const at = lines.findIndex((l) => new RegExp(`^\\s*${key}\\s*=`).test(l));
|
|
85
|
+
if (at >= 0) lines[at] = `${key}=${value}`;
|
|
86
|
+
else {
|
|
87
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
88
|
+
lines.push(`${key}=${value}`, '');
|
|
89
|
+
}
|
|
90
|
+
fs.writeFileSync(file, lines.join('\n'), { mode: 0o600 });
|
|
91
|
+
fs.chmodSync(file, 0o600);
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
// A list of spaces: `all`, empty or `none` for none, or the spaces listed. `fallback` is
|
|
80
95
|
// what an unset variable means.
|
|
81
96
|
export function spacesOf(value, fallback) {
|
|
@@ -105,19 +120,23 @@ export function onOff(value, fallback) {
|
|
|
105
120
|
|
|
106
121
|
// `requires`: config keys the chat app cannot start without (its module's `requires`).
|
|
107
122
|
// `env` is everything config.env and the environment hold, for the app's own keys.
|
|
108
|
-
//
|
|
123
|
+
// An empty HERDR_SESSIONS means hc's own session, `default` (`sessionsSet` says whether
|
|
124
|
+
// it was named). `sessions: false` is kept for callers that only read what is set.
|
|
125
|
+
export const DEFAULT_SESSION = 'default';
|
|
109
126
|
export function loadConfig({ requires = [], sessions: needSessions = true } = {}) {
|
|
110
127
|
const env = { ...parseEnv(CONFIG_FILE), ...process.env };
|
|
111
|
-
const
|
|
128
|
+
const named = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
129
|
+
const sessions = named.length ? named : [DEFAULT_SESSION];
|
|
112
130
|
const missing = requires.filter((k) => !env[k]);
|
|
113
131
|
if (missing.length) throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} empty in ${CONFIG_FILE}: fill ${missing.length > 1 ? 'them' : 'it'} in (the comment above each says where it comes from), then start again.`);
|
|
114
|
-
|
|
132
|
+
void needSessions; // an empty list is `default` now: never an error here
|
|
115
133
|
return {
|
|
116
134
|
profile: places.profile,
|
|
117
135
|
configFile: CONFIG_FILE,
|
|
118
136
|
chat: env.HERDRCHAT_CHAT || 'feishu',
|
|
119
137
|
env,
|
|
120
138
|
sessions,
|
|
139
|
+
sessionsSet: named.length > 0,
|
|
121
140
|
appId: env.FEISHU_APP_ID || '',
|
|
122
141
|
appSecret: env.FEISHU_APP_SECRET || '',
|
|
123
142
|
ownerOpenId: env.FEISHU_OWNER_OPEN_ID || '',
|
package/lib/core/engine.mjs
CHANGED
|
@@ -567,7 +567,8 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
567
567
|
const markdown = menu ? escapeMarkdown(menu.text) : `\`\`\`\n${tidyScreen(screenText, DIALOG_LINES)}\n\`\`\``;
|
|
568
568
|
return { menu, title, markdown, doneTitle: DONE_TITLE };
|
|
569
569
|
}
|
|
570
|
-
|
|
570
|
+
// c: the terminal's width when the card was made, to tell a resize from a new dialog
|
|
571
|
+
const dialogValue = (session, address, dialog, cols) => ({ s: session, a: address, k: dialog.menu.key, ...(cols ? { c: cols } : {}) });
|
|
571
572
|
|
|
572
573
|
// One dialog gets one card. A dialog is named by its menu key (or, with no menu, by its
|
|
573
574
|
// status words); the name is claimed before anything is sent, so the turn event, a
|
|
@@ -620,6 +621,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
620
621
|
return;
|
|
621
622
|
}
|
|
622
623
|
if (!claimDialog(k, dialog, record.status) && !again) return;
|
|
624
|
+
try {
|
|
625
|
+
await sendBlocked(session, address, record, k, dialog);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
// not posted: let the next event (or a held message) post it, never "posted already"
|
|
628
|
+
if (postedDialog.get(k) === dialogName(dialog, record.status)) postedDialog.delete(k);
|
|
629
|
+
throw err;
|
|
630
|
+
}
|
|
631
|
+
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
632
|
+
}
|
|
633
|
+
async function sendBlocked(session, address, record, k, dialog) {
|
|
623
634
|
await closeCard(k, 'It moved on; see the next card.');
|
|
624
635
|
if (!dialog.menu) {
|
|
625
636
|
const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
|
|
@@ -627,7 +638,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
627
638
|
if (room) await chat.pin(k, room, sent);
|
|
628
639
|
await chat.ring(k, sent, '⚠️ needs you');
|
|
629
640
|
} else {
|
|
630
|
-
const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog)));
|
|
641
|
+
const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog, record.cols)));
|
|
631
642
|
if (message) {
|
|
632
643
|
remember(message, dialog);
|
|
633
644
|
openCard.set(k, { message, dialog });
|
|
@@ -637,9 +648,10 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
637
648
|
// answered while the card was on its way: nothing will close it later, so now
|
|
638
649
|
const live = views.get(session)?.terminals.get(address);
|
|
639
650
|
if (live?.status?.state !== 'blocked' || postedDialog.get(k) !== dialogName(dialog, record.status)) await closeCard(k, '✔ Answered.');
|
|
651
|
+
} else {
|
|
652
|
+
throw new Error('the room for its card is gone');
|
|
640
653
|
}
|
|
641
654
|
}
|
|
642
|
-
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
643
655
|
}
|
|
644
656
|
|
|
645
657
|
// The keys that pick an option: its digit, or, in a question with previews (which takes
|
|
@@ -664,7 +676,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
664
676
|
// unless it still shows the dialog the card was made from (same key) with the same
|
|
665
677
|
// option. After the key, the card turns into the dialog's next step (the next question,
|
|
666
678
|
// the review, a toggled box) or says what was picked.
|
|
667
|
-
async function onAction({ value: v = {}, message, answer = '' }) {
|
|
679
|
+
async function onAction({ value: v = {}, message, answer = '', ticks = null }) {
|
|
668
680
|
const started = Date.now();
|
|
669
681
|
const shown = dialogCards.get(message) || { title: NEEDS_TITLE, markdown: '', doneTitle: DONE_TITLE };
|
|
670
682
|
// A box in a multi-select is ticked on the card only, at once; Next sends them all.
|
|
@@ -680,25 +692,37 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
680
692
|
log(`-> ${v.s}/${v.a}: click on ${message} not pressed: the terminal has ended`);
|
|
681
693
|
return answered(shown, 'The terminal has ended.');
|
|
682
694
|
}
|
|
683
|
-
const
|
|
695
|
+
const term = await hc.getTerminal(v.s, v.a);
|
|
696
|
+
const status = term.status;
|
|
684
697
|
const menu = status?.state === 'blocked' ? menuOf(await hc.screenTail(v.s, v.a, SCREEN_LINES)) : null;
|
|
698
|
+
const k = key(v.s, v.a);
|
|
685
699
|
if (!menu || menu.key !== v.k) {
|
|
686
|
-
log(`-> ${v.s}/${v.a}: click on ${message} not pressed: ${menu ? `the dialog changed (card ${v.k}, screen ${menu.key})` : `nothing waits (status ${status?.state || 'none'})`}`);
|
|
687
|
-
if (menu)
|
|
688
|
-
|
|
700
|
+
log(`-> ${v.s}/${v.a}: click on ${message} not pressed: ${menu ? `the dialog changed (card ${v.k}, screen ${menu.key}; width then ${v.c || '?'}, now ${term.cols || '?'})` : `nothing waits (status ${status?.state || 'none'})`}`);
|
|
701
|
+
if (!menu) return answered(shown, 'It no longer waits on this.');
|
|
702
|
+
// The card in your hand becomes what the screen asks now, to tap again. Never "see the
|
|
703
|
+
// new card": a new card can be held back as already posted, and then nothing is left
|
|
704
|
+
// to answer while your messages wait on the dialog.
|
|
705
|
+
const now = dialogOf(status, await hc.screenTail(v.s, v.a, SCREEN_LINES));
|
|
706
|
+
if (!now.menu) return answered(shown, 'It no longer waits on this.');
|
|
707
|
+
if (openCard.get(k) && openCard.get(k).message !== message) await closeCard(k, 'It moved on; see the next card.');
|
|
708
|
+
postedDialog.set(k, dialogName(now, status));
|
|
709
|
+
remember(message, now);
|
|
710
|
+
openCard.set(k, { message, dialog: now });
|
|
711
|
+
return { ...toast('warning', 'It changed before your tap, so nothing was pressed. The card shows it now: tap again.'), card: { kind: 'dialog', dialog: now, value: dialogValue(v.s, v.a, now, term.cols) } };
|
|
689
712
|
}
|
|
690
|
-
const k = key(v.s, v.a);
|
|
691
713
|
if (openCard.get(k)?.message === message) openCard.delete(k);
|
|
692
714
|
let picked;
|
|
693
715
|
if (v.pick === 'tab' && shown.menu?.multi) {
|
|
694
|
-
// the boxes the card
|
|
695
|
-
|
|
716
|
+
// the boxes as ticked on the card (sent with Next by a form, or kept per tap), made
|
|
717
|
+
// so on the screen: each one that differs is pressed
|
|
718
|
+
const wanted = ticks ? { ...shown.menu, options: shown.menu.options.map((o) => (o.checked === null ? o : { ...o, checked: Boolean(ticks[o.n]) })) } : shown.menu;
|
|
719
|
+
const changed = wanted.options.filter((o) => o.checked !== null && o.checked !== menu.options.find((m) => m.n === o.n)?.checked);
|
|
696
720
|
for (const o of changed) {
|
|
697
721
|
await hc.keys(v.s, v.a, [String(o.n)]);
|
|
698
722
|
await sleep(BOX_GAP_MS);
|
|
699
723
|
}
|
|
700
724
|
await hc.keys(v.s, v.a, ['tab']);
|
|
701
|
-
const ticked =
|
|
725
|
+
const ticked = wanted.options.filter((o) => o.checked).map((o) => o.label);
|
|
702
726
|
picked = ticked.length ? ticked.join(', ') : 'nothing ticked';
|
|
703
727
|
} else if (v.pick === 'esc' || v.pick === 'tab') {
|
|
704
728
|
await hc.keys(v.s, v.a, [v.pick]);
|
|
@@ -738,9 +762,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
738
762
|
if (next.menu && (next.menu.key === v.k || claimDialog(k, next, nextStatus))) {
|
|
739
763
|
remember(message, next);
|
|
740
764
|
openCard.set(k, { message, dialog: next });
|
|
741
|
-
return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next) } };
|
|
765
|
+
return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next, term.cols) } };
|
|
766
|
+
}
|
|
767
|
+
// its card is out already, unless that card was closed: then this one shows it
|
|
768
|
+
if (next.menu && openCard.has(k)) return answered(shown, `✔ ${picked}`);
|
|
769
|
+
if (next.menu) {
|
|
770
|
+
postedDialog.set(k, dialogName(next, nextStatus));
|
|
771
|
+
remember(message, next);
|
|
772
|
+
openCard.set(k, { message, dialog: next });
|
|
773
|
+
return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next, term.cols) } };
|
|
742
774
|
}
|
|
743
|
-
if (next.menu) return answered(shown, `✔ ${picked}`); // its card is out already
|
|
744
775
|
// still waiting, on something with no menu: hc sends no new turn for it, so post it
|
|
745
776
|
later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status: nextStatus }));
|
|
746
777
|
}
|
|
@@ -939,9 +970,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
939
970
|
log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
|
|
940
971
|
await markWorking(key(session, address), message);
|
|
941
972
|
} else {
|
|
942
|
-
|
|
973
|
+
const k = key(session, address);
|
|
974
|
+
hold(k, { where, message, session, address, text });
|
|
943
975
|
log(`-> ${session}/${address}: held until its dialog is answered: ${text.slice(0, 80)}`);
|
|
944
|
-
|
|
976
|
+
// "the card above" must be there: with a menu and no open card, post it now
|
|
977
|
+
const status = (await hc.getTerminal(session, address).catch(() => record))?.status;
|
|
978
|
+
if (menu && !openCard.has(k)) {
|
|
979
|
+
logWarn(`${session}/${address}: waits on a dialog with no open card; posting it again`);
|
|
980
|
+
await postBlocked(session, address, { ...record, status }, { again: true }).catch((err) => logError(`dialog card for ${session}/${address} not posted (${err.message})`));
|
|
981
|
+
}
|
|
982
|
+
await chat.reply(where, openCard.has(k) || !menu ? 'It asks something first (the card above). Your message goes in once that is answered.' : 'It asks something first, in herdr-app (its card could not be posted here). Your message goes in once that is answered.');
|
|
945
983
|
}
|
|
946
984
|
return;
|
|
947
985
|
}
|
package/lib/core/menu.mjs
CHANGED
|
@@ -39,8 +39,10 @@ function topOf(lines, first) {
|
|
|
39
39
|
|
|
40
40
|
const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
|
|
41
41
|
|
|
42
|
+
// Joined with spaces, not line breaks: the same dialog wraps at other places when the
|
|
43
|
+
// terminal is resized (herdr-app opened on a phone), and it is still the same dialog.
|
|
42
44
|
function keyOf(lines, top, last) {
|
|
43
|
-
const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '').replace(/\s+/g, ' ').trim()
|
|
45
|
+
const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
|
|
44
46
|
return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Pairing: how the bot learns who its owner is without anyone copying an id. `herdrchat
|
|
2
|
+
// setup` shows a short code and keeps it in state.json; the first person who sends that
|
|
3
|
+
// code to the bot becomes its owner. The code lasts PAIR_MINUTES and allows PAIR_TRIES
|
|
4
|
+
// wrong guesses, so nobody can guess their way in.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export const PAIR_MINUTES = 15;
|
|
8
|
+
export const PAIR_TRIES = 5;
|
|
9
|
+
|
|
10
|
+
// store: {read, write} of state.json
|
|
11
|
+
export function startPairing(store, now = Date.now()) {
|
|
12
|
+
const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
13
|
+
const state = store.read();
|
|
14
|
+
state.pairing = { code, until: now + PAIR_MINUTES * 60 * 1000, tries: 0 };
|
|
15
|
+
store.write(state);
|
|
16
|
+
return code;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 'ok' (the code, used up), 'wrong' (a guess counted), or 'none' (no pairing running)
|
|
20
|
+
export function tryPairing(store, text, now = Date.now()) {
|
|
21
|
+
const state = store.read();
|
|
22
|
+
const p = state.pairing;
|
|
23
|
+
if (!p || now > p.until) return 'none';
|
|
24
|
+
if (String(text || '').trim() === p.code) {
|
|
25
|
+
delete state.pairing;
|
|
26
|
+
store.write(state);
|
|
27
|
+
return 'ok';
|
|
28
|
+
}
|
|
29
|
+
p.tries = (p.tries || 0) + 1;
|
|
30
|
+
if (p.tries >= PAIR_TRIES) delete state.pairing;
|
|
31
|
+
store.write(state);
|
|
32
|
+
return 'wrong';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const pairingRunning = (store, now = Date.now()) => Boolean(store.read().pairing && now <= store.read().pairing.until);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdrchat",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "The chat client for hc (herdr): see and answer every hc terminal from Feishu or Telegram",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/zhangxiangliang/herdrchat.git"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: herdrchat
|
|
3
3
|
description: >-
|
|
4
|
-
Set up, run and look after herdrchat, which mirrors every herdr-app
|
|
4
|
+
Set up, run and look after herdrchat, the chat client for hc (herdr), which mirrors every herdr-app tab into
|
|
5
5
|
Feishu (a group per tab) or Telegram (a topic per tab): first setup of either app,
|
|
6
6
|
start, stop, status, logs, running it as a service, settings, fixing problems, the
|
|
7
7
|
Feishu developer console and @BotFather. Use for anything about herdrchat or its
|
|
@@ -29,7 +29,7 @@ from the package (`config.env.example`, `docs/guide.md`) are in the installed pa
|
|
|
29
29
|
|
|
30
30
|
| Command | Do |
|
|
31
31
|
|---|---|
|
|
32
|
-
| `/herdrchat setup` | First check herdr is there (`command -v hc`): herdrchat is built on herdr and does nothing without it
|
|
32
|
+
| `/herdrchat setup` | First check herdr is there (`command -v hc`): herdrchat is built on herdr and does nothing without it. When it is missing, offer to install it and, on a yes, follow `references/herdr.md` (from herdr's own instructions, read now, each command shown and agreed first). Then ask which chat app (Feishu, Telegram, or both: the second one on its own profile). Telegram: `/herdrchat telegram setup`. Feishu, in this order (with ego-browser the agent clicks the console; without it, offer to install Ego Lite or walk the user through it, `references/feishu.md` "Before anything"): create the app, the bot ability and the scopes; `herdrchat init` and fill `config.env` with the user (App ID, App Secret; `HERDR_SESSIONS` empty means hc's default session, `ls ~/.local/state/herdr-core` lists the sessions); `herdrchat start` (the console's long-connection check needs it running); events and callbacks; publish a version; `herdrchat pair` (in the background: it prints a 6-digit code and waits; the user sends the code to the bot, which writes `FEISHU_OWNER_OPEN_ID` itself and starts again; it also proves the events work); offer `/herdrchat auth` and `herdrchat install`. Never run `herdrchat setup` yourself: it asks questions in a terminal, and this shell has none. It is the same steps for a user without an agent; yours add the Feishu console through ego-browser. When there is no hc session yet, say so and stop until herdr runs one. |
|
|
33
33
|
| `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
|
|
34
34
|
| `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. With a service installed they go through it. |
|
|
35
35
|
| `/herdrchat install`, `/herdrchat uninstall` | Run it as a service of the system (launchd on macOS, systemd on Linux): it starts at login and comes back after a crash, whatever happens to the hc cores. Recommended once it works. Read the config first; after a node upgrade, install again (`herdrchat status` says so). |
|
|
@@ -55,6 +55,8 @@ With no subcommand, run `herdrchat status` and suggest the next step from it.
|
|
|
55
55
|
|
|
56
56
|
- `references/feishu.md`: the bot's app in the Feishu developer console, with Ego or as a
|
|
57
57
|
guided walk-through; the scopes this bot uses.
|
|
58
|
+
- `references/herdr.md`: what herdrchat is built on, and installing herdr with the user
|
|
59
|
+
when hc is missing.
|
|
58
60
|
- `references/telegram.md`: the Telegram bot, from @BotFather to the first topic; what
|
|
59
61
|
it does differently from Feishu.
|
|
60
62
|
- `references/ops.md`: running and checking the bot, its logs and state, the usual
|
|
@@ -6,13 +6,20 @@ that with ego-browser, in the user's own logged-in browser.
|
|
|
6
6
|
|
|
7
7
|
## Before anything
|
|
8
8
|
|
|
9
|
-
1. **ego-browser.**
|
|
9
|
+
1. **ego-browser, or the guide.** Only Feishu needs this (Telegram's bot is made in a
|
|
10
|
+
chat, with no browser). Run `command -v ego-browser`. When it is there, run
|
|
10
11
|
`ego-browser skill` and follow its usage guide. When it is missing, do not fall back
|
|
11
12
|
to another browser; ask the user which they want:
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
- **Install Ego Lite**, so the agent does the clicking. Use Ego's own instructions,
|
|
14
|
+
read now at https://lite.ego.app/ (not from memory): say what it installs, and
|
|
15
|
+
install only on a yes. Then check `command -v ego-browser` again (a new folder may
|
|
16
|
+
need a new shell), and have the user sign in to Feishu in Ego as an admin of the
|
|
17
|
+
tenant. If any of it fails or the user stops, take the guide below; do not retry
|
|
18
|
+
other ways.
|
|
19
|
+
- **The guide** ("Guide mode" below): the user clicks, the agent says what to click.
|
|
20
|
+
Either way is the same procedures. When Ego stops working in the middle (signed out,
|
|
21
|
+
a page that does not match), say so and go on in guide mode from that step: what is
|
|
22
|
+
done stays done.
|
|
16
23
|
2. **Which profile.** Ask which Ego profile is signed in to Feishu as an admin of the
|
|
17
24
|
tenant that owns the app (`await profiles()` lists them); never guess. Create one task
|
|
18
25
|
space for the whole job with that `profileId`, and close it at the end with
|
|
@@ -20,7 +27,9 @@ that with ego-browser, in the user's own logged-in browser.
|
|
|
20
27
|
3. **Which app.** The app id is `FEISHU_APP_ID` in the profile's `config.env`
|
|
21
28
|
(`~/.config/herdrchat/<profile>/config.env`, or in `HERDRCHAT_HOME`). Read only that
|
|
22
29
|
key; never print, screenshot or paste `FEISHU_APP_SECRET`, and never write a real app
|
|
23
|
-
id, open_id, chat id or tenant name into a file of this repo.
|
|
30
|
+
id, open_id, chat id or tenant name into a file of this repo. With Ego too, the agent
|
|
31
|
+
does not read or reveal the secret on the credentials page: the user copies it into
|
|
32
|
+
`config.env` themselves (the agent says the file and the line).
|
|
24
33
|
4. **Ask first** before anything outward-facing: publishing a version, applying scopes,
|
|
25
34
|
deleting or deactivating an app. One yes covers one action.
|
|
26
35
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# herdr: what herdrchat is built on, and putting it in place
|
|
2
|
+
|
|
3
|
+
herdrchat mirrors the terminals that **hc** (herdr-core) runs; **herdr-app** is herdr's
|
|
4
|
+
desktop and phone app that shows them in spaces and tabs. Without hc on the machine
|
|
5
|
+
herdrchat does nothing. Check: `command -v hc && hc --version`.
|
|
6
|
+
|
|
7
|
+
When hc is missing, help the user install it. Do it from herdr's own instructions, read
|
|
8
|
+
now, never from memory or a script kept here: install steps change, and a fixed script
|
|
9
|
+
breaks on another system or version.
|
|
10
|
+
|
|
11
|
+
## Installing it, with the user
|
|
12
|
+
|
|
13
|
+
1. **Say what and why.** "herdrchat is built on herdr: it mirrors the terminals that hc
|
|
14
|
+
runs. hc is not on this machine. Shall I install herdr for you?" Wait for a yes.
|
|
15
|
+
2. **Read the official way, now.** Open https://herdr.dev and follow its install or
|
|
16
|
+
download instructions for this system (macOS or Linux; say so if it is neither). Use
|
|
17
|
+
only what herdr's own site or its official releases give. If the page does not say
|
|
18
|
+
how, stop and tell the user where you looked.
|
|
19
|
+
3. **Show before running.** Give the user the exact command or download, where it comes
|
|
20
|
+
from, where it installs, and what it changes (a folder, the PATH, a login item).
|
|
21
|
+
When the instructions pipe a script into a shell, download the script first and read
|
|
22
|
+
it, then say what it does. Run it only after the user says yes to that command.
|
|
23
|
+
4. **Check it worked.** `hc --version` answers. If hc is not found, the new folder may
|
|
24
|
+
not be on PATH yet in this shell: say which folder, and how the instructions add it.
|
|
25
|
+
5. **A first session.** `hc ls` (or the instructions' own first step) starts the default
|
|
26
|
+
session; `ls ~/.local/state/herdr-core` then lists it. herdrchat's `HERDR_SESSIONS`
|
|
27
|
+
names the sessions to mirror. herdr-app is optional for herdrchat but is how terminals
|
|
28
|
+
get their space and tab names; offer it when the user wants the desktop app too.
|
|
29
|
+
6. **On a failure, stop.** Show the error as it is and what was tried. Do not try other
|
|
30
|
+
install ways the user did not agree to, and never run anything with sudo without
|
|
31
|
+
asking for that command.
|
|
32
|
+
|
|
33
|
+
Then go back to `/herdrchat setup`.
|
|
@@ -79,7 +79,7 @@ your token after `/auth`: never print it.
|
|
|
79
79
|
|
|
80
80
|
| You see | Likely cause | Do |
|
|
81
81
|
|---|---|---|
|
|
82
|
-
| nothing posts | bot down, or the owner not set (`FEISHU_OWNER_OPEN_ID`, `TELEGRAM_OWNER_ID`) | `herdrchat status`; the log says "owner not set" |
|
|
82
|
+
| nothing posts | bot down, or the owner not set (`FEISHU_OWNER_OPEN_ID`, `TELEGRAM_OWNER_ID`) | `herdrchat status`; the log says "owner not set": `herdrchat pair` |
|
|
83
83
|
| Telegram: no topic appears | topic mode off | log: "topic mode is off"; BotFather's mini app, the bot, Threaded Mode, then start again |
|
|
84
84
|
| Telegram: `telegram poll: ... 409` | another copy polls the same token | stop it (another machine?), then start once |
|
|
85
85
|
| a button gives error 200340 | the card callback is not set up | `references/feishu.md`: events and card callbacks |
|
|
@@ -19,18 +19,20 @@ Do these in order; the user does the Telegram steps, you check each one.
|
|
|
19
19
|
2. **The config.** `herdrchat init --chat telegram P` makes the profile's `config.env`
|
|
20
20
|
(private) and prints where it is. Ask the user to paste the token after
|
|
21
21
|
`TELEGRAM_BOT_TOKEN=` themselves (`open -e <file>` opens it), no quotes, no spaces, and
|
|
22
|
-
|
|
22
|
+
leave `HERDR_SESSIONS` empty for hc's default session, or name others
|
|
23
|
+
(`ls ~/.local/state/herdr-core` lists the sessions).
|
|
23
24
|
3. **Topic mode, checked before the first start.** It is not in BotFather's `/mybots` →
|
|
24
25
|
Bot Settings list; it is in BotFather's mini app (the **Open** button at the bottom
|
|
25
26
|
left of the BotFather chat): pick the bot, turn on Threaded Mode. Check it without
|
|
26
27
|
printing the token: a short `node -e` reads the token from the config, calls
|
|
27
28
|
`https://api.telegram.org/bot<token>/getMe` and prints only `username` and
|
|
28
29
|
`has_topics_enabled`, which must be `true`.
|
|
29
|
-
4. **
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
4. **Start, then pair the owner.** Read the config (secrets hidden) in one step, then
|
|
31
|
+
`herdrchat start P` in the next. Then run `herdrchat pair P` in the background: it
|
|
32
|
+
prints a 6-digit code and waits. Tell the user the code; they open the bot (tap
|
|
33
|
+
Start), send it, and the bot writes `TELEGRAM_OWNER_ID` itself and starts again as
|
|
34
|
+
theirs. Until then the bot makes no topic. No id to copy.
|
|
35
|
+
5. **The topics.** One topic per tab appears; Telegram takes about one
|
|
34
36
|
message a second in a chat, so twenty tabs take twenty seconds. A new topic gets the
|
|
35
37
|
conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it again.
|
|
36
38
|
6. **Make it a service.** Once it works: `herdrchat install P`. It then starts at login
|
|
@@ -68,7 +70,7 @@ without muting each topic by hand.
|
|
|
68
70
|
| Seen | Cause | Do |
|
|
69
71
|
|---|---|---|
|
|
70
72
|
| log: `topic mode is off for @<bot>` | step 3 not done | turn it on in BotFather's mini app, check `has_topics_enabled`, start again |
|
|
71
|
-
| the bot answers "
|
|
73
|
+
| the bot answers "This bot has no owner yet" | `TELEGRAM_OWNER_ID` empty | step 4: `herdrchat pair P` |
|
|
72
74
|
| `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 3) |
|
|
73
75
|
| log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop P`, find the other one (another machine?), start once |
|
|
74
76
|
| log: `too fast, waiting N s` | Telegram's flood limit; the bot waits as told | nothing; it catches up |
|