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.
@@ -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 now,
4
- Telegram next, then Slack and Discord) and **harnesses** (Claude Code now, then codex
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 harness
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, and its live event stream
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
- readers/ one reader per harness (the contract is in index.mjs)
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 a harness
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 terminal it shows (it has a `where` label) | a group named `<space> / <tab>`, tagged with the session; type there to talk to it. A tab only numbered (`2`) is named by the terminal's title instead (whatever the program sets, for any agent; Claude Code sets its session name or topic), else its hc name |
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
- | herdr itself | 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` |
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 @BotFather):
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
- | herdr itself | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
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
- 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`.
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
- herdr-core `schema/README.md`, "Events"). From hc it learns which terminals exist, where
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 a harness".
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` (see
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: `skills/herdrchat/references/telegram.md`. Feishu:
166
-
167
- Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
168
- (`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
169
-
170
- 1. Feishu developer console: create a custom app (企业自建应用), add the bot ability.
171
- 2. Permissions (batch import): `im:message:send_as_bot`, `im:message.p2p_msg:readonly`,
172
- `im:message.group_msg`, `im:chat:create`, `im:chat:update`,
173
- `im:message.reactions:write_only`, `im:resource` (images out),
174
- `im:message:readonly` (images in), `im:tag:write`,
175
- `im:biz_entity_tag_relation:write` (group tags).
176
- 3. `herdrchat init` makes `~/.config/herdrchat/default/config.env`; fill the app ID, the
177
- secret and `HERDR_SESSIONS`: the hc sessions on the machine the bot runs on that herdr-app shows
178
- (the machines they connect to come along, as in herdr-app). See "Where things are"
179
- for other places and more than one profile.
180
- 4. `herdrchat start` (the console checks for a live connection in the next step).
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 your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
187
- `herdrchat start` again.
188
- 8. Once it works: `herdrchat install` (see "Running as a service").
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/`. To use it
239
- with Claude Code, link it where skills are read, for example
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 (one file a
245
- day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
246
- ~/.local/state/herdrchat/<profile>/logs/*.log` finds
247
- what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
248
- before it is started again. Tokens and secrets are never logged. The
249
- skill's `references/ops.md` explains the lines.
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.
@@ -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, 'chat_id', chatId, 'interactive', card(part));
78
+ last = await createMessage(client, idType, id, 'interactive', card(part));
78
79
  } catch (err) {
79
- last = await createMessage(client, 'chat_id', chatId, 'text', JSON.stringify({ text: part }));
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
- export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, onConnect = () => {}) {
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, `Owner is not set. Put this in config.env:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen restart the bot.`);
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
- startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)));
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; nothing is done
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
- const pins = new Set();
208
- const pin = async (k) => { pins.add(k); };
209
- const unpin = async (k) => { pins.delete(k); };
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, `Owner is not set. Put this in config.env:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen restart the bot.`);
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}; turn it on in @BotFather (Bot Settings), or no room can be made`);
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: () => [...pins],
441
+ pinned: () => Object.keys(read().pinned || {}),
420
442
  ring,
421
443
  markWorking,
422
444
  markDone,
@@ -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, terminal}.
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(`HERDRCHAT_PROFILE "${profile}": letters, digits, - and _ only`);
37
- const terminal = profile === 'default' ? 'herdrchat' : `herdrchat-${profile}`;
38
- if (env.HERDRCHAT_HOME) return { profile, configDir: env.HERDRCHAT_HOME, stateDir: path.join(env.HERDRCHAT_HOME, 'state'), terminal };
39
- const configRoot = env.XDG_CONFIG_HOME || path.join(home, '.config');
40
- const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state');
41
- return { profile, configDir: path.join(configRoot, 'herdrchat', profile), stateDir: path.join(stateRoot, 'herdrchat', profile), terminal };
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
- export function loadConfig({ requires = [] } = {}) {
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(`${CONFIG_FILE}: ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required`);
98
- if (!sessions.length) throw new Error(`${CONFIG_FILE}: HERDR_SESSIONS must list at least one hc session`);
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,