herdrchat 1.1.2 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -11
- package/README.zh.md +11 -8
- package/bin/cli.mjs +110 -40
- package/docs/guide.md +82 -27
- package/lib/chats/feishu/client.mjs +7 -2
- package/lib/chats/feishu/index.mjs +53 -3
- package/lib/chats/telegram/index.mjs +24 -13
- package/lib/core/engine.mjs +61 -7
- package/lib/core/menu.mjs +8 -1
- package/lib/core/service.mjs +165 -0
- package/package.json +1 -1
- package/skills/herdrchat/SKILL.md +2 -1
- package/skills/herdrchat/references/ops.md +4 -2
- package/skills/herdrchat/references/telegram.md +4 -1
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/docs/guide.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
How herdrchat works and every setting, for when the README is not enough.
|
|
4
4
|
|
|
5
|
-
herdrchat is a chat front-end for hc: every
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
herdrchat is a chat front-end for hc: every herdr-app tab, followed and answered in a chat
|
|
6
|
+
app, Feishu or Telegram. Both sit on the same core (hc events, a reader per agent,
|
|
7
|
+
dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack and
|
|
8
|
+
Discord may follow the same way. A profile is one chat app (`HERDRCHAT_CHAT`); to use
|
|
9
|
+
both, run a profile each.
|
|
8
10
|
|
|
9
11
|
## Feishu
|
|
10
12
|
|
|
@@ -21,6 +23,24 @@ herdr-app, moved into one Feishu bot:
|
|
|
21
23
|
Everything comes from hc, so it works the same for Claude Code, codex, pi or any program
|
|
22
24
|
hc shows, on this machine or on a machine hc has added. Nothing is installed in the agents.
|
|
23
25
|
|
|
26
|
+
## Telegram
|
|
27
|
+
|
|
28
|
+
A Telegram bot cannot make groups, so it uses topics in your private chat with the bot
|
|
29
|
+
(topic mode must be on in @BotFather):
|
|
30
|
+
|
|
31
|
+
| herdr-app | Telegram |
|
|
32
|
+
|---|---|
|
|
33
|
+
| a tab | a topic named `<space> / <tab>` in the bot's private chat; type there to talk to it |
|
|
34
|
+
| a space | one topic icon color for all of its topics (Telegram offers six) |
|
|
35
|
+
| a terminal that is gone | its topic stays, its name starting with 🔚 (taken off if it comes back) |
|
|
36
|
+
| herdr itself | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
|
|
37
|
+
|
|
38
|
+
What differs: an image goes as its own photo after the text (a message cannot hold one);
|
|
39
|
+
a dialog's free answer is typed in the topic (a message has no text box); there are no
|
|
40
|
+
tags, labels, pins or `/auth` (a bot cannot act as you). Telegram takes about one message
|
|
41
|
+
a second in a chat, so the bot queues and waits as Telegram asks. Setting it up:
|
|
42
|
+
`skills/herdrchat/references/telegram.md`.
|
|
43
|
+
|
|
24
44
|
## How it works: hc, plus a reader per agent
|
|
25
45
|
|
|
26
46
|
**hc is the backbone.** The bot follows `hc events --json` for each session (format:
|
|
@@ -36,13 +56,14 @@ each agent a reader reads the agent's own records. The status hc reports names t
|
|
|
36
56
|
| `status.app` | reader | reads |
|
|
37
57
|
|---|---|---|
|
|
38
58
|
| `claude-code` | `lib/core/readers/claude.mjs` | its transcript: the terminal's foreground pid names the session in `~/.claude/sessions/<pid>.json` |
|
|
39
|
-
| anything else | none
|
|
59
|
+
| anything else | none | nothing by itself; what you type gets its screen in reply |
|
|
40
60
|
|
|
41
61
|
On another machine, a reader reads the same files through `hc exec --on`. To add an
|
|
42
|
-
agent
|
|
43
|
-
|
|
62
|
+
agent: `docs/architecture.md`, "Adding a harness".
|
|
63
|
+
|
|
64
|
+
## What a room shows
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
A room is a Feishu group or a Telegram topic.
|
|
46
67
|
|
|
47
68
|
- A program with a reader:
|
|
48
69
|
- while it works: one "⌛️ Working…" card per turn (then "✅ Worked"), edited every 5
|
|
@@ -50,9 +71,12 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
|
|
|
50
71
|
mid-turn) as **You:**, and each tool it calls as one line (`▸ Bash: Run the tests`).
|
|
51
72
|
It shows the program is alive, as herdr-app does, and since an edit is not a new
|
|
52
73
|
message, the phone does not ring every few seconds;
|
|
53
|
-
- when the turn ends: its last answer, as a new message.
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
- when the turn ends: its last answer, as a new message. In a space of
|
|
75
|
+
`HERDR_MUTED_SPACES` (all, by default) only "needs you" and "done" ring: in Feishu
|
|
76
|
+
the groups are muted (after `/auth`) and an @ under the answer rings; in Telegram
|
|
77
|
+
the posts go silently and a short reply rings. `HERDR_SILENT_SPACES` rings nothing.
|
|
78
|
+
- A message another terminal sent with `hc send` shows as **From t7 review**, not as
|
|
79
|
+
yours (hc wraps it in `<hc from="…">`, for any agent).
|
|
56
80
|
- Images too: ones it looked at (a screenshot, an image file it read) and ones you
|
|
57
81
|
pasted in the terminal, shown in the card.
|
|
58
82
|
- What you sent from the group is not repeated.
|
|
@@ -63,15 +87,21 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
|
|
|
63
87
|
a red "⚠️ Needs you" card. When the screen shows a numbered menu (as Claude Code's
|
|
64
88
|
permission and question dialogs do), the card shows only the dialog, as text, with a
|
|
65
89
|
button per option; a multi-select also has "Next ⇥", a "Type something" option a box to
|
|
66
|
-
type in
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
type in (Feishu; in Telegram you type the answer in the topic), and every card "Esc".
|
|
91
|
+
Without a menu the card shows the last lines of the screen and how to answer by typing.
|
|
92
|
+
Once the dialog is gone the card turns to "✅ Done". A card is posted only for a dialog
|
|
93
|
+
that still waits: one answered while its screen was read gets none.
|
|
94
|
+
|
|
95
|
+
## Answering a dialog from the chat
|
|
96
|
+
|
|
97
|
+
A button presses that option's number key in the terminal (Esc presses esc). A question
|
|
98
|
+
with previews (a box beside its options) takes no number, so there the button moves with
|
|
99
|
+
the arrows from the cursor to that option and presses Enter. In a multi-select, a box is
|
|
100
|
+
ticked on the card only, at once; Next sends them all: each box that differs from the
|
|
101
|
+
screen is pressed, then tab.
|
|
72
102
|
Before it presses anything, the bot reads the screen again and checks that it still
|
|
73
|
-
shows the same dialog (a hash of it, from its top border to its last option
|
|
74
|
-
same option, so an old card can never answer a newer dialog that looks alike. After the
|
|
103
|
+
shows the same dialog (a hash of it, from its top border to its last option, without the
|
|
104
|
+
cursor, the check marks and the preview box) and the same option, so an old card can never answer a newer dialog that looks alike. After the
|
|
75
105
|
key, the card turns into the dialog's next step (a toggled box, the next question, the
|
|
76
106
|
review) or says what was picked. Text you type in the group while it waits goes into its
|
|
77
107
|
"Type something" box when it has one.
|
|
@@ -123,14 +153,17 @@ delivered (the terminal has ended) says so plainly.
|
|
|
123
153
|
recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
|
|
124
154
|
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (see
|
|
125
155
|
below).
|
|
126
|
-
- In a terminal's
|
|
127
|
-
|
|
156
|
+
- In a terminal's room: any text goes to it (marked once typed in, and again when the
|
|
157
|
+
turn that took it is over); an image (alone or in a rich text message) is sent
|
|
128
158
|
beside the terminal with `hc put`, on its machine, and its path is typed in with your
|
|
129
159
|
text, so an agent like Claude Code opens it; `/screen`, `/answer <text>` (type into the
|
|
130
|
-
dialog it waits on, as is), `/esc
|
|
160
|
+
dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
|
|
161
|
+
a folded post). Dialog cards have buttons.
|
|
131
162
|
|
|
132
163
|
## Set up
|
|
133
164
|
|
|
165
|
+
Telegram: `skills/herdrchat/references/telegram.md`. Feishu:
|
|
166
|
+
|
|
134
167
|
Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
|
|
135
168
|
(`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
|
|
136
169
|
|
|
@@ -152,6 +185,24 @@ Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
|
|
|
152
185
|
7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
|
|
153
186
|
The bot answers with your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
|
|
154
187
|
`herdrchat start` again.
|
|
188
|
+
8. Once it works: `herdrchat install` (see "Running as a service").
|
|
189
|
+
|
|
190
|
+
## Running as a service
|
|
191
|
+
|
|
192
|
+
`herdrchat start` runs the bot in an hc terminal, which ends when that hc core stops,
|
|
193
|
+
and nothing starts it after a reboot. `herdrchat install` makes it a service of the
|
|
194
|
+
system instead: a launchd agent on macOS, a systemd user unit on Linux. It starts at
|
|
195
|
+
login, comes back by itself after a crash, and does not depend on any hc core. It runs
|
|
196
|
+
node and the bot by their full paths with only the variables it needs (hc's path, PATH,
|
|
197
|
+
the profile, the folders), so no agent's variables come along. With it installed,
|
|
198
|
+
`start`, `stop` and `status` go through the service; `herdrchat uninstall` removes it.
|
|
199
|
+
Each profile is its own service (`herdrchat install --profile telegram`).
|
|
200
|
+
|
|
201
|
+
- After a node upgrade that moves node, run `herdrchat install` again; `herdrchat status`
|
|
202
|
+
says when the service's node is gone.
|
|
203
|
+
- On Linux, `loginctl enable-linger` keeps it running after you log out.
|
|
204
|
+
- What the process prints before its log is open (a crash at start) goes to
|
|
205
|
+
`logs/service.out.log` in the state folder.
|
|
155
206
|
|
|
156
207
|
## Optional: let the bot act as you
|
|
157
208
|
|
|
@@ -181,9 +232,10 @@ refreshed, the bot says so once and falls back.
|
|
|
181
232
|
## Skills
|
|
182
233
|
|
|
183
234
|
`skills/herdrchat/` is one skill for an agent (Claude Code or another that reads
|
|
184
|
-
`SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `
|
|
185
|
-
|
|
186
|
-
a guided walk-through)
|
|
235
|
+
`SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `install`, `logs`, `auth`,
|
|
236
|
+
`config`, `fix`, `feishu <task>` (the developer console, with Ego Lite from
|
|
237
|
+
https://lite.ego.app/ or as a guided walk-through) and `telegram <task>` (the bot, from
|
|
238
|
+
@BotFather to the first topic). Details per part are in `skills/herdrchat/references/`. To use it
|
|
187
239
|
with Claude Code, link it where skills are read, for example
|
|
188
240
|
`ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
189
241
|
|
|
@@ -191,7 +243,7 @@ with Claude Code, link it where skills are read, for example
|
|
|
191
243
|
|
|
192
244
|
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder (one file a
|
|
193
245
|
day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
|
|
194
|
-
~/.local/state/herdrchat
|
|
246
|
+
~/.local/state/herdrchat/<profile>/logs/*.log` finds
|
|
195
247
|
what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
|
|
196
248
|
before it is started again. Tokens and secrets are never logged. The
|
|
197
249
|
skill's `references/ops.md` explains the lines.
|
|
@@ -199,12 +251,15 @@ skill's `references/ops.md` explains the lines.
|
|
|
199
251
|
## Limits
|
|
200
252
|
|
|
201
253
|
- Narration blocks are a condensed version of the text Claude showed, not the exact words.
|
|
202
|
-
- Only Claude Code has a reader so far. codex, pi and the rest
|
|
203
|
-
|
|
254
|
+
- Only Claude Code has a reader so far. codex, pi and the rest post nothing by themselves
|
|
255
|
+
until each has a reader; what you type to them gets their screen in reply.
|
|
204
256
|
- Only text, images and rich text are passed on; files, stickers and the rest are not.
|
|
205
257
|
- An image that fails to upload three times is not sent; the group says so and why.
|
|
206
258
|
- Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
|
|
207
259
|
at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
|
|
208
260
|
- Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
|
|
261
|
+
- Telegram: a bot cannot mute a topic for you; a topic you mute in Telegram stays fully
|
|
262
|
+
quiet, "needs you" included. Closing a topic is documented for supergroups only, so an
|
|
263
|
+
ended topic is marked by its name.
|
|
209
264
|
- Dialog buttons come from the screen's numbered menu; a dialog drawn some other way gets
|
|
210
265
|
the screen and `/answer`, `/esc`.
|
|
@@ -258,7 +258,8 @@ export async function renameChat(client, chatId, name) {
|
|
|
258
258
|
// Messages in (im.message.receive_v1) and card clicks (card.action.trigger), both over
|
|
259
259
|
// the long connection. A click must be answered within 3 seconds; what `onCardAction`
|
|
260
260
|
// returns ({toast, card}) is that answer.
|
|
261
|
-
|
|
261
|
+
// onConnect runs at every connection, the first and each one after a drop.
|
|
262
|
+
export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, onConnect = () => {}) {
|
|
262
263
|
const ws = new Lark.WSClient({
|
|
263
264
|
appId: cfg.appId,
|
|
264
265
|
appSecret: cfg.appSecret,
|
|
@@ -266,9 +267,13 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
|
|
|
266
267
|
onReady: () => {
|
|
267
268
|
console.log('[feishu] connected');
|
|
268
269
|
onReady();
|
|
270
|
+
onConnect();
|
|
269
271
|
},
|
|
270
272
|
onReconnecting: () => console.warn('[feishu] reconnecting'),
|
|
271
|
-
onReconnected: () =>
|
|
273
|
+
onReconnected: () => {
|
|
274
|
+
console.log('[feishu] reconnected');
|
|
275
|
+
onConnect();
|
|
276
|
+
},
|
|
272
277
|
onError: (err) => console.error(`[feishu] connection failed: ${err.message}`),
|
|
273
278
|
});
|
|
274
279
|
ws.start({
|
|
@@ -401,13 +401,40 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
401
401
|
|
|
402
402
|
// Messages (deduplicated, the owner's only) and card clicks, turned into the core's
|
|
403
403
|
// shape: {room (null: the direct chat), where, message, text, images}.
|
|
404
|
+
//
|
|
405
|
+
// Feishu wants an event handled within 3 seconds or it pushes it again, so a message is
|
|
406
|
+
// taken at once and then handled in its chat's own queue (in order). Whether Feishu
|
|
407
|
+
// sends again what came while the bot was offline is not documented, so the bot looks
|
|
408
|
+
// itself: per chat it keeps the time of the last message it handled (state.json
|
|
409
|
+
// feishuRead), and at every connection it lists what came after that and handles what
|
|
410
|
+
// it missed.
|
|
404
411
|
const seen = new Set();
|
|
412
|
+
const queues = new Map(); // chat -> its last queued message
|
|
413
|
+
function queued(chatId, fn) {
|
|
414
|
+
const next = (queues.get(chatId) || Promise.resolve()).then(fn).catch((err) => logError(`feishu message in ${chatId}:`, err));
|
|
415
|
+
queues.set(chatId, next);
|
|
416
|
+
next.then(() => { if (queues.get(chatId) === next) queues.delete(chatId); });
|
|
417
|
+
}
|
|
418
|
+
function markRead(chatId, at) {
|
|
419
|
+
const state = read();
|
|
420
|
+
if ((state.feishuRead?.[chatId] || 0) >= at) return;
|
|
421
|
+
state.feishuRead = { ...(state.feishuRead || {}), [chatId]: at };
|
|
422
|
+
write(state);
|
|
423
|
+
}
|
|
424
|
+
|
|
405
425
|
function start({ onMessage, onAction, onReady }) {
|
|
406
|
-
const
|
|
426
|
+
const take = (data) => {
|
|
407
427
|
const msg = data.message;
|
|
408
428
|
if (seen.has(msg.message_id)) return;
|
|
409
429
|
seen.add(msg.message_id);
|
|
410
|
-
if (seen.size >
|
|
430
|
+
if (seen.size > 2000) seen.delete(seen.values().next().value);
|
|
431
|
+
queued(msg.chat_id, async () => {
|
|
432
|
+
await handleMessage(data);
|
|
433
|
+
markRead(msg.chat_id, Number(msg.create_time) || Date.now());
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
const handleMessage = async (data) => {
|
|
437
|
+
const msg = data.message;
|
|
411
438
|
const openId = data.sender?.sender_id?.open_id;
|
|
412
439
|
if (!cfg.ownerOpenId) {
|
|
413
440
|
log(`owner not set; message from open_id ${openId}`);
|
|
@@ -437,7 +464,30 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
437
464
|
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 }) };
|
|
438
465
|
return res;
|
|
439
466
|
};
|
|
440
|
-
|
|
467
|
+
// What came while the bot was away: the owner's messages after the last one handled,
|
|
468
|
+
// in every chat it reads (a chat with none handled yet starts from now).
|
|
469
|
+
const catchUp = async () => {
|
|
470
|
+
if (!cfg.ownerOpenId) return;
|
|
471
|
+
const state = read();
|
|
472
|
+
const chats = new Set([...Object.values(state.chats || {}).filter((c) => !c.ended).map((c) => c.chat), ...Object.keys(state.feishuRead || {})]);
|
|
473
|
+
for (const chatId of chats) {
|
|
474
|
+
const since = state.feishuRead?.[chatId];
|
|
475
|
+
if (!since) {
|
|
476
|
+
markRead(chatId, Date.now());
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const res = await client.im.v1.message.list({ params: { container_id_type: 'chat', container_id: chatId, start_time: String(Math.floor(since / 1000)), sort_type: 'ByCreateTimeAsc', page_size: 50 } });
|
|
481
|
+
const missed = (res?.data?.items || []).filter((m) => Number(m.create_time) > since && m.sender?.sender_type === 'user' && m.sender?.id === cfg.ownerOpenId && !seen.has(m.message_id));
|
|
482
|
+
if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
|
|
483
|
+
const p2p = !Object.values(state.chats || {}).some((c) => c.chat === chatId);
|
|
484
|
+
for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group', message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)));
|
|
441
491
|
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
442
492
|
asYou().catch((err) => logError(`as you: ${err.message}`));
|
|
443
493
|
}
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
// topic's name gets a 🔚 in front, taken off when it is live again
|
|
14
14
|
// images a message cannot hold one, so each goes as its own photo after the text
|
|
15
15
|
// buttons callback_data holds 64 bytes, so a button carries a short id and the
|
|
16
|
-
// dialog it answers is kept
|
|
16
|
+
// dialog it answers is kept in state.json
|
|
17
17
|
// a free answer no text box on a message: you type the answer in the topic
|
|
18
18
|
// pin a topic cannot be pinned; nothing is done
|
|
19
|
+
import crypto from 'node:crypto';
|
|
19
20
|
import fs from 'node:fs';
|
|
20
21
|
import { createClient } from './client.mjs';
|
|
21
22
|
import { escapeHtml, split, toHtml } from './format.mjs';
|
|
@@ -133,14 +134,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
133
134
|
|
|
134
135
|
// ---------- dialogs ----------
|
|
135
136
|
|
|
136
|
-
// A button carries "<id>:<pick>"; the id names the dialog's value
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
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.
|
|
140
140
|
function keyboard(dialog, value) {
|
|
141
|
-
const id = (
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
|
144
148
|
const rows = [];
|
|
145
149
|
for (const o of dialog.menu.options) {
|
|
146
150
|
if (o === dialog.menu.free) continue;
|
|
@@ -326,10 +330,10 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
326
330
|
const answer = (text) => tg.call('answerCallbackQuery', { callback_query_id: q.id, text: (text || '').slice(0, 200) }).catch(() => {});
|
|
327
331
|
if (String(q.from?.id) !== owner) return answer('Only the owner can answer.');
|
|
328
332
|
const [id, pickRaw] = String(q.data || '').split(':');
|
|
329
|
-
const kept = buttons
|
|
333
|
+
const kept = read().buttons?.[id];
|
|
330
334
|
const message = String(q.message?.message_id || '');
|
|
331
335
|
if (!kept) {
|
|
332
|
-
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(() => {});
|
|
333
337
|
return answer('This dialog is gone.');
|
|
334
338
|
}
|
|
335
339
|
const pick = /^\d+$/.test(pickRaw) ? Number(pickRaw) : pickRaw;
|
|
@@ -340,9 +344,12 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
340
344
|
}
|
|
341
345
|
|
|
342
346
|
// Long polling: getUpdates holds up to POLL_SECONDS for news; a failure waits RETRY_MS
|
|
343
|
-
// and tries again (409: another copy of the bot polls the same token).
|
|
347
|
+
// and tries again (409: another copy of the bot polls the same token). Telegram keeps
|
|
348
|
+
// what the bot has not confirmed for 24 hours, so nothing sent while it was away is
|
|
349
|
+
// lost; which update it handled last is kept in state.json (telegramOffset), so after a
|
|
350
|
+
// restart nothing is handled twice.
|
|
344
351
|
function start({ onMessage, onAction, onReady = () => {} }) {
|
|
345
|
-
let offset = 0;
|
|
352
|
+
let offset = read().telegramOffset || 0;
|
|
346
353
|
let connected = false;
|
|
347
354
|
(async () => {
|
|
348
355
|
for (;;) {
|
|
@@ -361,13 +368,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
361
368
|
}
|
|
362
369
|
const updates = await tg.call('getUpdates', { offset, timeout: POLL_SECONDS, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (POLL_SECONDS + 15) * 1000 });
|
|
363
370
|
for (const u of updates) {
|
|
364
|
-
offset = u.update_id + 1;
|
|
365
371
|
try {
|
|
366
372
|
if (u.message) await handleMessage(u.message, onMessage);
|
|
367
373
|
if (u.callback_query) await handleClick(u.callback_query, onAction);
|
|
368
374
|
} catch (err) {
|
|
369
375
|
logError(`telegram update ${u.update_id}:`, err);
|
|
370
376
|
}
|
|
377
|
+
// saved once handled: a crash halfway handles it again, never skips it
|
|
378
|
+
offset = u.update_id + 1;
|
|
379
|
+
const state = read();
|
|
380
|
+
state.telegramOffset = offset;
|
|
381
|
+
write(state);
|
|
371
382
|
}
|
|
372
383
|
} catch (err) {
|
|
373
384
|
logError(`telegram poll: ${err.message}`);
|
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
|
}
|
|
@@ -624,9 +641,17 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
624
641
|
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
625
642
|
}
|
|
626
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
|
+
|
|
627
652
|
// "Type something": pick that option, then type the words.
|
|
628
653
|
async function answerFree(session, address, menu, text) {
|
|
629
|
-
await hc.keys(session, address,
|
|
654
|
+
await hc.keys(session, address, pickKeys(menu, menu.free));
|
|
630
655
|
await sleep(300);
|
|
631
656
|
await hc.send(session, address, text, { force: true });
|
|
632
657
|
}
|
|
@@ -689,7 +714,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
689
714
|
await answerFree(v.s, v.a, menu, text);
|
|
690
715
|
picked = text;
|
|
691
716
|
} else {
|
|
692
|
-
await hc.keys(v.s, v.a,
|
|
717
|
+
await hc.keys(v.s, v.a, pickKeys(menu, option));
|
|
693
718
|
picked = `${option.n}. ${option.label}`;
|
|
694
719
|
}
|
|
695
720
|
}
|
|
@@ -772,6 +797,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
772
797
|
if (now !== was) since.set(k, Date.now());
|
|
773
798
|
if (now !== 'blocked') {
|
|
774
799
|
postedDialog.delete(k);
|
|
800
|
+
if (now) sendWaiting(k);
|
|
775
801
|
if (chat.pinned().includes(k)) later(session, address, 'unpin', () => chat.unpin(k));
|
|
776
802
|
if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
|
|
777
803
|
return;
|
|
@@ -863,6 +889,23 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
863
889
|
}
|
|
864
890
|
}
|
|
865
891
|
|
|
892
|
+
// Messages sent while it waited on a dialog with no box to type in, per terminal, kept
|
|
893
|
+
// in state.json (waiting) so a restart does not lose them; they go in, in order, once it
|
|
894
|
+
// no longer waits (onStatus, and at start).
|
|
895
|
+
function hold(k, m) {
|
|
896
|
+
const state = read();
|
|
897
|
+
state.waiting = { ...(state.waiting || {}), [k]: [...(state.waiting?.[k] || []), m] };
|
|
898
|
+
write(state);
|
|
899
|
+
}
|
|
900
|
+
function sendWaiting(k) {
|
|
901
|
+
const state = read();
|
|
902
|
+
const held = state.waiting?.[k];
|
|
903
|
+
if (!held?.length) return;
|
|
904
|
+
delete state.waiting[k];
|
|
905
|
+
write(state);
|
|
906
|
+
for (const m of held) laneOf(k, 'held message', () => typeInto(m.where, m.message, m.session, m.address, m.text));
|
|
907
|
+
}
|
|
908
|
+
|
|
866
909
|
async function typeInto(where, message, session, address, text) {
|
|
867
910
|
const record = views.get(session)?.terminals.get(address);
|
|
868
911
|
if (!record || record.state !== 'live') {
|
|
@@ -883,16 +926,21 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
883
926
|
return;
|
|
884
927
|
}
|
|
885
928
|
const answer = text.match(/^\/answer\s+([\s\S]+)$/);
|
|
886
|
-
|
|
929
|
+
// whether it waits on a dialog now: asked of hc, since the status followed from its
|
|
930
|
+
// events can be a moment behind (a dialog just answered still looks blocked)
|
|
931
|
+
const waits = !answer && agent && (await hc.getTerminal(session, address).catch(() => record))?.status?.state === 'blocked';
|
|
932
|
+
if (waits) {
|
|
887
933
|
// It waits on a dialog: words go into its "Type something" box when it has one;
|
|
888
|
-
// otherwise the dialog
|
|
934
|
+
// otherwise they wait for the dialog to be answered, then go in (never dropped).
|
|
889
935
|
const menu = menuOf(await hc.screenTail(session, address, SCREEN_LINES));
|
|
890
936
|
if (menu?.free) {
|
|
891
937
|
await answerFree(session, address, menu, text);
|
|
892
938
|
log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
|
|
893
939
|
await markWorking(key(session, address), message);
|
|
894
940
|
} else {
|
|
895
|
-
|
|
941
|
+
hold(key(session, address), { where, message, session, address, text });
|
|
942
|
+
log(`-> ${session}/${address}: held until its dialog is answered: ${text.slice(0, 80)}`);
|
|
943
|
+
await chat.reply(where, 'It asks something first (the card above). Your message goes in once that is answered.');
|
|
896
944
|
}
|
|
897
945
|
return;
|
|
898
946
|
}
|
|
@@ -1032,6 +1080,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1032
1080
|
const target = terminalOfRoom(room);
|
|
1033
1081
|
if (!target) {
|
|
1034
1082
|
log(`message in a room this bot did not make: ${room}`);
|
|
1083
|
+
await chat.reply(where, 'Not delivered: this room is not a terminal\'s. Each terminal has its own room; the direct chat takes /ls, /new and /kill.');
|
|
1035
1084
|
return;
|
|
1036
1085
|
}
|
|
1037
1086
|
if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
|
|
@@ -1058,6 +1107,11 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1058
1107
|
for (const session of cfg.sessions) watch(session);
|
|
1059
1108
|
ready.then(() => setTimeout(async () => {
|
|
1060
1109
|
await endGone().catch((err) => logError(`end gone rooms: ${err.message}`));
|
|
1110
|
+
// messages held before a restart, for terminals that no longer wait
|
|
1111
|
+
for (const k of Object.keys(read().waiting || {})) {
|
|
1112
|
+
const address = addressOfKey(k);
|
|
1113
|
+
if (!cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.status?.state === 'blocked')) sendWaiting(k);
|
|
1114
|
+
}
|
|
1061
1115
|
// pins left from before a restart, for terminals that no longer wait
|
|
1062
1116
|
for (const k of chat.pinned()) {
|
|
1063
1117
|
const address = addressOfKey(k);
|
package/lib/core/menu.mjs
CHANGED
|
@@ -45,7 +45,8 @@ function keyOf(lines, top, last) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function menuOf(screen) {
|
|
48
|
-
const
|
|
48
|
+
const raw = screen.split('\n');
|
|
49
|
+
const lines = raw.map(ownPart);
|
|
49
50
|
let run = null;
|
|
50
51
|
let menu = null;
|
|
51
52
|
lines.forEach((line, i) => {
|
|
@@ -63,7 +64,13 @@ export function menuOf(screen) {
|
|
|
63
64
|
});
|
|
64
65
|
if (!menu) return null;
|
|
65
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;
|
|
66
71
|
return {
|
|
72
|
+
beside,
|
|
73
|
+
cursor,
|
|
67
74
|
key: keyOf(lines, top, menu.last),
|
|
68
75
|
framed: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
|
|
69
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
|
@@ -22,7 +22,8 @@ herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A profile
|
|
|
22
22
|
|---|---|
|
|
23
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`. |
|
|
24
24
|
| `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
|
|
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. |
|
|
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). |
|
|
26
27
|
| `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
|
|
27
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. |
|
|
28
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. |
|
|
@@ -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
|
|
@@ -39,6 +39,9 @@ Do these in order; the user does the Telegram steps, you check each one.
|
|
|
39
39
|
takes about one message a second in a chat, so twenty tabs take twenty seconds. A new
|
|
40
40
|
topic gets the conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it
|
|
41
41
|
again.
|
|
42
|
+
7. **Make it a service.** Once it works: `herdrchat install --profile telegram`. It then
|
|
43
|
+
starts at login and comes back by itself after a crash, whatever happens to the hc
|
|
44
|
+
cores; `herdrchat status --profile telegram` says it runs as the service.
|
|
42
45
|
|
|
43
46
|
## What rings
|
|
44
47
|
|
|
@@ -74,4 +77,4 @@ without muting each topic by hand.
|
|
|
74
77
|
| `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 5) |
|
|
75
78
|
| log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop --profile telegram`, find the other one (another machine?), start once |
|
|
76
79
|
| 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
|
|
80
|
+
| 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 |
|