herdrchat 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -23
- package/README.zh.md +70 -16
- package/bin/cli.mjs +126 -47
- package/bin/herdrchat.mjs +2 -2
- package/config.env.example +37 -20
- package/docs/architecture.md +13 -10
- package/docs/guide.md +134 -45
- package/lib/chats/contract.mjs +3 -2
- package/lib/chats/feishu/client.mjs +14 -5
- package/lib/chats/feishu/index.mjs +15 -2
- package/lib/chats/telegram/index.mjs +29 -7
- package/lib/core/config.mjs +28 -10
- package/lib/core/engine.mjs +17 -11
- package/lib/core/hc.mjs +57 -1
- package/lib/core/log.mjs +10 -4
- package/lib/core/readers/index.mjs +1 -1
- package/lib/core/service.mjs +1 -0
- package/package.json +1 -1
- package/skills/herdrchat/SKILL.md +9 -8
- package/skills/herdrchat/references/feishu.md +25 -3
- package/skills/herdrchat/references/ops.md +22 -52
- package/skills/herdrchat/references/telegram.md +31 -36
package/docs/architecture.md
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
# herdrchat architecture
|
|
2
2
|
|
|
3
|
-
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu
|
|
4
|
-
Telegram
|
|
5
|
-
and pi). The core between them knows neither. This page says where everything is and
|
|
3
|
+
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu and
|
|
4
|
+
Telegram now, Slack and Discord later) and **agents**, each read by its reader (Claude
|
|
5
|
+
Code now, then codex and pi). The core between them knows neither. This page says where everything is and
|
|
6
6
|
how to add one of each.
|
|
7
7
|
|
|
8
8
|
## The parts
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
bin/
|
|
12
|
-
cli.mjs herdrchat init|start|stop|status|logs
|
|
12
|
+
cli.mjs herdrchat init|start|stop|status|logs|install|uninstall|run|version
|
|
13
13
|
herdrchat.mjs wires the parts: config, log, the chat app, the core
|
|
14
14
|
lib/
|
|
15
|
-
core/ the same for every chat app and every
|
|
15
|
+
core/ the same for every chat app and every agent
|
|
16
16
|
engine.mjs follows hc, keeps one room per herdr-app tab, posts what agents
|
|
17
17
|
say, dialogs and clicks, what you type, the overview
|
|
18
|
-
hc.mjs events.mjs hc: commands,
|
|
18
|
+
hc.mjs events.mjs hc: commands, its live event stream, the sessions on a machine
|
|
19
19
|
views.mjs which view of a terminal is followed (hc core id + tN)
|
|
20
20
|
cursor.mjs home.mjs what was posted already; reading an agent's log files
|
|
21
21
|
menu.mjs a dialog's options, read from the screen
|
|
22
22
|
config.mjs log.mjs config.env, state.json, the daily log
|
|
23
|
-
|
|
23
|
+
service.mjs herdrchat as a launchd or systemd service
|
|
24
|
+
readers/ one reader per agent (the contract is in index.mjs)
|
|
24
25
|
chats/
|
|
25
26
|
contract.mjs what a chat app gives the core, and the check for it
|
|
26
27
|
index.mjs the list of chat apps
|
|
27
|
-
feishu/ index.mjs (the contract), client.mjs, overview.mjs,
|
|
28
|
+
feishu/ index.mjs (the contract), client.mjs, overview.mjs, avatar.mjs, userauth.mjs
|
|
29
|
+
telegram/ index.mjs (the contract), client.mjs, format.mjs
|
|
28
30
|
test/
|
|
29
31
|
fakes.mjs a fake chat app, a fake hc and a fake reader
|
|
30
|
-
engine.test.mjs the core, run on the fakes
|
|
32
|
+
engine.test.mjs the core, run on the fakes; the apps' contracts
|
|
33
|
+
*.test.mjs the parts one by one
|
|
31
34
|
```
|
|
32
35
|
|
|
33
36
|
The rules that keep it this way:
|
|
@@ -61,7 +64,7 @@ The rules that keep it this way:
|
|
|
61
64
|
`HERDRCHAT_CHAT=<app>` in that profile's config.env), so the running Feishu bot is
|
|
62
65
|
not touched.
|
|
63
66
|
|
|
64
|
-
## Adding
|
|
67
|
+
## Adding an agent
|
|
65
68
|
|
|
66
69
|
1. Read how the agent keeps its conversation on disk, from its docs or its source: where
|
|
67
70
|
the log is, how to find the one for a running terminal, how a turn's last answer is
|
package/docs/guide.md
CHANGED
|
@@ -8,17 +8,33 @@ dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack
|
|
|
8
8
|
Discord may follow the same way. A profile is one chat app (`HERDRCHAT_CHAT`); to use
|
|
9
9
|
both, run a profile each.
|
|
10
10
|
|
|
11
|
+
Words used here:
|
|
12
|
+
|
|
13
|
+
| Word | Meaning |
|
|
14
|
+
|---|---|
|
|
15
|
+
| hc, herdr-core | the program that runs terminals and keeps them alive ([herdr.dev](https://herdr.dev)) |
|
|
16
|
+
| herdr-app | its desktop and phone app, which shows the terminals in spaces and tabs |
|
|
17
|
+
| session | one hc core and its terminals (`HC_SESSION`); `ls ~/.local/state/herdr-core` lists those on a machine |
|
|
18
|
+
| space, tab | how herdr-app groups terminals in its sidebar |
|
|
19
|
+
| room | where one tab is mirrored: a Feishu group or a Telegram topic |
|
|
20
|
+
| direct chat | your private chat with the bot: the overview and the commands |
|
|
21
|
+
| profile | one herdrchat setup for one chat app, with its own settings and state (`default` unless named) |
|
|
22
|
+
| reader | the part that reads an agent's own conversation log (Claude Code so far) |
|
|
23
|
+
|
|
11
24
|
## Feishu
|
|
12
25
|
|
|
13
26
|
herdr-app, moved into one Feishu bot:
|
|
14
27
|
|
|
15
28
|
| herdr-app | Feishu |
|
|
16
29
|
|---|---|
|
|
17
|
-
| a
|
|
30
|
+
| a tab | a group named `<space> / <tab>`, tagged with its session; type there to talk to it |
|
|
18
31
|
| a space | one avatar for all of its groups: the space's first letter on a color of its own |
|
|
19
32
|
| a terminal on another machine | a group named `<space> / <tab>`, tagged with the machine, made once |
|
|
20
33
|
| a terminal that is gone | its group stays, with its history, tagged `ended` (untagged if it comes back) |
|
|
21
|
-
|
|
|
34
|
+
| the sidebar | the direct chat with the bot: an overview card (every terminal's state, what waits for you first, each a link to its group), kept up to date in place; `/ls`, `/new`, `/kill` |
|
|
35
|
+
|
|
36
|
+
A tab herdr-app only numbered (`2`) is named after the terminal's title instead (what the
|
|
37
|
+
program sets; Claude Code sets its session name or topic), else its hc name.
|
|
22
38
|
|
|
23
39
|
Everything comes from hc, so it works the same for Claude Code, codex, pi or any program
|
|
24
40
|
hc shows, on this machine or on a machine hc has added. Nothing is installed in the agents.
|
|
@@ -26,25 +42,75 @@ hc shows, on this machine or on a machine hc has added. Nothing is installed in
|
|
|
26
42
|
## Telegram
|
|
27
43
|
|
|
28
44
|
A Telegram bot cannot make groups, so it uses topics in your private chat with the bot
|
|
29
|
-
(topic mode must be on in
|
|
45
|
+
(topic mode must be on: BotFather's mini app, the Open button in the BotFather chat, then
|
|
46
|
+
the bot, Threaded Mode):
|
|
30
47
|
|
|
31
48
|
| herdr-app | Telegram |
|
|
32
49
|
|---|---|
|
|
33
50
|
| a tab | a topic named `<space> / <tab>` in the bot's private chat; type there to talk to it |
|
|
34
51
|
| a space | one topic icon color for all of its topics (Telegram offers six) |
|
|
35
52
|
| a terminal that is gone | its topic stays, its name starting with 🔚 (taken off if it comes back) |
|
|
36
|
-
|
|
|
53
|
+
| the sidebar | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
|
|
54
|
+
|
|
55
|
+
What differs is in "Feishu and Telegram side by side" below. Telegram takes about one
|
|
56
|
+
message a second in a chat, so the bot queues and waits as Telegram asks. Setting it up:
|
|
57
|
+
"Set up: Telegram" below.
|
|
58
|
+
|
|
59
|
+
## Feishu and Telegram side by side
|
|
37
60
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
Both do the same things; where an app cannot, something stands in.
|
|
62
|
+
|
|
63
|
+
| | Feishu | Telegram |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| a tab | a group | a topic in the bot's private chat |
|
|
66
|
+
| a space | an avatar (its letter, its color) | a topic icon color |
|
|
67
|
+
| an ended terminal | the `ended` tag | 🔚 before the topic's name |
|
|
68
|
+
| the direct chat | the overview card, commands typed or from the bot menu (set in the console) | the main thread: the overview, commands with the `/` list |
|
|
69
|
+
| what it says while working | one live card per turn, edited | one live message per turn, edited |
|
|
70
|
+
| a dialog | a card with buttons, a text box for its own words | a message with buttons; its own words are typed in the topic |
|
|
71
|
+
| needs you | the group pinned (after `/auth`), an @ that rings | the dialog card pinned in its topic, a reply that rings |
|
|
72
|
+
| quiet spaces (`HERDR_MUTED_SPACES`) | groups muted (after `/auth`); only an @ rings | posts silent; only a short reply rings |
|
|
73
|
+
| silent spaces (`HERDR_SILENT_SPACES`) | no @ | no ringing reply |
|
|
74
|
+
| a new room (`HERDR_HISTORY`), `/history` | the conversation as a file, its newest part in a folded panel | the same, the newest part in an expandable quote |
|
|
75
|
+
| images | in the card, both ways | as photos after the text, both ways |
|
|
76
|
+
| your message marked | a reaction, then OK | 👀, then 👌 |
|
|
77
|
+
| tags and labels (`HERDR_BOT_TAGS`, `HERDR_USER_TAGS`) | yes | none in Telegram |
|
|
78
|
+
| messages while the bot is away | looked for and handled when it connects | kept by Telegram for 24 hours, handled when it connects |
|
|
79
|
+
| acting as you (`/auth`) | mute, labels, pins | not needed: posts are silent by themselves |
|
|
80
|
+
|
|
81
|
+
## Settings
|
|
82
|
+
|
|
83
|
+
In the profile's `config.env` (`herdrchat status` prints where it is):
|
|
84
|
+
|
|
85
|
+
| Key | For | Default | What it does |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `HERDRCHAT_CHAT` | both | `feishu` | the chat app this profile mirrors into: `feishu` or `telegram` |
|
|
88
|
+
| `HERDR_SESSIONS` | both | (required) | the hc sessions to mirror, comma-separated; machines they reach come along |
|
|
89
|
+
| `HERDR_MUTED_SPACES` | both | `all` | spaces whose rooms are quiet: only "needs you" and "done" ring; empty for none |
|
|
90
|
+
| `HERDR_SILENT_SPACES` | both | (none) | spaces where nothing rings, not even "needs you" |
|
|
91
|
+
| `HERDR_HISTORY` | both | `file,folded` | what a new room gets of the conversation before it: `file`, `folded`, both, or empty |
|
|
92
|
+
| `FEISHU_APP_ID`, `FEISHU_APP_SECRET` | Feishu | (required) | the Feishu app |
|
|
93
|
+
| `FEISHU_OWNER_OPEN_ID` | Feishu | (empty) | you; the bot tells you yours when you message it |
|
|
94
|
+
| `HERDR_BOT_TAGS` | Feishu | `on` | the session or machine tag and `ended` next to a group's name |
|
|
95
|
+
| `HERDR_USER_TAGS` | Feishu | `on` | your sidebar labels, once `/auth` is done |
|
|
96
|
+
| `TELEGRAM_BOT_TOKEN` | Telegram | (required) | the bot's token from @BotFather |
|
|
97
|
+
| `TELEGRAM_OWNER_ID` | Telegram | (empty) | you; the bot tells you yours when you message it |
|
|
98
|
+
|
|
99
|
+
Where a profile's files are: settings in `~/.config/herdrchat/<profile>/config.env`,
|
|
100
|
+
what the bot writes (state, logs, your `/auth` token) in
|
|
101
|
+
`~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both.
|
|
102
|
+
|
|
103
|
+
In the environment (not `config.env`): `HERDRCHAT_PROFILE` (or `--profile`) picks the
|
|
104
|
+
profile; `HERDRCHAT_HOME` puts a profile's `config.env` and state in one folder of your
|
|
105
|
+
choice (give each profile its own); `XDG_CONFIG_HOME` and `XDG_STATE_HOME` move the usual
|
|
106
|
+
folders. A setup in a moved folder gets its own hc terminal and service name, so trying
|
|
107
|
+
one never touches the usual one. A key set in the environment wins over `config.env`. A
|
|
108
|
+
setting changed in `config.env` takes effect at the next `herdrchat start`.
|
|
43
109
|
|
|
44
110
|
## How it works: hc, plus a reader per agent
|
|
45
111
|
|
|
46
|
-
**hc is the backbone.** The bot follows `hc events --json` for each session (format:
|
|
47
|
-
|
|
112
|
+
**hc is the backbone.** The bot follows `hc events --json` for each session (its format:
|
|
113
|
+
`hc schema`). From hc it learns which terminals exist, where
|
|
48
114
|
herdr-app shows them, their status, and when a turn ends (`turn` event: the status left
|
|
49
115
|
`working`). Typing from a group goes through `hc send`.
|
|
50
116
|
|
|
@@ -59,7 +125,7 @@ each agent a reader reads the agent's own records. The status hc reports names t
|
|
|
59
125
|
| anything else | none | nothing by itself; what you type gets its screen in reply |
|
|
60
126
|
|
|
61
127
|
On another machine, a reader reads the same files through `hc exec --on`. To add an
|
|
62
|
-
agent: `docs/architecture.md`, "Adding
|
|
128
|
+
agent: `docs/architecture.md`, "Adding an agent".
|
|
63
129
|
|
|
64
130
|
## What a room shows
|
|
65
131
|
|
|
@@ -151,8 +217,10 @@ delivered (the terminal has ended) says so plainly.
|
|
|
151
217
|
|
|
152
218
|
- Direct chat: `/ls` (the overview again at the bottom of the chat; the old one is
|
|
153
219
|
recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
|
|
154
|
-
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (
|
|
155
|
-
below).
|
|
220
|
+
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (Feishu;
|
|
221
|
+
see below). Telegram lists them when you type `/`; Feishu can have a bot menu for
|
|
222
|
+
`/ls`, help and `/auth` (set in the developer console:
|
|
223
|
+
`skills/herdrchat/references/feishu.md`, "The bot menu").
|
|
156
224
|
- In a terminal's room: any text goes to it (marked once typed in, and again when the
|
|
157
225
|
turn that took it is over); an image (alone or in a rich text message) is sent
|
|
158
226
|
beside the terminal with `hc put`, on its machine, and its path is typed in with your
|
|
@@ -160,32 +228,52 @@ delivered (the terminal has ended) says so plainly.
|
|
|
160
228
|
dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
|
|
161
229
|
a folded post). Dialog cards have buttons.
|
|
162
230
|
|
|
163
|
-
## Set up
|
|
164
|
-
|
|
165
|
-
Telegram
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
231
|
+
## Set up: Telegram
|
|
232
|
+
|
|
233
|
+
1. In Telegram, message **@BotFather**: `/newbot`, give it a name and a username ending
|
|
234
|
+
in `bot`. It answers with a token; keep it private.
|
|
235
|
+
2. Turn on topics for the bot: in the BotFather chat tap **Open** (its mini app), pick the
|
|
236
|
+
bot, turn on **Threaded Mode**. Without it the bot cannot make topics.
|
|
237
|
+
3. `herdrchat init --chat telegram` (with `--profile <name>` too if you also run Feishu)
|
|
238
|
+
prints where its `config.env` is. Fill in `TELEGRAM_BOT_TOKEN` and `HERDR_SESSIONS`
|
|
239
|
+
(`ls ~/.local/state/herdr-core` lists your hc sessions).
|
|
240
|
+
4. `herdrchat start`, then message the bot anything. It answers with the line to add,
|
|
241
|
+
`TELEGRAM_OWNER_ID=<your id>`; add it and run `herdrchat start` again. One topic per
|
|
242
|
+
tab appears in your private chat with the bot (Telegram takes about one message a
|
|
243
|
+
second, so many tabs take a moment).
|
|
244
|
+
5. Once it works: `herdrchat install` (see "Running as a service").
|
|
245
|
+
|
|
246
|
+
If Telegram is your only app, use the default profile with `--chat telegram`. Use
|
|
247
|
+
`--profile <name>` only for a second chat app beside the first.
|
|
248
|
+
|
|
249
|
+
## Set up: Feishu
|
|
250
|
+
|
|
251
|
+
Feishu only: Lark (the international version) is not supported yet. Steps 1, 2, 5 and 6
|
|
252
|
+
are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrchat skill
|
|
253
|
+
(`/herdrchat setup`) can do them with you.
|
|
254
|
+
|
|
255
|
+
1. Create a custom app (企业自建应用) and add the bot ability (机器人).
|
|
256
|
+
2. Permissions: import the scopes listed in `skills/herdrchat/references/feishu.md`,
|
|
257
|
+
"Scopes this bot uses" (there as JSON for the batch import): sending as the bot,
|
|
258
|
+
reading the messages sent to it, making and renaming groups, reactions, images both
|
|
259
|
+
ways, and group tags.
|
|
260
|
+
3. `herdrchat init` prints where its `config.env` is. Fill in the app's App ID and App
|
|
261
|
+
Secret (the app's 凭证与基础信息 page) and `HERDR_SESSIONS` (`ls
|
|
262
|
+
~/.local/state/herdr-core` lists your hc sessions).
|
|
263
|
+
4. `herdrchat start`. The bot must be running for the next step: the console checks for
|
|
264
|
+
a live connection.
|
|
181
265
|
5. Events and callbacks (事件与回调): for events, choose the long connection mode (长连接)
|
|
182
266
|
and add `im.message.receive_v1`; for callbacks (回调配置), choose the long connection
|
|
183
267
|
mode too and add `card.action.trigger` (the dialog buttons).
|
|
184
|
-
6. Publish a version.
|
|
268
|
+
6. Publish a version. If the page does not say 本次发布免审核, your team's admin must
|
|
269
|
+
approve it first.
|
|
185
270
|
7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
|
|
186
|
-
The bot answers with
|
|
187
|
-
`herdrchat start` again.
|
|
188
|
-
8.
|
|
271
|
+
The bot answers with the line to add, `FEISHU_OWNER_OPEN_ID=<your open_id>`; add it
|
|
272
|
+
and run `herdrchat start` again. One group per tab appears.
|
|
273
|
+
8. Optional: send `/auth` to the bot so it can mute its groups for you ("Optional: let
|
|
274
|
+
the bot act as you"), and add the bot menu (`skills/herdrchat/references/feishu.md`,
|
|
275
|
+
"The bot menu").
|
|
276
|
+
9. Once it works: `herdrchat install` (see "Running as a service").
|
|
189
277
|
|
|
190
278
|
## Running as a service
|
|
191
279
|
|
|
@@ -235,18 +323,18 @@ refreshed, the bot says so once and falls back.
|
|
|
235
323
|
`SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `install`, `logs`, `auth`,
|
|
236
324
|
`config`, `fix`, `feishu <task>` (the developer console, with Ego Lite from
|
|
237
325
|
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/`.
|
|
239
|
-
with
|
|
240
|
-
`ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
326
|
+
@BotFather to the first topic). Details per part are in `skills/herdrchat/references/`.
|
|
327
|
+
Install it with `npx skills add zhangxiangliang/herdrchat` (README); from a clone you can
|
|
328
|
+
link it instead, for example `ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
241
329
|
|
|
242
330
|
## Logs
|
|
243
331
|
|
|
244
|
-
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder
|
|
245
|
-
day
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
before it is started again. Tokens and secrets are never logged. The
|
|
249
|
-
|
|
332
|
+
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder, one file a
|
|
333
|
+
day by your local date (the last 14 kept), as `<time> <LEVEL> <text>` with the time in
|
|
334
|
+
UTC. `herdrchat logs` shows today's (`--errors` only what went wrong, `--day 2026-01-31`
|
|
335
|
+
another day); a retry that recovered is a `WARN`, and a crash leaves its stack there
|
|
336
|
+
before it is started again. Tokens and secrets are never logged. The skill's
|
|
337
|
+
`references/ops.md` explains the lines.
|
|
250
338
|
|
|
251
339
|
## Limits
|
|
252
340
|
|
|
@@ -255,6 +343,7 @@ skill's `references/ops.md` explains the lines.
|
|
|
255
343
|
until each has a reader; what you type to them gets their screen in reply.
|
|
256
344
|
- Only text, images and rich text are passed on; files, stickers and the rest are not.
|
|
257
345
|
- An image that fails to upload three times is not sent; the group says so and why.
|
|
346
|
+
- Feishu only, not Lark (the international version) yet: the bot talks to open.feishu.cn.
|
|
258
347
|
- Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
|
|
259
348
|
at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
|
|
260
349
|
- Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
|
package/lib/chats/contract.mjs
CHANGED
|
@@ -18,6 +18,7 @@ export const CHAT_MEMBERS = {
|
|
|
18
18
|
name: 'string: the app, as in HERDRCHAT_CHAT',
|
|
19
19
|
ready: '() -> boolean: the owner is known (no room is made before)',
|
|
20
20
|
help: 'string[]: lines it adds to the direct chat\'s help',
|
|
21
|
+
roomWord: 'string: what the user calls a room in this app (group, topic), for what the bot says',
|
|
21
22
|
liveBytes: 'number: how much text one editable card holds',
|
|
22
23
|
split: '(markdown) -> string[]: one message\'s worth each',
|
|
23
24
|
start: '({onMessage, onAction, onReady}): connect; onMessage({room|null, where, message, text, images}) for the owner\'s messages (room null: the direct chat), onAction({value, message, answer}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks',
|
|
@@ -33,7 +34,7 @@ export const CHAT_MEMBERS = {
|
|
|
33
34
|
editCard: '(message, look)',
|
|
34
35
|
postDialog: '(room, dialog, value) -> message: dialog {title, markdown, menu, doneTitle}; each button sends value plus {pick, label}',
|
|
35
36
|
closeDialog: '(message, dialog, done): no buttons any more, says what was done',
|
|
36
|
-
pin: '(k, room): the room waits for you',
|
|
37
|
+
pin: '(k, room, message): the room waits for you, on this message (its dialog card)',
|
|
37
38
|
unpin: '(k)',
|
|
38
39
|
pinned: '() -> k[]',
|
|
39
40
|
ring: '(k, message, text): make the phone ring for this message if it would not',
|
|
@@ -48,7 +49,7 @@ export const CHAT_MEMBERS = {
|
|
|
48
49
|
showOverview: '(rows, {fresh}): rows [{name, address, state, kind, program, since, room}]',
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
const TYPES = { name: 'string', help: 'object', liveBytes: 'number', foldedBytes: 'number' };
|
|
52
|
+
const TYPES = { name: 'string', help: 'object', roomWord: 'string', liveBytes: 'number', foldedBytes: 'number' };
|
|
52
53
|
|
|
53
54
|
// Throws, naming every member that is missing or of the wrong kind.
|
|
54
55
|
export function checkChat(chat) {
|
|
@@ -69,14 +69,15 @@ async function createMessage(client, receiveIdType, receiveId, msgType, content)
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// A markdown card per chunk; plain text when the card is refused. Returns the id of the
|
|
72
|
-
// last message sent.
|
|
72
|
+
// last message sent. `chatId` may be "open_id:<id>" for a person's direct chat.
|
|
73
73
|
export async function sendMarkdown(client, chatId, text) {
|
|
74
|
+
const [idType, id] = chatId.startsWith('open_id:') ? ['open_id', chatId.slice(8)] : ['chat_id', chatId];
|
|
74
75
|
let last = null;
|
|
75
76
|
for (const part of chunk(text || '(empty)')) {
|
|
76
77
|
try {
|
|
77
|
-
last = await createMessage(client,
|
|
78
|
+
last = await createMessage(client, idType, id, 'interactive', card(part));
|
|
78
79
|
} catch (err) {
|
|
79
|
-
last = await createMessage(client,
|
|
80
|
+
last = await createMessage(client, idType, id, 'text', JSON.stringify({ text: part }));
|
|
80
81
|
console.warn(`[feishu] card refused, sent as text: ${err.message}`);
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -258,8 +259,9 @@ export async function renameChat(client, chatId, name) {
|
|
|
258
259
|
// Messages in (im.message.receive_v1) and card clicks (card.action.trigger), both over
|
|
259
260
|
// the long connection. A click must be answered within 3 seconds; what `onCardAction`
|
|
260
261
|
// returns ({toast, card}) is that answer.
|
|
261
|
-
// onConnect runs at every connection, the first and each one after a drop
|
|
262
|
-
|
|
262
|
+
// onConnect runs at every connection, the first and each one after a drop; onMenu gets a
|
|
263
|
+
// click on the bot's menu (application.bot.menu_v6: its event_key and who clicked).
|
|
264
|
+
export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, onConnect = () => {}, onMenu = () => {}) {
|
|
263
265
|
const ws = new Lark.WSClient({
|
|
264
266
|
appId: cfg.appId,
|
|
265
267
|
appSecret: cfg.appSecret,
|
|
@@ -285,6 +287,13 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, on
|
|
|
285
287
|
console.error(`[feishu] message handler: ${err.stack || err.message}`);
|
|
286
288
|
}
|
|
287
289
|
},
|
|
290
|
+
'application.bot.menu_v6': async (data) => {
|
|
291
|
+
try {
|
|
292
|
+
await onMenu(data);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(`[feishu] menu handler: ${err.stack || err.message}`);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
288
297
|
'card.action.trigger': async (data) => {
|
|
289
298
|
try {
|
|
290
299
|
return await onCardAction(data);
|
|
@@ -17,6 +17,9 @@ const DONE_EMOJI = 'OK';
|
|
|
17
17
|
const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
|
|
18
18
|
|
|
19
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
// What to do after an edit to config.env, said the way this profile is run.
|
|
21
|
+
const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
|
|
22
|
+
|
|
20
23
|
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
21
24
|
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
22
25
|
|
|
@@ -438,7 +441,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
438
441
|
const openId = data.sender?.sender_id?.open_id;
|
|
439
442
|
if (!cfg.ownerOpenId) {
|
|
440
443
|
log(`owner not set; message from open_id ${openId}`);
|
|
441
|
-
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `
|
|
444
|
+
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen run ${startAgain(cfg)}. Your terminals' groups appear then.`);
|
|
442
445
|
return;
|
|
443
446
|
}
|
|
444
447
|
if (openId !== cfg.ownerOpenId) {
|
|
@@ -487,7 +490,16 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
487
490
|
}
|
|
488
491
|
}
|
|
489
492
|
};
|
|
490
|
-
|
|
493
|
+
// The bot's menu (set in the developer console; direct chat only): an item that pushes
|
|
494
|
+
// an event with key "ls", "help" or "auth" works as that command sent in the direct chat.
|
|
495
|
+
const onMenu = async (data) => {
|
|
496
|
+
const openId = data.operator?.operator_id?.open_id;
|
|
497
|
+
const key = String(data.event_key || '').replace(/^\//, '');
|
|
498
|
+
if (!cfg.ownerOpenId || openId !== cfg.ownerOpenId || !/^[a-z]+$/.test(key)) return;
|
|
499
|
+
log(`feishu menu: /${key}`);
|
|
500
|
+
queued('menu', () => onMessage({ room: null, where: `open_id:${openId}`, message: `menu-${data.timestamp || Date.now()}`, text: `/${key}`, images: [] }));
|
|
501
|
+
};
|
|
502
|
+
startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)), onMenu);
|
|
491
503
|
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
492
504
|
asYou().catch((err) => logError(`as you: ${err.message}`));
|
|
493
505
|
}
|
|
@@ -517,6 +529,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
517
529
|
name: 'feishu',
|
|
518
530
|
ready: () => Boolean(cfg.ownerOpenId),
|
|
519
531
|
help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
|
|
532
|
+
roomWord: 'group',
|
|
520
533
|
liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
|
|
521
534
|
split: (text) => chunk(text),
|
|
522
535
|
start,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// buttons callback_data holds 64 bytes, so a button carries a short id and the
|
|
16
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
|
-
// pin a topic cannot be pinned
|
|
18
|
+
// pin a topic cannot be pinned, so the dialog card is: pinned while it waits
|
|
19
19
|
import crypto from 'node:crypto';
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import { createClient } from './client.mjs';
|
|
@@ -46,6 +46,9 @@ const COMMANDS = [
|
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
// What to do after an edit to config.env, said the way this profile is run.
|
|
50
|
+
const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
|
|
51
|
+
|
|
49
52
|
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
50
53
|
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
51
54
|
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
@@ -204,9 +207,27 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
204
207
|
|
|
205
208
|
// ---------- attention ----------
|
|
206
209
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
// A dialog card is pinned while it waits, as Feishu pins its group, and unpinned once
|
|
211
|
+
// it is answered; which message is pinned for which room is kept in state.json (pinned).
|
|
212
|
+
async function pin(k, room, message) {
|
|
213
|
+
if (!message) return;
|
|
214
|
+
try {
|
|
215
|
+
await tg.queued('pinChatMessage', { chat_id: owner, message_id: Number(message), disable_notification: true });
|
|
216
|
+
const state = read();
|
|
217
|
+
state.pinned = { ...(state.pinned || {}), [k]: String(message) };
|
|
218
|
+
write(state);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logError(`pin ${message}: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function unpin(k) {
|
|
224
|
+
const message = read().pinned?.[k];
|
|
225
|
+
if (!message) return;
|
|
226
|
+
await tg.queued('unpinChatMessage', { chat_id: owner, message_id: Number(message) }).catch((err) => logError(`unpin ${message}: ${err.message}`));
|
|
227
|
+
const state = read();
|
|
228
|
+
delete state.pinned[k];
|
|
229
|
+
write(state);
|
|
230
|
+
}
|
|
210
231
|
|
|
211
232
|
// In a quiet room this reply is what sounds; in another the post sounded already. A
|
|
212
233
|
// space in HERDR_SILENT_SPACES never sounds.
|
|
@@ -311,7 +332,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
311
332
|
const where = `${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`;
|
|
312
333
|
if (!owner) {
|
|
313
334
|
log(`owner not set; message from user id ${from}`);
|
|
314
|
-
if (m.chat.type === 'private') await reply(where, `
|
|
335
|
+
if (m.chat.type === 'private') await reply(where, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen run ${startAgain(cfg)}. Your terminals' topics appear then.`);
|
|
315
336
|
return;
|
|
316
337
|
}
|
|
317
338
|
if (from !== owner || String(m.chat.id) !== owner) {
|
|
@@ -357,7 +378,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
357
378
|
if (!username) {
|
|
358
379
|
const me = await tg.call('getMe');
|
|
359
380
|
username = me.username;
|
|
360
|
-
if (!me.has_topics_enabled) logWarn(`telegram: topic mode is off for @${username}
|
|
381
|
+
if (!me.has_topics_enabled) logWarn(`telegram: topic mode is off for @${username}, so no topic can be made: open @BotFather, tap Open (its mini app), pick the bot, turn on Threaded Mode, then herdrchat start again`);
|
|
361
382
|
await tg.call('setMyCommands', { commands: COMMANDS }).catch((err) => logWarn(`telegram commands: ${err.message}`));
|
|
362
383
|
}
|
|
363
384
|
// the token works and Telegram answers: in (the first poll may hold for a while)
|
|
@@ -400,6 +421,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
400
421
|
name: 'telegram',
|
|
401
422
|
ready: () => Boolean(owner),
|
|
402
423
|
help: [],
|
|
424
|
+
roomWord: 'topic',
|
|
403
425
|
liveBytes: 3500, // a message holds 4096 characters after parsing
|
|
404
426
|
split: (text) => split(text),
|
|
405
427
|
start,
|
|
@@ -416,7 +438,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
416
438
|
closeDialog,
|
|
417
439
|
pin,
|
|
418
440
|
unpin,
|
|
419
|
-
pinned: () =>
|
|
441
|
+
pinned: () => Object.keys(read().pinned || {}),
|
|
420
442
|
ring,
|
|
421
443
|
markWorking,
|
|
422
444
|
markDone,
|
package/lib/core/config.mjs
CHANGED
|
@@ -26,19 +26,34 @@
|
|
|
26
26
|
// HERDR_HISTORY what a new room gets of the conversation before it:
|
|
27
27
|
// `file,folded` (default: all of it as a file, the newest
|
|
28
28
|
// part in a folded post), `file`, `folded`, or empty / `none`
|
|
29
|
+
import crypto from 'node:crypto';
|
|
29
30
|
import fs from 'node:fs';
|
|
30
31
|
import os from 'node:os';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
|
|
33
|
-
// Where one profile lives, from the environment: {profile, configDir, stateDir,
|
|
34
|
+
// Where one profile lives, from the environment: {profile, id, configDir, stateDir,
|
|
35
|
+
// terminal}. `id` names what the system sees of it (its hc terminal, its service): the
|
|
36
|
+
// profile, plus a short tag of its folder when that is not the usual one (HERDRCHAT_HOME,
|
|
37
|
+
// XDG_*), so a setup tried in another folder never stops, replaces or removes the real one.
|
|
34
38
|
export function placesOf(env = process.env, home = os.homedir()) {
|
|
35
39
|
const profile = env.HERDRCHAT_PROFILE || 'default';
|
|
36
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(profile)) throw new Error(`
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(profile)) throw new Error(`profile "${profile}": use letters, digits, - and _ only`);
|
|
41
|
+
const usual = path.join(home, '.config', 'herdrchat', profile);
|
|
42
|
+
let configDir;
|
|
43
|
+
let stateDir;
|
|
44
|
+
if (env.HERDRCHAT_HOME) {
|
|
45
|
+
configDir = env.HERDRCHAT_HOME;
|
|
46
|
+
stateDir = path.join(env.HERDRCHAT_HOME, 'state');
|
|
47
|
+
} else {
|
|
48
|
+
configDir = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'herdrchat', profile);
|
|
49
|
+
stateDir = path.join(env.XDG_STATE_HOME || path.join(home, '.local', 'state'), 'herdrchat', profile);
|
|
50
|
+
}
|
|
51
|
+
const usualState = path.join(home, '.local', 'state', 'herdrchat', profile);
|
|
52
|
+
const moved = path.resolve(configDir) !== usual || path.resolve(stateDir) !== usualState;
|
|
53
|
+
const tag = moved ? crypto.createHash('sha1').update(`${path.resolve(configDir)}\0${path.resolve(stateDir)}`).digest('hex').slice(0, 6) : '';
|
|
54
|
+
const id = tag ? `${profile}-${tag}` : profile;
|
|
55
|
+
const terminal = id === 'default' ? 'herdrchat' : `herdrchat-${id}`;
|
|
56
|
+
return { profile, id, configDir, stateDir, terminal };
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
const places = placesOf();
|
|
@@ -90,13 +105,16 @@ export function onOff(value, fallback) {
|
|
|
90
105
|
|
|
91
106
|
// `requires`: config keys the chat app cannot start without (its module's `requires`).
|
|
92
107
|
// `env` is everything config.env and the environment hold, for the app's own keys.
|
|
93
|
-
|
|
108
|
+
// `sessions: false`: an empty HERDR_SESSIONS is not an error (status shows what is set).
|
|
109
|
+
export function loadConfig({ requires = [], sessions: needSessions = true } = {}) {
|
|
94
110
|
const env = { ...parseEnv(CONFIG_FILE), ...process.env };
|
|
95
111
|
const sessions = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
96
112
|
const missing = requires.filter((k) => !env[k]);
|
|
97
|
-
if (missing.length) throw new Error(`${
|
|
98
|
-
if (!sessions.length) throw new Error(
|
|
113
|
+
if (missing.length) throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} empty in ${CONFIG_FILE}: fill ${missing.length > 1 ? 'them' : 'it'} in (the comment above each says where it comes from), then start again.`);
|
|
114
|
+
if (needSessions && !sessions.length) throw new Error(`HERDR_SESSIONS is empty in ${CONFIG_FILE}: put the hc sessions to mirror there, comma-separated (\`ls ~/.local/state/herdr-core\` lists the sessions on this machine), then start again.`);
|
|
99
115
|
return {
|
|
116
|
+
profile: places.profile,
|
|
117
|
+
configFile: CONFIG_FILE,
|
|
100
118
|
chat: env.HERDRCHAT_CHAT || 'feishu',
|
|
101
119
|
env,
|
|
102
120
|
sessions,
|