herdrchat 1.1.1 → 1.2.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 +17 -11
- package/README.zh.md +11 -8
- package/bin/cli.mjs +110 -40
- package/config.env.example +4 -3
- package/lib/chats/telegram/index.mjs +33 -19
- package/lib/core/config.mjs +6 -4
- package/lib/core/engine.mjs +35 -4
- package/lib/core/menu.mjs +17 -1
- package/lib/core/service.mjs +165 -0
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/herdrchat/SKILL.md +18 -10
- package/skills/herdrchat/references/ops.md +5 -3
- package/skills/herdrchat/references/telegram.md +77 -23
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# herdrchat
|
|
4
4
|
|
|
5
|
-
Every terminal hc runs, in the chat app you already use
|
|
5
|
+
Every terminal hc runs, in the chat app you already use: Feishu or Telegram.
|
|
6
6
|
|
|
7
7
|
[English](README.md) · [简体中文](README.zh.md)
|
|
8
8
|
|
|
@@ -16,13 +16,15 @@ herdr-app has official phone and desktop apps. But to use them away from your de
|
|
|
16
16
|
phone has to reach the machine that runs hc, so you first need a VPN or a private network.
|
|
17
17
|
And on a small phone screen, working with an agent is clumsy.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
nothing to set up on your network: no public IP, no VPN. Each
|
|
21
|
-
group
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
Your chat app is already on your phone. herdrchat connects out to it by itself, so there
|
|
20
|
+
is nothing to set up on your network: no public IP, no VPN. Each herdr-app tab becomes a
|
|
21
|
+
Feishu group, or a topic in your private chat with the Telegram bot. You read what the
|
|
22
|
+
agent says while it works, tap buttons to answer its questions, send and receive images,
|
|
23
|
+
and see all terminals on one overview. A new room starts with the conversation so far.
|
|
24
|
+
Your phone rings only when an agent needs you or is done. It works with any agent hc
|
|
25
|
+
runs.
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
Use one app or both at once (a profile each). Slack and Discord may follow.
|
|
26
28
|
|
|
27
29
|
## Install
|
|
28
30
|
|
|
@@ -35,18 +37,22 @@ Then ask your agent:
|
|
|
35
37
|
|
|
36
38
|
| Command | What it does |
|
|
37
39
|
|---|---|
|
|
38
|
-
| `/herdrchat setup` | Sets everything up with you: the Feishu app, the settings, the first start. |
|
|
40
|
+
| `/herdrchat setup` | Sets everything up with you: the Feishu app or the Telegram bot, the settings, the first start. |
|
|
39
41
|
| `/herdrchat status` | Says whether it runs, and what is wrong if anything is. |
|
|
40
42
|
| `/herdrchat fix` | Finds the cause of a problem and fixes it. |
|
|
41
43
|
| `/herdrchat config` | Changes a setting: which terminals, which groups stay quiet, tags. |
|
|
42
44
|
| `/herdrchat feishu <task>` | Does a task in the Feishu developer console for you. |
|
|
45
|
+
| `/herdrchat telegram <task>` | The Telegram bot, from @BotFather to the first topic. |
|
|
43
46
|
|
|
44
|
-
On the command line, without an agent: `herdrchat init`, `start`, `stop`, `status`, `logs
|
|
47
|
+
On the command line, without an agent: `herdrchat init`, `start`, `stop`, `status`, `logs`;
|
|
48
|
+
`herdrchat install` runs it as a service that starts at login and comes back after a
|
|
49
|
+
crash. Add `--profile <name>` for a second chat app.
|
|
45
50
|
|
|
46
51
|
## Where things are
|
|
47
52
|
|
|
48
|
-
Settings in `~/.config/herdrchat
|
|
49
|
-
`~/.local/state/herdrchat
|
|
53
|
+
Settings in `~/.config/herdrchat/<profile>/config.env`, everything the bot writes in
|
|
54
|
+
`~/.local/state/herdrchat/<profile>/` (the profile is `default` unless you name one).
|
|
55
|
+
Updating or reinstalling herdrchat never touches them.
|
|
50
56
|
|
|
51
57
|
How it works and every setting: [docs/guide.md](docs/guide.md).
|
|
52
58
|
|
package/README.zh.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# herdrchat
|
|
4
4
|
|
|
5
|
-
hc
|
|
5
|
+
hc 跑的每个终端,都能在你常用的聊天软件里看到和回复:飞书或 Telegram。
|
|
6
6
|
|
|
7
7
|
[English](README.md) · [简体中文](README.zh.md)
|
|
8
8
|
|
|
@@ -15,12 +15,13 @@ hc 跑的每个终端,都能在你常用的聊天软件里看到和回复。
|
|
|
15
15
|
herdr-app 官方有手机端和电脑端。但想在外面用,手机得能连到跑 hc 的机器,
|
|
16
16
|
所以要先折腾 VPN 或内网环境;而且在手机的小屏幕上和 agent 交互,体验并不好。
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
聊天软件本来就在你手机上。herdrchat 自己主动连过去,网络上什么都不用配:不要公网 IP,也不要 VPN。
|
|
19
|
+
herdr-app 的每个标签页,在飞书里是一个群,在 Telegram 里是和 bot 私聊中的一个话题:
|
|
20
|
+
agent 干活时说的话实时可见,它问你的问题点按钮就能回答,图片双向都能传,所有终端汇总在一张总览里。
|
|
21
|
+
新开的群或话题会先补上之前的对话。只有 agent 需要你、或者做完的时候手机才会响。
|
|
21
22
|
hc 能跑的 agent 都能用,我们自己用下来非常顺畅。
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
可以只用一个,也可以两个同时用(各开一个 profile)。后面有精力的话,也会考虑接入 Slack、Discord。
|
|
24
25
|
|
|
25
26
|
## 安装
|
|
26
27
|
|
|
@@ -33,17 +34,19 @@ npm install -g herdrchat # skill 要用到的 herdrchat 命令
|
|
|
33
34
|
|
|
34
35
|
| 命令 | 做什么 |
|
|
35
36
|
|---|---|
|
|
36
|
-
| `/herdrchat setup` |
|
|
37
|
+
| `/herdrchat setup` | 和你一起从零配好:飞书应用或 Telegram bot、配置、第一次启动。 |
|
|
37
38
|
| `/herdrchat status` | 告诉你它在不在跑,有问题的话问题在哪。 |
|
|
38
39
|
| `/herdrchat fix` | 找出问题的原因并修好。 |
|
|
39
40
|
| `/herdrchat config` | 改配置:接哪些终端、哪些群保持安静、标签。 |
|
|
40
41
|
| `/herdrchat feishu <任务>` | 替你在飞书开发者后台完成操作。 |
|
|
42
|
+
| `/herdrchat telegram <任务>` | Telegram bot:从 @BotFather 一直到第一个话题。 |
|
|
41
43
|
|
|
42
|
-
不用 agent 也可以直接在命令行里用:`herdrchat init`、`start`、`stop`、`status`、`logs
|
|
44
|
+
不用 agent 也可以直接在命令行里用:`herdrchat init`、`start`、`stop`、`status`、`logs`;
|
|
45
|
+
`herdrchat install` 把它装成系统服务,开机自动启动,崩溃后自动重启。第二个聊天软件加 `--profile <名字>`。
|
|
43
46
|
|
|
44
47
|
## 文件在哪
|
|
45
48
|
|
|
46
|
-
配置在 `~/.config/herdrchat
|
|
49
|
+
配置在 `~/.config/herdrchat/<profile>/config.env`,bot 写的东西都在 `~/.local/state/herdrchat/<profile>/`(没指定 profile 时就是 `default`)。
|
|
47
50
|
更新或重装 herdrchat 都不会动它们。
|
|
48
51
|
|
|
49
52
|
工作原理和全部配置:[docs/guide.md](docs/guide.md)(英文)。
|
package/bin/cli.mjs
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// herdrchat's command line: set up, start, stop and look at one profile.
|
|
3
3
|
//
|
|
4
4
|
// herdrchat <command> [--profile <name>]
|
|
5
|
-
// init
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// init make the profile's config.env from the example when it is missing
|
|
6
|
+
// install run it as a service of the system (launchd, systemd): it starts at
|
|
7
|
+
// login and comes back after a crash; start, stop, status go through it
|
|
8
|
+
// uninstall no service any more (it is stopped)
|
|
9
|
+
// start start (or restart) it: the service, or else its own hc terminal
|
|
10
|
+
// stop stop it
|
|
11
|
+
// status how it runs, its folders, sessions, its chat app, errors today
|
|
12
|
+
// logs today's log (-n <lines>, --errors for ERROR and WARN only)
|
|
13
|
+
// run run in the foreground (what start keeps running)
|
|
11
14
|
//
|
|
12
15
|
// The profile is --profile or HERDRCHAT_PROFILE (default "default"); where its files are
|
|
13
16
|
// is in lib/core/config.mjs.
|
|
@@ -18,12 +21,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
21
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
22
|
const USAGE = `herdrchat <command> [--profile <name>]
|
|
20
23
|
|
|
21
|
-
init
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
init make the profile's config.env from the example when it is missing
|
|
25
|
+
install run it as a service of the system: starts at login, back after a crash
|
|
26
|
+
uninstall no service any more (it is stopped)
|
|
27
|
+
start start (or restart) it: the service, or else its own hc terminal
|
|
28
|
+
stop stop it
|
|
29
|
+
status how it runs, its folders, sessions, its chat app, errors today
|
|
30
|
+
logs today's log (-n <lines>, --errors for ERROR and WARN only)
|
|
31
|
+
run run in the foreground (what start keeps running)`;
|
|
27
32
|
|
|
28
33
|
const argv = process.argv.slice(2);
|
|
29
34
|
const flag = (name) => {
|
|
@@ -42,6 +47,7 @@ const command = argv.find((a) => !a.startsWith('-'));
|
|
|
42
47
|
// after the profile is set: lib/core/config.mjs reads it when it loads
|
|
43
48
|
const { CONFIG_FILE, STATE_DIR, loadConfig, makeDirs, placesOf } = await import('../lib/core/config.mjs');
|
|
44
49
|
const { hc } = await import('../lib/core/hc.mjs');
|
|
50
|
+
const { serviceOf } = await import('../lib/core/service.mjs');
|
|
45
51
|
const places = placesOf();
|
|
46
52
|
|
|
47
53
|
const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -86,6 +92,26 @@ async function waitConnected(since) {
|
|
|
86
92
|
return { ok: false, errors: [...new Set(errors.map((l) => l.slice(25)))].slice(-5) };
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
// What the bot needs wherever it runs (hc starts a program with the core's environment,
|
|
96
|
+
// and a service with almost none): hc's full path, our PATH, the profile and the folders.
|
|
97
|
+
// Nothing else, so no agent's variables come along.
|
|
98
|
+
function botEnv() {
|
|
99
|
+
const hcPath = findHc();
|
|
100
|
+
if (!hcPath) throw new Error('hc is not on PATH: install herdr-core first (https://herdr.app)');
|
|
101
|
+
const env = { HERDRCHAT_HC: hcPath, HERDRCHAT_PROFILE: places.profile };
|
|
102
|
+
for (const k of ['PATH', 'HERDRCHAT_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME']) if (process.env[k]) env[k] = process.env[k];
|
|
103
|
+
return env;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const service = () => serviceOf({ profile: places.profile, root: ROOT, node: process.execPath, env: botEnv(), stateDir: STATE_DIR });
|
|
107
|
+
|
|
108
|
+
function failedToConnect(where, result) {
|
|
109
|
+
console.error(`${where} but did not reach its chat app within ${CONNECT_WAIT_MS / 1000} s.`);
|
|
110
|
+
if (result.errors.length) console.error(`Its errors:\n ${result.errors.join('\n ')}`);
|
|
111
|
+
console.error('It keeps retrying; `herdrchat logs --errors` shows more.');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
async function terminalState(session) {
|
|
90
116
|
try {
|
|
91
117
|
return JSON.parse(await hc(session, ['get', places.terminal, '--json'])).state;
|
|
@@ -106,53 +132,97 @@ const commands = {
|
|
|
106
132
|
console.log(`Made ${CONFIG_FILE}: fill in the app id, the secret and HERDR_SESSIONS.`);
|
|
107
133
|
},
|
|
108
134
|
|
|
135
|
+
// The service file is written again each time (a moved node or hc is picked up), and
|
|
136
|
+
// the bot in an hc terminal is stopped first: two bots would answer every message.
|
|
137
|
+
async install() {
|
|
138
|
+
const svc = service();
|
|
139
|
+
if (!svc) throw new Error(`no launchd or systemd on ${process.platform}: run \`herdrchat start\` instead`);
|
|
140
|
+
loadConfig(); // a config that cannot start the bot fails here, not in the service
|
|
141
|
+
await stopEverywhere();
|
|
142
|
+
svc.unload();
|
|
143
|
+
svc.write(svc.content);
|
|
144
|
+
const since = new Date().toISOString();
|
|
145
|
+
svc.load();
|
|
146
|
+
const result = await waitConnected(since);
|
|
147
|
+
if (!result.ok) return failedToConnect(`The ${svc.kind} service ${svc.name} is installed`, result);
|
|
148
|
+
console.log(`Installed the ${svc.kind} service ${svc.name} (${svc.file}); it is running, connected to its chat app, starts at login and comes back after a crash.`);
|
|
149
|
+
if (svc.kind === 'systemd') console.log('To keep it running after you log out: loginctl enable-linger');
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async uninstall() {
|
|
153
|
+
const svc = service();
|
|
154
|
+
if (!svc?.installed()) {
|
|
155
|
+
console.log('No herdrchat service is installed for this profile.');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
svc.unload();
|
|
159
|
+
svc.remove();
|
|
160
|
+
console.log(`Removed the ${svc.kind} service ${svc.name}; the bot is stopped. \`herdrchat start\` runs it in an hc terminal again.`);
|
|
161
|
+
},
|
|
162
|
+
|
|
109
163
|
async start() {
|
|
164
|
+
await stopEverywhere();
|
|
165
|
+
const svc = service();
|
|
166
|
+
const since = new Date().toISOString();
|
|
167
|
+
if (svc?.installed()) {
|
|
168
|
+
if (svc.loaded()) svc.restart();
|
|
169
|
+
else svc.load();
|
|
170
|
+
const result = await waitConnected(since);
|
|
171
|
+
if (!result.ok) return failedToConnect(`The ${svc.kind} service ${svc.name} started`, result);
|
|
172
|
+
console.log(`Started the ${svc.kind} service ${svc.name} (profile ${places.profile}); connected to its chat app.`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
110
175
|
const session = firstSession();
|
|
111
|
-
const
|
|
112
|
-
if (!hcPath) throw new Error('hc is not on PATH: install herdr-core first (https://herdr.app)');
|
|
113
|
-
// hc starts a program with the core's environment, not ours, and a core started by a
|
|
114
|
-
// service may not have hc on its PATH: hc's full path, our PATH, the profile and the
|
|
115
|
-
// folders go along
|
|
116
|
-
const env = [['HERDRCHAT_HC', hcPath], ...['PATH', 'HERDRCHAT_PROFILE', 'HERDRCHAT_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME'].map((k) => [k, process.env[k]])]
|
|
117
|
-
.filter(([, v]) => v)
|
|
118
|
-
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
119
|
-
.join(' ');
|
|
176
|
+
const env = Object.entries(botEnv()).map(([k, v]) => `${k}=${shellQuote(v)}`).join(' ');
|
|
120
177
|
const run = `env ${env} ${shellQuote(process.execPath)} ${shellQuote(path.join(ROOT, 'bin', 'herdrchat.mjs'))}`;
|
|
121
178
|
const loop = `while true; do ${run}; echo 'herdrchat exited, restarting in 3 s'; sleep 3; done`;
|
|
122
|
-
await stopEverywhere();
|
|
123
|
-
const since = new Date().toISOString();
|
|
124
179
|
await hc(session, ['run', '--bg', '--no-parent', '--name', places.terminal, '--cwd', ROOT, '--', 'sh', '-c', loop]);
|
|
125
180
|
const result = await waitConnected(since);
|
|
126
|
-
if (!result.ok) {
|
|
127
|
-
|
|
128
|
-
if (result.errors.length) console.error(`Its errors:\n ${result.errors.join('\n ')}`);
|
|
129
|
-
console.error('It keeps retrying; `herdrchat logs --errors` shows more.');
|
|
130
|
-
process.exitCode = 1;
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
console.log(`Started ${places.terminal} (profile ${places.profile}) in hc session ${session}; connected to its chat app.`);
|
|
181
|
+
if (!result.ok) return failedToConnect(`${places.terminal} started in hc session ${session}`, result);
|
|
182
|
+
console.log(`Started ${places.terminal} (profile ${places.profile}) in hc session ${session}; connected to its chat app. \`herdrchat install\` makes it a service that starts at login and comes back after a crash.`);
|
|
134
183
|
},
|
|
135
184
|
|
|
136
185
|
async stop() {
|
|
137
186
|
await stopEverywhere();
|
|
138
|
-
|
|
187
|
+
const svc = service();
|
|
188
|
+
if (svc?.installed()) svc.unload(); // stays installed: \`herdrchat start\` loads it again; at login it starts again
|
|
189
|
+
console.log(`Stopped herdrchat (profile ${places.profile}). Until it starts again, nothing reaches its chat app.`);
|
|
139
190
|
},
|
|
140
191
|
|
|
141
192
|
async status() {
|
|
142
|
-
console.log(`profile ${places.profile}
|
|
193
|
+
console.log(`profile ${places.profile}`);
|
|
143
194
|
console.log(`settings ${CONFIG_FILE}${fs.existsSync(CONFIG_FILE) ? '' : ' (missing: run `herdrchat init`)'}`);
|
|
144
195
|
console.log(`state ${STATE_DIR}`);
|
|
145
196
|
if (!fs.existsSync(CONFIG_FILE)) return;
|
|
146
197
|
const cfg = loadConfig();
|
|
147
198
|
console.log(`sessions ${cfg.sessions.join(', ')}`);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
199
|
+
// how it runs: the service, an hc terminal, or not at all
|
|
200
|
+
const svc = service();
|
|
201
|
+
const inTerminal = (await terminalState(cfg.sessions[0])) === 'live';
|
|
202
|
+
if (svc?.installed()) {
|
|
203
|
+
const st = svc.state();
|
|
204
|
+
console.log(`running ${st.running ? `yes, as the ${svc.kind} service ${svc.name}${st.pid ? ` (pid ${st.pid})` : ''}` : `no: the ${svc.kind} service ${svc.name} is stopped (\`herdrchat start\`)`}`);
|
|
205
|
+
const gone = svc.nodeGone();
|
|
206
|
+
if (gone) console.log(` the service runs ${gone}, which is gone (node moved): \`herdrchat install\` again`);
|
|
207
|
+
if (inTerminal) console.log(` also in hc terminal ${places.terminal}: two bots; \`herdrchat start\` keeps one`);
|
|
208
|
+
} else {
|
|
209
|
+
console.log(`running ${inTerminal ? `yes, in hc terminal ${places.terminal} (session ${cfg.sessions[0]}); \`herdrchat install\` makes it a service` : 'no (`herdrchat start`, or `herdrchat install` for a service)'}`);
|
|
210
|
+
}
|
|
211
|
+
// what its chat app needs, without printing a secret
|
|
212
|
+
console.log(`chat ${cfg.chat}`);
|
|
213
|
+
if (cfg.chat === 'feishu') {
|
|
214
|
+
let token = null;
|
|
215
|
+
try {
|
|
216
|
+
token = JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'user-token.json'), 'utf8'));
|
|
217
|
+
} catch {
|
|
218
|
+
// no /auth yet
|
|
219
|
+
}
|
|
220
|
+
console.log(`owner ${cfg.ownerOpenId ? 'set' : 'not set: message the bot, it says what to put in config.env'}`);
|
|
221
|
+
console.log(`/auth ${!token ? 'not done (muting, labels and pins need it)' : (token.refreshUntil || 0) > Date.now() ? 'done' : 'expired: send /auth again'}`);
|
|
222
|
+
} else if (cfg.chat === 'telegram') {
|
|
223
|
+
console.log(`token ${cfg.env.TELEGRAM_BOT_TOKEN ? 'set' : 'missing: TELEGRAM_BOT_TOKEN from @BotFather'}`);
|
|
224
|
+
console.log(`owner ${cfg.env.TELEGRAM_OWNER_ID ? 'set' : 'not set: message the bot, it says what to put in config.env'}`);
|
|
154
225
|
}
|
|
155
|
-
console.log(`/auth ${!token ? 'not done' : (token.refreshUntil || 0) > Date.now() ? 'done' : 'expired: send /auth again'}`);
|
|
156
226
|
const log = path.join(STATE_DIR, 'logs', `${new Date().toISOString().slice(0, 10)}.log`);
|
|
157
227
|
if (fs.existsSync(log)) {
|
|
158
228
|
const text = fs.readFileSync(log, 'utf8');
|
package/config.env.example
CHANGED
|
@@ -17,10 +17,11 @@ TELEGRAM_BOT_TOKEN=
|
|
|
17
17
|
TELEGRAM_OWNER_ID=
|
|
18
18
|
|
|
19
19
|
HERDR_SESSIONS=session-a,session-b
|
|
20
|
-
#
|
|
21
|
-
# empty for none, or spaces,
|
|
20
|
+
# spaces whose rooms are quiet, so only "needs you" and "done" ring (Feishu: groups muted
|
|
21
|
+
# once /auth is done; Telegram: silent posts): `all` (default), empty for none, or spaces,
|
|
22
|
+
# comma-separated; elsewhere every post rings
|
|
22
23
|
HERDR_MUTED_SPACES=all
|
|
23
|
-
# spaces
|
|
24
|
+
# spaces where nothing rings, not even "needs you": empty (default), `all`, or spaces
|
|
24
25
|
HERDR_SILENT_SPACES=
|
|
25
26
|
# the bot's tags next to group names (tenant tags): on (default) or off
|
|
26
27
|
HERDR_BOT_TAGS=on
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// herdrchat's Telegram side. A bot cannot make groups (no such Bot API method), so each
|
|
2
2
|
// herdr-app tab is a topic in your private chat with the bot (topic mode must be on in
|
|
3
3
|
// @BotFather; getMe says has_topics_enabled), and the chat's main thread is the direct
|
|
4
|
-
// chat: the overview and the commands.
|
|
5
|
-
// notification without sound)
|
|
6
|
-
// you" and "done"
|
|
4
|
+
// chat: the overview and the commands. In a space of HERDR_MUTED_SPACES (all, by
|
|
5
|
+
// default) the bot posts silently (a notification without sound) and `ring` sends a short
|
|
6
|
+
// reply that sounds, for "needs you" and "done"; a bot cannot mute a chat for you, so
|
|
7
|
+
// this stands in for Feishu's muted groups. Elsewhere every post sounds. A space of
|
|
8
|
+
// HERDR_SILENT_SPACES never sounds. The core (lib/core/engine.mjs) calls only what is returned here; the
|
|
7
9
|
// contract is lib/chats/contract.mjs.
|
|
8
10
|
//
|
|
9
11
|
// What Telegram does differently, and what stands in:
|
|
@@ -11,9 +13,10 @@
|
|
|
11
13
|
// topic's name gets a 🔚 in front, taken off when it is live again
|
|
12
14
|
// images a message cannot hold one, so each goes as its own photo after the text
|
|
13
15
|
// buttons callback_data holds 64 bytes, so a button carries a short id and the
|
|
14
|
-
// dialog it answers is kept
|
|
16
|
+
// dialog it answers is kept in state.json
|
|
15
17
|
// a free answer no text box on a message: you type the answer in the topic
|
|
16
18
|
// pin a topic cannot be pinned; nothing is done
|
|
19
|
+
import crypto from 'node:crypto';
|
|
17
20
|
import fs from 'node:fs';
|
|
18
21
|
import { createClient } from './client.mjs';
|
|
19
22
|
import { escapeHtml, split, toHtml } from './format.mjs';
|
|
@@ -71,11 +74,18 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
71
74
|
const buffer = images.get(id);
|
|
72
75
|
if (!buffer) continue;
|
|
73
76
|
images.delete(id);
|
|
74
|
-
last = String((await tg.sendPhoto(owner, room, buffer)).message_id);
|
|
77
|
+
last = String((await tg.sendPhoto(owner, room, buffer, { silent: quiet(room) })).message_id);
|
|
75
78
|
}
|
|
76
79
|
return last;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
// Whether a room's posts go silently: its space is in HERDR_MUTED_SPACES.
|
|
83
|
+
const muted = cfg.muted || { all: true, spaces: [] };
|
|
84
|
+
function quiet(room) {
|
|
85
|
+
const c = Object.values(read().chats).find((x) => x.chat === room);
|
|
86
|
+
return !c || inSpaces(muted, spaceOfName(c.name));
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
// HTML that Telegram refuses (an entity it cannot parse) goes again as plain text.
|
|
80
90
|
async function sendText(chatId, threadId, html, plain, { silent = true, extra = {} } = {}) {
|
|
81
91
|
const base = { chat_id: chatId, ...(threadId ? { message_thread_id: Number(threadId) } : {}), disable_notification: silent, link_preview_options: { is_disabled: true }, ...extra };
|
|
@@ -91,7 +101,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
91
101
|
async function post(room, markdown) {
|
|
92
102
|
const { text, found } = takeImages(markdown);
|
|
93
103
|
let last = null;
|
|
94
|
-
if (text) for (const piece of split(text)) last = String((await sendText(owner, room, toHtml(piece), piece)).message_id);
|
|
104
|
+
if (text) for (const piece of split(text)) last = String((await sendText(owner, room, toHtml(piece), piece, { silent: quiet(room) })).message_id);
|
|
95
105
|
return (await sendImages(room, found)) || last;
|
|
96
106
|
}
|
|
97
107
|
|
|
@@ -99,7 +109,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
99
109
|
|
|
100
110
|
async function postCard(room, look) {
|
|
101
111
|
const { text, found } = takeImages(look.markdown);
|
|
102
|
-
const message = String((await sendText(owner, room, lookHtml({ ...look, markdown: text }), `${look.title}\n\n${text}
|
|
112
|
+
const message = String((await sendText(owner, room, lookHtml({ ...look, markdown: text }), `${look.title}\n\n${text}`, { silent: quiet(room) })).message_id);
|
|
103
113
|
await sendImages(room, found);
|
|
104
114
|
return message;
|
|
105
115
|
}
|
|
@@ -124,14 +134,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
124
134
|
|
|
125
135
|
// ---------- dialogs ----------
|
|
126
136
|
|
|
127
|
-
// A button carries "<id>:<pick>"; the id names the dialog's value
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
let buttonN = 0;
|
|
137
|
+
// A button carries "<id>:<pick>"; the id names the dialog's value (the newest
|
|
138
|
+
// BUTTONS_KEPT; an older card is told its dialog is gone).
|
|
139
|
+
// They are kept in state.json (buttons), so a card still answers after a restart.
|
|
131
140
|
function keyboard(dialog, value) {
|
|
132
|
-
const id = (
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
const id = crypto.randomBytes(6).toString('base64url');
|
|
142
|
+
const state = read();
|
|
143
|
+
const kept = { ...(state.buttons || {}), [id]: { value, labels: Object.fromEntries(dialog.menu.options.map((o) => [o.n, o.label])) } };
|
|
144
|
+
const ids = Object.keys(kept);
|
|
145
|
+
for (const old of ids.slice(0, Math.max(0, ids.length - BUTTONS_KEPT))) delete kept[old];
|
|
146
|
+
state.buttons = kept;
|
|
147
|
+
write(state);
|
|
135
148
|
const rows = [];
|
|
136
149
|
for (const o of dialog.menu.options) {
|
|
137
150
|
if (o === dialog.menu.free) continue;
|
|
@@ -146,7 +159,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
146
159
|
const doneHtml = (dialog, done) => `<b>${escapeHtml(dialog.doneTitle)}</b>\n\n${toHtml(dialog.markdown)}\n\n<b>${escapeHtml(done)}</b>`;
|
|
147
160
|
|
|
148
161
|
async function postDialog(room, dialog, value) {
|
|
149
|
-
const res = await sendText(owner, room, dialogHtml(dialog), dialog.markdown, { extra: { reply_markup: keyboard(dialog, value) } });
|
|
162
|
+
const res = await sendText(owner, room, dialogHtml(dialog), dialog.markdown, { silent: quiet(room), extra: { reply_markup: keyboard(dialog, value) } });
|
|
150
163
|
return String(res.message_id);
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -195,10 +208,11 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
195
208
|
const pin = async (k) => { pins.add(k); };
|
|
196
209
|
const unpin = async (k) => { pins.delete(k); };
|
|
197
210
|
|
|
198
|
-
//
|
|
211
|
+
// In a quiet room this reply is what sounds; in another the post sounded already. A
|
|
212
|
+
// space in HERDR_SILENT_SPACES never sounds.
|
|
199
213
|
async function ring(k, message, text) {
|
|
200
214
|
const c = read().chats[k];
|
|
201
|
-
if (!message || !c || inSpaces(cfg.silent, spaceOfName(c.name))) return;
|
|
215
|
+
if (!message || !c || !quiet(c.chat) || inSpaces(cfg.silent, spaceOfName(c.name))) return;
|
|
202
216
|
await sendText(owner, c.chat, escapeHtml(text), text, { silent: false, extra: { reply_parameters: { message_id: Number(message), allow_sending_without_reply: true } } })
|
|
203
217
|
.catch((err) => logError(`ring: ${err.message}`));
|
|
204
218
|
}
|
|
@@ -316,10 +330,10 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
316
330
|
const answer = (text) => tg.call('answerCallbackQuery', { callback_query_id: q.id, text: (text || '').slice(0, 200) }).catch(() => {});
|
|
317
331
|
if (String(q.from?.id) !== owner) return answer('Only the owner can answer.');
|
|
318
332
|
const [id, pickRaw] = String(q.data || '').split(':');
|
|
319
|
-
const kept = buttons
|
|
333
|
+
const kept = read().buttons?.[id];
|
|
320
334
|
const message = String(q.message?.message_id || '');
|
|
321
335
|
if (!kept) {
|
|
322
|
-
await edit(message, `${q.message?.text ? escapeHtml(q.message.text) : ''}\n\n<b>This
|
|
336
|
+
await edit(message, `${q.message?.text ? escapeHtml(q.message.text) : ''}\n\n<b>This card is too old to answer; see the newest card.</b>`, { reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
323
337
|
return answer('This dialog is gone.');
|
|
324
338
|
}
|
|
325
339
|
const pick = /^\d+$/.test(pickRaw) ? Number(pickRaw) : pickRaw;
|
package/lib/core/config.mjs
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
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
14
|
// HERDR_SESSIONS hc sessions to mirror, comma-separated
|
|
15
|
-
// HERDR_MUTED_SPACES spaces whose
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// HERDR_MUTED_SPACES spaces whose rooms are quiet, so only "needs you" and
|
|
16
|
+
// "done" ring (Feishu: groups muted, once /auth is done,
|
|
17
|
+
// and an @; Telegram: silent posts and a reply that
|
|
18
|
+
// rings): `all` (default), empty for none, or spaces,
|
|
19
|
+
// comma-separated; elsewhere every post rings
|
|
20
|
+
// HERDR_SILENT_SPACES spaces where nothing rings, not even "needs you":
|
|
19
21
|
// empty (default) for none, `all`, or spaces
|
|
20
22
|
// HERDR_BOT_TAGS `on` (default) or `off`: the bot's tags next to group
|
|
21
23
|
// names (tenant tags: session or machine, and "ended")
|
package/lib/core/engine.mjs
CHANGED
|
@@ -248,7 +248,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
248
248
|
function transcriptOf(items) {
|
|
249
249
|
const parts = [];
|
|
250
250
|
for (const it of items) {
|
|
251
|
-
if (it.role === 'you') parts.push(
|
|
251
|
+
if (it.role === 'you') parts.push(youOrOthers(it.text, ` · ${clock(it.at)}`));
|
|
252
252
|
else if (it.role === 'ai') parts.push(it.text);
|
|
253
253
|
else if (it.role === 'tool') parts.push(it.text);
|
|
254
254
|
else if (it.role === 'image') parts.push(`_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'})_`);
|
|
@@ -357,6 +357,23 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
357
357
|
// What you sent from the chat; its room shows it already, so it is not posted again.
|
|
358
358
|
const fromChat = new Map();
|
|
359
359
|
|
|
360
|
+
// A message another terminal sent with `hc send` reaches the agent wrapped by hc as
|
|
361
|
+
// <hc from="t7 review" reach="...">...</hc> (any agent: hc adds it), so it is shown as
|
|
362
|
+
// from that terminal, not as yours; what is around such notes is yours.
|
|
363
|
+
const HC_NOTE = /<hc from="([^"]*)"(?:\s+reach="([^"]*)")?>([\s\S]*?)<\/hc>/g;
|
|
364
|
+
const quoted = (text) => text.split('\n').map((l) => `> ${l}`).join('\n');
|
|
365
|
+
function youOrOthers(text, at = '') {
|
|
366
|
+
const parts = [];
|
|
367
|
+
let rest = text;
|
|
368
|
+
for (const m of text.matchAll(HC_NOTE)) {
|
|
369
|
+
parts.push(`**From ${m[1]}**${at}${m[2] ? ` _(answer: ${m[2]})_` : ''}\n${quoted(m[3].trim())}`);
|
|
370
|
+
rest = rest.replace(m[0], '');
|
|
371
|
+
}
|
|
372
|
+
rest = rest.trim();
|
|
373
|
+
if (rest) parts.unshift(`**You:**${at ? ` ${at.replace(/^ · /, '')}` : ''}\n${quoted(rest)}`);
|
|
374
|
+
return parts.join('\n\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
360
377
|
function formatItems(k, items) {
|
|
361
378
|
const sent = fromChat.get(k) || [];
|
|
362
379
|
const parts = [];
|
|
@@ -380,7 +397,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
380
397
|
sent.splice(i, 1);
|
|
381
398
|
continue;
|
|
382
399
|
}
|
|
383
|
-
parts.push(
|
|
400
|
+
parts.push(youOrOthers(it.text));
|
|
384
401
|
} else {
|
|
385
402
|
parts.push(it.text);
|
|
386
403
|
}
|
|
@@ -595,6 +612,12 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
595
612
|
if (steady) break;
|
|
596
613
|
}
|
|
597
614
|
const k = key(session, address);
|
|
615
|
+
// answered while its screen was being read: nothing waits, so no card
|
|
616
|
+
const nowStatus = (await hc.getTerminal(session, address).catch(() => null))?.status;
|
|
617
|
+
if (nowStatus && nowStatus.state !== 'blocked') {
|
|
618
|
+
log(`<- ${session}/${address}: no card, it no longer waits (${nowStatus.state})`);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
598
621
|
if (!claimDialog(k, dialog, record.status) && !again) return;
|
|
599
622
|
await closeCard(k, 'It moved on; see the next card.');
|
|
600
623
|
if (!dialog.menu) {
|
|
@@ -618,9 +641,17 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
618
641
|
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
619
642
|
}
|
|
620
643
|
|
|
644
|
+
// The keys that pick an option: its digit, or, in a question with previews (which takes
|
|
645
|
+
// no digit), the arrows from the cursor to it and Enter.
|
|
646
|
+
function pickKeys(menu, option) {
|
|
647
|
+
if (!menu.beside) return [String(option.n)];
|
|
648
|
+
const moves = option.n - (menu.cursor || 1);
|
|
649
|
+
return [...Array(Math.abs(moves)).fill(moves > 0 ? 'down' : 'up'), 'enter'];
|
|
650
|
+
}
|
|
651
|
+
|
|
621
652
|
// "Type something": pick that option, then type the words.
|
|
622
653
|
async function answerFree(session, address, menu, text) {
|
|
623
|
-
await hc.keys(session, address,
|
|
654
|
+
await hc.keys(session, address, pickKeys(menu, menu.free));
|
|
624
655
|
await sleep(300);
|
|
625
656
|
await hc.send(session, address, text, { force: true });
|
|
626
657
|
}
|
|
@@ -683,7 +714,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
683
714
|
await answerFree(v.s, v.a, menu, text);
|
|
684
715
|
picked = text;
|
|
685
716
|
} else {
|
|
686
|
-
await hc.keys(v.s, v.a,
|
|
717
|
+
await hc.keys(v.s, v.a, pickKeys(menu, option));
|
|
687
718
|
picked = `${option.n}. ${option.label}`;
|
|
688
719
|
}
|
|
689
720
|
}
|
package/lib/core/menu.mjs
CHANGED
|
@@ -13,12 +13,21 @@
|
|
|
13
13
|
// above a multi-question dialog, which change as you answer: the same dialog keeps its key). A card click is pressed only when the
|
|
14
14
|
// screen still shows the dialog with that key, so an old card can never answer a newer
|
|
15
15
|
// dialog that looks alike (two permission prompts both offer "1. Yes").
|
|
16
|
+
//
|
|
17
|
+
// A question with previews draws a box beside its options, on the same lines, and the
|
|
18
|
+
// box shows the preview of the option under the cursor: what is right of it is cut off
|
|
19
|
+
// (three spaces or more, then a box line), so it is neither part of an option's label
|
|
20
|
+
// nor of the key; a question's own left border (│) goes too.
|
|
16
21
|
import crypto from 'node:crypto';
|
|
17
22
|
|
|
18
23
|
const OPTION = /^\s*(?:❯\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\s*$/;
|
|
19
24
|
const RULE = /^[\s─╌━-]*$/;
|
|
20
25
|
const BORDER = /^\s*─{10,}/;
|
|
21
26
|
const FREE = /^Type something/i;
|
|
27
|
+
const BESIDE = /\s{3,}[┌└│├┤┐┘╭╰─┬┴┼║╔╚].*$/u;
|
|
28
|
+
const LEFT_BORDER = /^(\s*)│\s?/u;
|
|
29
|
+
|
|
30
|
+
const ownPart = (line) => line.replace(BESIDE, '').replace(LEFT_BORDER, '$1');
|
|
22
31
|
|
|
23
32
|
const TEXT_LINES = 15; // a dialog with no border found: this many lines above its options
|
|
24
33
|
|
|
@@ -36,7 +45,8 @@ function keyOf(lines, top, last) {
|
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export function menuOf(screen) {
|
|
39
|
-
const
|
|
48
|
+
const raw = screen.split('\n');
|
|
49
|
+
const lines = raw.map(ownPart);
|
|
40
50
|
let run = null;
|
|
41
51
|
let menu = null;
|
|
42
52
|
lines.forEach((line, i) => {
|
|
@@ -54,7 +64,13 @@ export function menuOf(screen) {
|
|
|
54
64
|
});
|
|
55
65
|
if (!menu) return null;
|
|
56
66
|
const top = topOf(lines, menu.first);
|
|
67
|
+
// A question with previews takes no digit (its keys say "↑/↓ to navigate"): an option
|
|
68
|
+
// is reached from the cursor (❯) with the arrows, then Enter.
|
|
69
|
+
const beside = raw.slice(menu.first, menu.last + 1).some((l) => BESIDE.test(l));
|
|
70
|
+
const cursor = menu.options.find((o, i) => raw.slice(menu.first, menu.last + 1).some((l) => /❯/.test(l) && new RegExp(`^\\s*❯\\s*${o.n}\\.`).test(l)))?.n || 1;
|
|
57
71
|
return {
|
|
72
|
+
beside,
|
|
73
|
+
cursor,
|
|
58
74
|
key: keyOf(lines, top, menu.last),
|
|
59
75
|
framed: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
|
|
60
76
|
text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// herdrchat as a service of the system, so it starts at login and comes back by itself
|
|
2
|
+
// after a crash, whatever happens to the hc cores: a launchd LaunchAgent on macOS, a
|
|
3
|
+
// systemd user unit on Linux. `herdrchat install` makes it; start, stop and status then
|
|
4
|
+
// go through it. Without it the bot runs in an hc terminal (`herdrchat start`), which
|
|
5
|
+
// ends when that hc core stops.
|
|
6
|
+
//
|
|
7
|
+
// The service runs node and bin/herdrchat.mjs by their full paths, with only the
|
|
8
|
+
// variables listed in `env` (no agent's variables come along): a node upgrade that moves
|
|
9
|
+
// node needs `herdrchat install` again, which status says.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const THROTTLE_S = 10; // launchd: at most one start in this many seconds
|
|
16
|
+
const RESTART_S = 5; // systemd: wait before starting again
|
|
17
|
+
|
|
18
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
19
|
+
|
|
20
|
+
export function launchdPlist({ label, node, script, root, env, out }) {
|
|
21
|
+
const vars = Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`).join('\n');
|
|
22
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
23
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
24
|
+
<plist version="1.0">
|
|
25
|
+
<dict>
|
|
26
|
+
<key>Label</key>
|
|
27
|
+
<string>${xml(label)}</string>
|
|
28
|
+
<key>ProgramArguments</key>
|
|
29
|
+
<array>
|
|
30
|
+
<string>${xml(node)}</string>
|
|
31
|
+
<string>${xml(script)}</string>
|
|
32
|
+
</array>
|
|
33
|
+
<key>WorkingDirectory</key>
|
|
34
|
+
<string>${xml(root)}</string>
|
|
35
|
+
<key>EnvironmentVariables</key>
|
|
36
|
+
<dict>
|
|
37
|
+
${vars}
|
|
38
|
+
</dict>
|
|
39
|
+
<key>RunAtLoad</key>
|
|
40
|
+
<true/>
|
|
41
|
+
<key>KeepAlive</key>
|
|
42
|
+
<true/>
|
|
43
|
+
<key>ThrottleInterval</key>
|
|
44
|
+
<integer>${THROTTLE_S}</integer>
|
|
45
|
+
<key>StandardOutPath</key>
|
|
46
|
+
<string>${xml(out)}</string>
|
|
47
|
+
<key>StandardErrorPath</key>
|
|
48
|
+
<string>${xml(out)}</string>
|
|
49
|
+
</dict>
|
|
50
|
+
</plist>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unitQuote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
55
|
+
|
|
56
|
+
export function systemdUnit({ profile, node, script, root, env, out }) {
|
|
57
|
+
return `[Unit]
|
|
58
|
+
Description=herdrchat (profile ${profile}): herdr-app tabs in a chat app
|
|
59
|
+
After=network-online.target
|
|
60
|
+
|
|
61
|
+
[Service]
|
|
62
|
+
ExecStart=${unitQuote(node)} ${unitQuote(script)}
|
|
63
|
+
WorkingDirectory=${root}
|
|
64
|
+
${Object.entries(env).map(([k, v]) => `Environment=${unitQuote(`${k}=${v}`)}`).join('\n')}
|
|
65
|
+
Restart=always
|
|
66
|
+
RestartSec=${RESTART_S}
|
|
67
|
+
StandardOutput=append:${out}
|
|
68
|
+
StandardError=append:${out}
|
|
69
|
+
|
|
70
|
+
[Install]
|
|
71
|
+
WantedBy=default.target
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const run = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
|
|
77
|
+
// {kind, name, file, content, write, remove, installed, load, unload, restart, state,
|
|
78
|
+
// nodeGone}, or null on a system with neither launchd nor systemd.
|
|
79
|
+
export function serviceOf({ profile, root, node, env, stateDir, platform = process.platform, home = os.homedir() }) {
|
|
80
|
+
const script = path.join(root, 'bin', 'herdrchat.mjs');
|
|
81
|
+
const out = path.join(stateDir, 'logs', 'service.out.log');
|
|
82
|
+
const common = { profile, node, script, root, env, out };
|
|
83
|
+
if (platform === 'darwin') {
|
|
84
|
+
const label = `app.herdrchat.${profile}`;
|
|
85
|
+
const file = path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
86
|
+
const target = `gui/${process.getuid()}/${label}`;
|
|
87
|
+
return {
|
|
88
|
+
kind: 'launchd',
|
|
89
|
+
name: label,
|
|
90
|
+
file,
|
|
91
|
+
content: launchdPlist({ ...common, label }),
|
|
92
|
+
loaded() {
|
|
93
|
+
try {
|
|
94
|
+
run('launchctl', ['print', target]);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
load() { run('launchctl', ['bootstrap', `gui/${process.getuid()}`, file]); },
|
|
101
|
+
unload() { try { run('launchctl', ['bootout', target]); } catch { /* not loaded */ } },
|
|
102
|
+
restart() { run('launchctl', ['kickstart', '-k', target]); },
|
|
103
|
+
// {running, pid}
|
|
104
|
+
state() {
|
|
105
|
+
try {
|
|
106
|
+
const text = run('launchctl', ['print', target]);
|
|
107
|
+
return { running: /\bstate = running\b/.test(text), pid: Number(text.match(/\bpid = (\d+)/)?.[1]) || null };
|
|
108
|
+
} catch {
|
|
109
|
+
return { running: false, pid: null };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
...files(file, out),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (platform === 'linux') {
|
|
116
|
+
const unit = `herdrchat-${profile}.service`;
|
|
117
|
+
const file = path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user', unit);
|
|
118
|
+
const ctl = (...a) => run('systemctl', ['--user', ...a]);
|
|
119
|
+
return {
|
|
120
|
+
kind: 'systemd',
|
|
121
|
+
name: unit,
|
|
122
|
+
file,
|
|
123
|
+
content: systemdUnit(common),
|
|
124
|
+
loaded() {
|
|
125
|
+
try {
|
|
126
|
+
return ctl('is-enabled', unit).trim() === 'enabled';
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
load() { ctl('daemon-reload'); ctl('enable', '--now', unit); },
|
|
132
|
+
unload() { try { ctl('disable', '--now', unit); } catch { /* not loaded */ } },
|
|
133
|
+
restart() { ctl('restart', unit); },
|
|
134
|
+
state() {
|
|
135
|
+
try {
|
|
136
|
+
const pid = Number(ctl('show', '-p', 'MainPID', '--value', unit).trim()) || null;
|
|
137
|
+
return { running: ctl('is-active', unit).trim() === 'active', pid };
|
|
138
|
+
} catch {
|
|
139
|
+
return { running: false, pid: null };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
...files(file, out),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function files(file, out) {
|
|
149
|
+
return {
|
|
150
|
+
installed: () => fs.existsSync(file),
|
|
151
|
+
write(content) {
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
|
|
154
|
+
fs.writeFileSync(file, content, { mode: 0o600 });
|
|
155
|
+
},
|
|
156
|
+
remove() { fs.rmSync(file, { force: true }); },
|
|
157
|
+
// the node the installed service runs, when it is gone (a node upgrade moved it)
|
|
158
|
+
nodeGone() {
|
|
159
|
+
if (!fs.existsSync(file)) return null;
|
|
160
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
161
|
+
const node = text.match(/<string>([^<]*node[^<]*)<\/string>/)?.[1] || text.match(/^ExecStart="([^"]+)"/m)?.[1];
|
|
162
|
+
return node && !fs.existsSync(node) ? node : null;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
package/package.json
CHANGED
package/skills/README.md
CHANGED
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
it to set up, run and look after herdrchat. Its commands are `/herdrchat <command>`.
|
|
5
5
|
|
|
6
6
|
Each part of the system has one reference under `herdrchat/references/`, named after the
|
|
7
|
-
part: a chat app (`feishu.md`,
|
|
7
|
+
part: a chat app (`feishu.md`, `telegram.md`, later `slack.md`, `discord.md`) or a part of
|
|
8
8
|
the core (`ops.md`, later `reader.md`). New work goes into the reference of its part, or
|
|
9
9
|
a new one for a new part; `SKILL.md` only lists commands and points to references.
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: herdrchat
|
|
3
|
-
description: Set up, run and look after herdrchat, the chat front-end for hc that mirrors every
|
|
3
|
+
description: Set up, run and look after herdrchat, the chat front-end for hc that mirrors every herdr-app tab into a chat app - Feishu (one group per tab) or Telegram (one topic per tab in the bot's private chat), with dialog buttons, an overview and quiet phones - first setup of either app, running both side by side, start and stop, status and logs, /auth, settings, fixing problems, the Feishu developer console and @BotFather. Use for anything about herdrchat or its Feishu or Telegram bot, even when the user only says "set up the Telegram bot", "is it running", "it doesn't post", "mute the workers" or "publish the bot". Trigger with "/herdrchat", "/herdrchat setup", "/herdrchat status", "/herdrchat fix".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# herdrchat
|
|
7
7
|
|
|
8
|
-
herdrchat mirrors every
|
|
9
|
-
|
|
10
|
-
chat.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
herdrchat mirrors every herdr-app tab into a chat app: a group per tab in Feishu, a topic
|
|
9
|
+
per tab in the bot's private chat in Telegram; the conversation as it happens, dialogs as
|
|
10
|
+
buttons, an overview in the direct chat. One profile is one chat app (`HERDRCHAT_CHAT`);
|
|
11
|
+
to use both, run a profile each (`--profile telegram` beside the default Feishu one).
|
|
12
|
+
|
|
13
|
+
Its command is `herdrchat` (`node bin/cli.mjs` in a clone): check with `command -v
|
|
14
|
+
herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A profile
|
|
15
|
+
(`--profile`, default `default`) keeps its settings in
|
|
16
|
+
`~/.config/herdrchat/<profile>/config.env` and its state in
|
|
14
17
|
`~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both.
|
|
15
18
|
|
|
16
19
|
## Commands
|
|
17
20
|
|
|
18
21
|
| Command | Do |
|
|
19
22
|
|---|---|
|
|
20
|
-
| `/herdrchat setup` | First
|
|
23
|
+
| `/herdrchat setup` | First ask which chat app (Feishu, Telegram, or both: one profile each). Telegram: `/herdrchat telegram setup`. Feishu, in order: `herdrchat init`; make the Feishu app (`references/feishu.md`: create, bot, scopes, events and callbacks); fill `config.env` with the user (app id, secret, `HERDR_SESSIONS`: the hc sessions herdr-app shows on this machine); `herdrchat start`; publish a version; get the user's open_id (they message the bot, it answers with it) into `FEISHU_OWNER_OPEN_ID`; start again. Optional: `/herdrchat auth`. |
|
|
21
24
|
| `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
|
|
22
|
-
| `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. |
|
|
25
|
+
| `/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. |
|
|
26
|
+
| `/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). |
|
|
23
27
|
| `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
|
|
24
28
|
| `/herdrchat auth` | The user sends `/auth` to the bot and allows it on Feishu's page; the app needs the user scopes in `references/feishu.md`. Then groups are muted, labels and pins follow. |
|
|
25
29
|
| `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`, `HERDR_HISTORY`, `HERDRCHAT_CHAT`; each is explained in `config.env.example`), read the file back, then restart. |
|
|
26
30
|
| `/herdrchat fix` | Find the cause from `herdrchat status`, the logs and the live result, then fix it: `references/ops.md`. |
|
|
27
31
|
| `/herdrchat feishu <task>` | Anything in the Feishu developer console: scopes, callbacks, publishing, the icon, deleting an app: `references/feishu.md`. |
|
|
32
|
+
| `/herdrchat telegram <task>` | The Telegram bot, as its own profile: `setup` (@BotFather, topic mode, token, owner id, first start), checking the token, quiet and silent spaces, what Telegram cannot do and what stands in: `references/telegram.md`. |
|
|
28
33
|
|
|
29
34
|
With no subcommand, run `herdrchat status` and suggest the next step from it.
|
|
30
35
|
|
|
@@ -32,7 +37,8 @@ With no subcommand, run `herdrchat status` and suggest the next step from it.
|
|
|
32
37
|
|
|
33
38
|
- Ask before anything outward-facing: publishing a version, applying scopes, deleting or
|
|
34
39
|
disbanding anything, sending to a real group. One yes covers one action.
|
|
35
|
-
- Never print, screenshot or paste `FEISHU_APP_SECRET` or
|
|
40
|
+
- Never print, screenshot or paste `FEISHU_APP_SECRET`, `TELEGRAM_BOT_TOKEN` or
|
|
41
|
+
`user-token.json`; ask the user to paste a token into `config.env` themselves.
|
|
36
42
|
- A check that decides an action runs on its own first (read the config, then start;
|
|
37
43
|
scan, then commit).
|
|
38
44
|
- Say what was checked on the live bot and what was not.
|
|
@@ -41,5 +47,7 @@ With no subcommand, run `herdrchat status` and suggest the next step from it.
|
|
|
41
47
|
|
|
42
48
|
- `references/feishu.md`: the bot's app in the Feishu developer console, with Ego or as a
|
|
43
49
|
guided walk-through; the scopes this bot uses.
|
|
50
|
+
- `references/telegram.md`: the Telegram bot, from @BotFather to the first topic; what
|
|
51
|
+
it does differently from Feishu.
|
|
44
52
|
- `references/ops.md`: running and checking the bot, its logs and state, the usual
|
|
45
53
|
problems, trying a Feishu API safely, committing without a leak.
|
|
@@ -17,7 +17,9 @@ Below, `S` is the first session in `HERDR_SESSIONS`.
|
|
|
17
17
|
HC_SESSION=S hc get herdrchat --json # state "live"
|
|
18
18
|
HC_SESSION=S hc read herdrchat --lines 40 # its terminal: start lines, "[feishu] connected"
|
|
19
19
|
herdrchat status # running, folders, /auth, errors today
|
|
20
|
-
herdrchat start # restart: stops it in every session, waits until it reaches
|
|
20
|
+
herdrchat start # restart: stops it in every session, waits until it reaches its chat app
|
|
21
|
+
herdrchat install # run it as a service (launchd/systemd): starts at login, back after a crash
|
|
22
|
+
launchctl print gui/$(id -u)/app.herdrchat.<profile> # the service on macOS: state, pid, last exit
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
A restart is needed after any code or `config.env` change. It is safe: what was posted
|
|
@@ -38,7 +40,7 @@ While the bot is down the user hears nothing, so:
|
|
|
38
40
|
|
|
39
41
|
## Logs
|
|
40
42
|
|
|
41
|
-
`D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text
|
|
43
|
+
`D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`. Run as a service, what the process printed before its log was open (a crash at start) is in `D/logs/service.out.log`:
|
|
42
44
|
|
|
43
45
|
```sh
|
|
44
46
|
grep -h ERROR D/logs/*.log | tail -20 # what went wrong
|
|
@@ -90,7 +92,7 @@ Say which checks were run and which were not; "the code is there" is not "it wor
|
|
|
90
92
|
| nothing posts | bot down, or `FEISHU_OWNER_OPEN_ID` unset | check it runs; the log says "owner not set" |
|
|
91
93
|
| a button gives error 200340 | the card callback is not set up | `references/feishu.md`: events and card callbacks |
|
|
92
94
|
| a dialog had no card | answered in the terminal before the card; or no numbered menu on screen | log: `blocked`; the card says `/answer` when it saw no menu |
|
|
93
|
-
| the phone rings too much | not `/auth`ed
|
|
95
|
+
| the phone rings too much | Feishu: not `/auth`ed; either app: settings | Feishu `/auth`; `HERDR_MUTED_SPACES` (quiet but for needs you and done), `HERDR_SILENT_SPACES` (nothing rings), per profile |
|
|
94
96
|
| a ⚠️ warning in the direct chat about acting as you | the token could not be refreshed | send `/auth` again |
|
|
95
97
|
| a group keeps an old name | its terminal is not live | it renames when the terminal is back |
|
|
96
98
|
| a Feishu call fails with 400 in a burst | a rate limit (tags: 5 a second) | space the calls; the bot queues tag calls |
|
|
@@ -1,23 +1,77 @@
|
|
|
1
|
-
# Telegram:
|
|
2
|
-
|
|
3
|
-
A Telegram bot cannot make groups
|
|
4
|
-
your private chat with the bot
|
|
5
|
-
(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
# Telegram: the bot, from @BotFather to the first topic
|
|
2
|
+
|
|
3
|
+
A Telegram bot cannot make groups (the Bot API has no such method), so herdrchat puts each
|
|
4
|
+
herdr-app tab in a **topic** of your private chat with the bot: each has its own name,
|
|
5
|
+
icon color (one per space), unread count, and can be muted on its own. The chat's main
|
|
6
|
+
thread holds the overview and the commands. It runs as its **own profile**, beside a
|
|
7
|
+
Feishu one; each bot follows the same hc terminals, so an answer in either reaches the
|
|
8
|
+
terminal, and a dialog answered in one closes its card in the other.
|
|
9
|
+
|
|
10
|
+
## Setup (`/herdrchat telegram setup`)
|
|
11
|
+
|
|
12
|
+
Do these in order; the user does the Telegram steps, you check each one.
|
|
13
|
+
|
|
14
|
+
1. **The bot.** The user messages **@BotFather**: `/newbot`, a name, a username ending in
|
|
15
|
+
`bot`. BotFather answers with a token. The user keeps it: never ask them to paste it
|
|
16
|
+
into the chat.
|
|
17
|
+
2. **Topic mode.** It is not in BotFather's `/mybots` → Bot Settings list; it is in
|
|
18
|
+
BotFather's mini app (the **Open** button at the bottom left of the BotFather chat):
|
|
19
|
+
pick the bot, turn on Threaded Mode / topics. Check it (step 5): `has_topics_enabled`
|
|
20
|
+
must be `true`, or no topic can be made.
|
|
21
|
+
3. **The profile's config.** Make `~/.config/herdrchat/telegram/config.env` (folder mode
|
|
22
|
+
700, file 600) with:
|
|
23
|
+
```
|
|
24
|
+
HERDRCHAT_CHAT=telegram
|
|
25
|
+
TELEGRAM_BOT_TOKEN=
|
|
26
|
+
TELEGRAM_OWNER_ID=
|
|
27
|
+
HERDR_SESSIONS=<the same sessions as the Feishu profile>
|
|
28
|
+
```
|
|
29
|
+
and ask the user to paste the token after `TELEGRAM_BOT_TOKEN=` themselves
|
|
30
|
+
(`open -e <file>` opens it), no quotes, no spaces.
|
|
31
|
+
4. **First start, to learn the owner.** `herdrchat start --profile telegram`; the user
|
|
32
|
+
messages the bot anything; it answers `TELEGRAM_OWNER_ID=<digits>`. The user puts that
|
|
33
|
+
in the config. Until then the bot makes no topic.
|
|
34
|
+
5. **Check, without printing secrets.** In a short `node -e`: read the token from the
|
|
35
|
+
config, call `https://api.telegram.org/bot<token>/getMe`, print only `username` and
|
|
36
|
+
`has_topics_enabled`; for the owner, print only whether `TELEGRAM_OWNER_ID` is digits.
|
|
37
|
+
6. **Start again.** Read the config (secrets hidden) in one step, then
|
|
38
|
+
`herdrchat start --profile telegram` in the next. One topic per tab appears; Telegram
|
|
39
|
+
takes about one message a second in a chat, so twenty tabs take twenty seconds. A new
|
|
40
|
+
topic gets the conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it
|
|
41
|
+
again.
|
|
42
|
+
|
|
43
|
+
## What rings
|
|
44
|
+
|
|
45
|
+
The same settings as Feishu, in the Telegram profile's own config.env:
|
|
46
|
+
|
|
47
|
+
- `HERDR_MUTED_SPACES` (default `all`): in these spaces posts go silently (a notification
|
|
48
|
+
without sound) and only "⚠️ needs you" and "✅ done" ring, as a short reply. Elsewhere
|
|
49
|
+
every post rings.
|
|
50
|
+
- `HERDR_SILENT_SPACES` (default none): nothing rings there, not even "needs you".
|
|
51
|
+
|
|
52
|
+
A bot cannot mute a chat or a topic for the user. A topic the user mutes in Telegram is
|
|
53
|
+
fully quiet, "needs you" included; `HERDR_SILENT_SPACES` does the same for a whole space
|
|
54
|
+
without muting each topic by hand.
|
|
55
|
+
|
|
56
|
+
## What Telegram does differently
|
|
57
|
+
|
|
58
|
+
| Feishu | Telegram, and what stands in |
|
|
59
|
+
|---|---|
|
|
60
|
+
| a group per tab | a topic per tab, in the bot's private chat |
|
|
61
|
+
| an ended group gets the "ended" tag | an ended topic's name starts with 🔚 (closing a topic is documented for supergroups only) |
|
|
62
|
+
| an image inside a card | each image as its own photo after the text |
|
|
63
|
+
| a text box on a dialog card | type the answer in the topic |
|
|
64
|
+
| tags, labels, pins, `/auth` | none (a topic cannot be pinned; a bot cannot act as the user) |
|
|
65
|
+
| the overview card | one message in the main thread, edited in place |
|
|
66
|
+
| `/` commands: typed | `/` shows a menu: ls, new, kill, screen, answer, esc, history |
|
|
67
|
+
|
|
68
|
+
## When something is off
|
|
69
|
+
|
|
70
|
+
| Seen | Cause | Do |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| log: `topic mode is off for @<bot>` | step 2 not done | turn it on in BotFather's mini app, check `has_topics_enabled`, start again |
|
|
73
|
+
| the bot answers `Owner is not set` | `TELEGRAM_OWNER_ID` empty | step 4 |
|
|
74
|
+
| `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 5) |
|
|
75
|
+
| log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop --profile telegram`, find the other one (another machine?), start once |
|
|
76
|
+
| log: `too fast, waiting N s` | Telegram's flood limit; the bot waits as told | nothing; it catches up |
|
|
77
|
+
| a click answers "This dialog is gone" | the card is older than the last 300 dialog cards | answer the newest card, or in the terminal |
|