herdrchat 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +1 -1
- package/docs/guide.md +132 -29
- package/lib/chats/contract.mjs +1 -1
- package/lib/chats/feishu/client.mjs +19 -5
- package/lib/chats/feishu/index.mjs +62 -3
- package/lib/chats/telegram/index.mjs +33 -8
- package/lib/core/engine.mjs +35 -6
- package/lib/core/log.mjs +4 -1
- package/package.json +1 -1
- package/skills/herdrchat/references/feishu.md +21 -0
- package/skills/herdrchat/references/telegram.md +5 -1
package/bin/cli.mjs
CHANGED
|
@@ -52,7 +52,7 @@ const places = placesOf();
|
|
|
52
52
|
|
|
53
53
|
const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
54
54
|
const firstSession = () => loadConfig().sessions[0];
|
|
55
|
-
const CONNECT_WAIT_MS =
|
|
55
|
+
const CONNECT_WAIT_MS = 60000; // a first attempt that times out (15 s) is tried again
|
|
56
56
|
|
|
57
57
|
// hc's full path, from HERDRCHAT_HC or PATH
|
|
58
58
|
function findHc() {
|
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,68 @@ 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 is in "Feishu and Telegram side by side" below. Telegram takes about one
|
|
39
|
+
message a second in a chat, so the bot queues and waits as Telegram asks. Setting it up:
|
|
40
|
+
`skills/herdrchat/references/telegram.md`.
|
|
41
|
+
|
|
42
|
+
## Feishu and Telegram side by side
|
|
43
|
+
|
|
44
|
+
Both do the same things; where an app cannot, something stands in.
|
|
45
|
+
|
|
46
|
+
| | Feishu | Telegram |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| a tab | a group | a topic in the bot's private chat |
|
|
49
|
+
| a space | an avatar (its letter, its color) | a topic icon color |
|
|
50
|
+
| an ended terminal | the `ended` tag | 🔚 before the topic's name |
|
|
51
|
+
| 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 |
|
|
52
|
+
| what it says while working | one live card per turn, edited | one live message per turn, edited |
|
|
53
|
+
| 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 |
|
|
54
|
+
| needs you | the group pinned (after `/auth`), an @ that rings | the dialog card pinned in its topic, a reply that rings |
|
|
55
|
+
| quiet spaces (`HERDR_MUTED_SPACES`) | groups muted (after `/auth`); only an @ rings | posts silent; only a short reply rings |
|
|
56
|
+
| silent spaces (`HERDR_SILENT_SPACES`) | no @ | no ringing reply |
|
|
57
|
+
| 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 |
|
|
58
|
+
| images | in the card, both ways | as photos after the text, both ways |
|
|
59
|
+
| your message marked | a reaction, then OK | 👀, then 👌 |
|
|
60
|
+
| tags and labels (`HERDR_BOT_TAGS`, `HERDR_USER_TAGS`) | yes | none in Telegram |
|
|
61
|
+
| messages while the bot is away | looked for and handled when it connects | kept by Telegram for 24 hours, handled when it connects |
|
|
62
|
+
| acting as you (`/auth`) | mute, labels, pins | not needed: posts are silent by themselves |
|
|
63
|
+
|
|
64
|
+
## Settings
|
|
65
|
+
|
|
66
|
+
In the profile's `config.env` (`herdrchat status` prints where it is):
|
|
67
|
+
|
|
68
|
+
| Key | For | Default | What it does |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `HERDRCHAT_CHAT` | both | `feishu` | the chat app this profile mirrors into: `feishu` or `telegram` |
|
|
71
|
+
| `HERDR_SESSIONS` | both | (required) | the hc sessions to mirror, comma-separated; machines they reach come along |
|
|
72
|
+
| `HERDR_MUTED_SPACES` | both | `all` | spaces whose rooms are quiet: only "needs you" and "done" ring; empty for none |
|
|
73
|
+
| `HERDR_SILENT_SPACES` | both | (none) | spaces where nothing rings, not even "needs you" |
|
|
74
|
+
| `HERDR_HISTORY` | both | `file,folded` | what a new room gets of the conversation before it: `file`, `folded`, both, or empty |
|
|
75
|
+
| `FEISHU_APP_ID`, `FEISHU_APP_SECRET` | Feishu | (required) | the Feishu app |
|
|
76
|
+
| `FEISHU_OWNER_OPEN_ID` | Feishu | (empty) | you; the bot tells you yours when you message it |
|
|
77
|
+
| `HERDR_BOT_TAGS` | Feishu | `on` | the session or machine tag and `ended` next to a group's name |
|
|
78
|
+
| `HERDR_USER_TAGS` | Feishu | `on` | your sidebar labels, once `/auth` is done |
|
|
79
|
+
| `TELEGRAM_BOT_TOKEN` | Telegram | (required) | the bot's token from @BotFather |
|
|
80
|
+
| `TELEGRAM_OWNER_ID` | Telegram | (empty) | you; the bot tells you yours when you message it |
|
|
81
|
+
|
|
82
|
+
In the environment (not `config.env`): `HERDRCHAT_PROFILE` (or `--profile`) picks the
|
|
83
|
+
profile; `HERDRCHAT_HOME` puts a profile's `config.env` and state in one folder;
|
|
84
|
+
`XDG_CONFIG_HOME` and `XDG_STATE_HOME` move the usual folders. `herdrchat start` and
|
|
85
|
+
`herdrchat install` hand the bot hc's full path as `HERDRCHAT_HC`. A setting changed in
|
|
86
|
+
`config.env` takes effect at the next `herdrchat start`.
|
|
87
|
+
|
|
24
88
|
## How it works: hc, plus a reader per agent
|
|
25
89
|
|
|
26
90
|
**hc is the backbone.** The bot follows `hc events --json` for each session (format:
|
|
@@ -36,13 +100,14 @@ each agent a reader reads the agent's own records. The status hc reports names t
|
|
|
36
100
|
| `status.app` | reader | reads |
|
|
37
101
|
|---|---|---|
|
|
38
102
|
| `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
|
|
103
|
+
| anything else | none | nothing by itself; what you type gets its screen in reply |
|
|
40
104
|
|
|
41
105
|
On another machine, a reader reads the same files through `hc exec --on`. To add an
|
|
42
|
-
agent
|
|
43
|
-
|
|
106
|
+
agent: `docs/architecture.md`, "Adding a harness".
|
|
107
|
+
|
|
108
|
+
## What a room shows
|
|
44
109
|
|
|
45
|
-
|
|
110
|
+
A room is a Feishu group or a Telegram topic.
|
|
46
111
|
|
|
47
112
|
- A program with a reader:
|
|
48
113
|
- while it works: one "⌛️ Working…" card per turn (then "✅ Worked"), edited every 5
|
|
@@ -50,9 +115,12 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
|
|
|
50
115
|
mid-turn) as **You:**, and each tool it calls as one line (`▸ Bash: Run the tests`).
|
|
51
116
|
It shows the program is alive, as herdr-app does, and since an edit is not a new
|
|
52
117
|
message, the phone does not ring every few seconds;
|
|
53
|
-
- when the turn ends: its last answer, as a new message.
|
|
54
|
-
|
|
55
|
-
|
|
118
|
+
- when the turn ends: its last answer, as a new message. In a space of
|
|
119
|
+
`HERDR_MUTED_SPACES` (all, by default) only "needs you" and "done" ring: in Feishu
|
|
120
|
+
the groups are muted (after `/auth`) and an @ under the answer rings; in Telegram
|
|
121
|
+
the posts go silently and a short reply rings. `HERDR_SILENT_SPACES` rings nothing.
|
|
122
|
+
- A message another terminal sent with `hc send` shows as **From t7 review**, not as
|
|
123
|
+
yours (hc wraps it in `<hc from="…">`, for any agent).
|
|
56
124
|
- Images too: ones it looked at (a screenshot, an image file it read) and ones you
|
|
57
125
|
pasted in the terminal, shown in the card.
|
|
58
126
|
- What you sent from the group is not repeated.
|
|
@@ -63,15 +131,21 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
|
|
|
63
131
|
a red "⚠️ Needs you" card. When the screen shows a numbered menu (as Claude Code's
|
|
64
132
|
permission and question dialogs do), the card shows only the dialog, as text, with a
|
|
65
133
|
button per option; a multi-select also has "Next ⇥", a "Type something" option a box to
|
|
66
|
-
type in
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
134
|
+
type in (Feishu; in Telegram you type the answer in the topic), and every card "Esc".
|
|
135
|
+
Without a menu the card shows the last lines of the screen and how to answer by typing.
|
|
136
|
+
Once the dialog is gone the card turns to "✅ Done". A card is posted only for a dialog
|
|
137
|
+
that still waits: one answered while its screen was read gets none.
|
|
138
|
+
|
|
139
|
+
## Answering a dialog from the chat
|
|
140
|
+
|
|
141
|
+
A button presses that option's number key in the terminal (Esc presses esc). A question
|
|
142
|
+
with previews (a box beside its options) takes no number, so there the button moves with
|
|
143
|
+
the arrows from the cursor to that option and presses Enter. In a multi-select, a box is
|
|
144
|
+
ticked on the card only, at once; Next sends them all: each box that differs from the
|
|
145
|
+
screen is pressed, then tab.
|
|
72
146
|
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
|
|
147
|
+
shows the same dialog (a hash of it, from its top border to its last option, without the
|
|
148
|
+
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
149
|
key, the card turns into the dialog's next step (a toggled box, the next question, the
|
|
76
150
|
review) or says what was picked. Text you type in the group while it waits goes into its
|
|
77
151
|
"Type something" box when it has one.
|
|
@@ -121,16 +195,21 @@ delivered (the terminal has ended) says so plainly.
|
|
|
121
195
|
|
|
122
196
|
- Direct chat: `/ls` (the overview again at the bottom of the chat; the old one is
|
|
123
197
|
recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
|
|
124
|
-
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (
|
|
125
|
-
below).
|
|
126
|
-
|
|
127
|
-
|
|
198
|
+
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (Feishu;
|
|
199
|
+
see below). Telegram lists them when you type `/`; Feishu can have a bot menu for
|
|
200
|
+
`/ls`, help and `/auth` (set in the developer console:
|
|
201
|
+
`skills/herdrchat/references/feishu.md`, "The bot menu").
|
|
202
|
+
- In a terminal's room: any text goes to it (marked once typed in, and again when the
|
|
203
|
+
turn that took it is over); an image (alone or in a rich text message) is sent
|
|
128
204
|
beside the terminal with `hc put`, on its machine, and its path is typed in with your
|
|
129
205
|
text, so an agent like Claude Code opens it; `/screen`, `/answer <text>` (type into the
|
|
130
|
-
dialog it waits on, as is), `/esc
|
|
206
|
+
dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
|
|
207
|
+
a folded post). Dialog cards have buttons.
|
|
131
208
|
|
|
132
209
|
## Set up
|
|
133
210
|
|
|
211
|
+
Telegram: `skills/herdrchat/references/telegram.md`. Feishu:
|
|
212
|
+
|
|
134
213
|
Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
|
|
135
214
|
(`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
|
|
136
215
|
|
|
@@ -152,6 +231,26 @@ Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
|
|
|
152
231
|
7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
|
|
153
232
|
The bot answers with your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
|
|
154
233
|
`herdrchat start` again.
|
|
234
|
+
8. Optional: the bot menu in the direct chat (`skills/herdrchat/references/feishu.md`,
|
|
235
|
+
"The bot menu").
|
|
236
|
+
9. Once it works: `herdrchat install` (see "Running as a service").
|
|
237
|
+
|
|
238
|
+
## Running as a service
|
|
239
|
+
|
|
240
|
+
`herdrchat start` runs the bot in an hc terminal, which ends when that hc core stops,
|
|
241
|
+
and nothing starts it after a reboot. `herdrchat install` makes it a service of the
|
|
242
|
+
system instead: a launchd agent on macOS, a systemd user unit on Linux. It starts at
|
|
243
|
+
login, comes back by itself after a crash, and does not depend on any hc core. It runs
|
|
244
|
+
node and the bot by their full paths with only the variables it needs (hc's path, PATH,
|
|
245
|
+
the profile, the folders), so no agent's variables come along. With it installed,
|
|
246
|
+
`start`, `stop` and `status` go through the service; `herdrchat uninstall` removes it.
|
|
247
|
+
Each profile is its own service (`herdrchat install --profile telegram`).
|
|
248
|
+
|
|
249
|
+
- After a node upgrade that moves node, run `herdrchat install` again; `herdrchat status`
|
|
250
|
+
says when the service's node is gone.
|
|
251
|
+
- On Linux, `loginctl enable-linger` keeps it running after you log out.
|
|
252
|
+
- What the process prints before its log is open (a crash at start) goes to
|
|
253
|
+
`logs/service.out.log` in the state folder.
|
|
155
254
|
|
|
156
255
|
## Optional: let the bot act as you
|
|
157
256
|
|
|
@@ -181,9 +280,10 @@ refreshed, the bot says so once and falls back.
|
|
|
181
280
|
## Skills
|
|
182
281
|
|
|
183
282
|
`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)
|
|
283
|
+
`SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `install`, `logs`, `auth`,
|
|
284
|
+
`config`, `fix`, `feishu <task>` (the developer console, with Ego Lite from
|
|
285
|
+
https://lite.ego.app/ or as a guided walk-through) and `telegram <task>` (the bot, from
|
|
286
|
+
@BotFather to the first topic). Details per part are in `skills/herdrchat/references/`. To use it
|
|
187
287
|
with Claude Code, link it where skills are read, for example
|
|
188
288
|
`ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
189
289
|
|
|
@@ -191,7 +291,7 @@ with Claude Code, link it where skills are read, for example
|
|
|
191
291
|
|
|
192
292
|
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder (one file a
|
|
193
293
|
day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
|
|
194
|
-
~/.local/state/herdrchat
|
|
294
|
+
~/.local/state/herdrchat/<profile>/logs/*.log` finds
|
|
195
295
|
what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
|
|
196
296
|
before it is started again. Tokens and secrets are never logged. The
|
|
197
297
|
skill's `references/ops.md` explains the lines.
|
|
@@ -199,12 +299,15 @@ skill's `references/ops.md` explains the lines.
|
|
|
199
299
|
## Limits
|
|
200
300
|
|
|
201
301
|
- 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
|
-
|
|
302
|
+
- Only Claude Code has a reader so far. codex, pi and the rest post nothing by themselves
|
|
303
|
+
until each has a reader; what you type to them gets their screen in reply.
|
|
204
304
|
- Only text, images and rich text are passed on; files, stickers and the rest are not.
|
|
205
305
|
- An image that fails to upload three times is not sent; the group says so and why.
|
|
206
306
|
- Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
|
|
207
307
|
at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
|
|
208
308
|
- Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
|
|
309
|
+
- Telegram: a bot cannot mute a topic for you; a topic you mute in Telegram stays fully
|
|
310
|
+
quiet, "needs you" included. Closing a topic is documented for supergroups only, so an
|
|
311
|
+
ended topic is marked by its name.
|
|
209
312
|
- Dialog buttons come from the screen's numbered menu; a dialog drawn some other way gets
|
|
210
313
|
the screen and `/answer`, `/esc`.
|
package/lib/chats/contract.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export const CHAT_MEMBERS = {
|
|
|
33
33
|
editCard: '(message, look)',
|
|
34
34
|
postDialog: '(room, dialog, value) -> message: dialog {title, markdown, menu, doneTitle}; each button sends value plus {pick, label}',
|
|
35
35
|
closeDialog: '(message, dialog, done): no buttons any more, says what was done',
|
|
36
|
-
pin: '(k, room): the room waits for you',
|
|
36
|
+
pin: '(k, room, message): the room waits for you, on this message (its dialog card)',
|
|
37
37
|
unpin: '(k)',
|
|
38
38
|
pinned: '() -> k[]',
|
|
39
39
|
ring: '(k, message, text): make the phone ring for this message if it would not',
|
|
@@ -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,7 +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
|
-
|
|
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 = () => {}) {
|
|
262
265
|
const ws = new Lark.WSClient({
|
|
263
266
|
appId: cfg.appId,
|
|
264
267
|
appSecret: cfg.appSecret,
|
|
@@ -266,9 +269,13 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
|
|
|
266
269
|
onReady: () => {
|
|
267
270
|
console.log('[feishu] connected');
|
|
268
271
|
onReady();
|
|
272
|
+
onConnect();
|
|
269
273
|
},
|
|
270
274
|
onReconnecting: () => console.warn('[feishu] reconnecting'),
|
|
271
|
-
onReconnected: () =>
|
|
275
|
+
onReconnected: () => {
|
|
276
|
+
console.log('[feishu] reconnected');
|
|
277
|
+
onConnect();
|
|
278
|
+
},
|
|
272
279
|
onError: (err) => console.error(`[feishu] connection failed: ${err.message}`),
|
|
273
280
|
});
|
|
274
281
|
ws.start({
|
|
@@ -280,6 +287,13 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
|
|
|
280
287
|
console.error(`[feishu] message handler: ${err.stack || err.message}`);
|
|
281
288
|
}
|
|
282
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
|
+
},
|
|
283
297
|
'card.action.trigger': async (data) => {
|
|
284
298
|
try {
|
|
285
299
|
return await onCardAction(data);
|
|
@@ -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,39 @@ 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
|
+
// The bot's menu (set in the developer console; direct chat only): an item that pushes
|
|
491
|
+
// an event with key "ls", "help" or "auth" works as that command sent in the direct chat.
|
|
492
|
+
const onMenu = async (data) => {
|
|
493
|
+
const openId = data.operator?.operator_id?.open_id;
|
|
494
|
+
const key = String(data.event_key || '').replace(/^\//, '');
|
|
495
|
+
if (!cfg.ownerOpenId || openId !== cfg.ownerOpenId || !/^[a-z]+$/.test(key)) return;
|
|
496
|
+
log(`feishu menu: /${key}`);
|
|
497
|
+
queued('menu', () => onMessage({ room: null, where: `open_id:${openId}`, message: `menu-${data.timestamp || Date.now()}`, text: `/${key}`, images: [] }));
|
|
498
|
+
};
|
|
499
|
+
startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)), onMenu);
|
|
441
500
|
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
442
501
|
asYou().catch((err) => logError(`as you: ${err.message}`));
|
|
443
502
|
}
|
|
@@ -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';
|
|
@@ -204,9 +204,27 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
204
204
|
|
|
205
205
|
// ---------- attention ----------
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
// A dialog card is pinned while it waits, as Feishu pins its group, and unpinned once
|
|
208
|
+
// it is answered; which message is pinned for which room is kept in state.json (pinned).
|
|
209
|
+
async function pin(k, room, message) {
|
|
210
|
+
if (!message) return;
|
|
211
|
+
try {
|
|
212
|
+
await tg.queued('pinChatMessage', { chat_id: owner, message_id: Number(message), disable_notification: true });
|
|
213
|
+
const state = read();
|
|
214
|
+
state.pinned = { ...(state.pinned || {}), [k]: String(message) };
|
|
215
|
+
write(state);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logError(`pin ${message}: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function unpin(k) {
|
|
221
|
+
const message = read().pinned?.[k];
|
|
222
|
+
if (!message) return;
|
|
223
|
+
await tg.queued('unpinChatMessage', { chat_id: owner, message_id: Number(message) }).catch((err) => logError(`unpin ${message}: ${err.message}`));
|
|
224
|
+
const state = read();
|
|
225
|
+
delete state.pinned[k];
|
|
226
|
+
write(state);
|
|
227
|
+
}
|
|
210
228
|
|
|
211
229
|
// In a quiet room this reply is what sounds; in another the post sounded already. A
|
|
212
230
|
// space in HERDR_SILENT_SPACES never sounds.
|
|
@@ -344,9 +362,12 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
344
362
|
}
|
|
345
363
|
|
|
346
364
|
// Long polling: getUpdates holds up to POLL_SECONDS for news; a failure waits RETRY_MS
|
|
347
|
-
// and tries again (409: another copy of the bot polls the same token).
|
|
365
|
+
// and tries again (409: another copy of the bot polls the same token). Telegram keeps
|
|
366
|
+
// what the bot has not confirmed for 24 hours, so nothing sent while it was away is
|
|
367
|
+
// lost; which update it handled last is kept in state.json (telegramOffset), so after a
|
|
368
|
+
// restart nothing is handled twice.
|
|
348
369
|
function start({ onMessage, onAction, onReady = () => {} }) {
|
|
349
|
-
let offset = 0;
|
|
370
|
+
let offset = read().telegramOffset || 0;
|
|
350
371
|
let connected = false;
|
|
351
372
|
(async () => {
|
|
352
373
|
for (;;) {
|
|
@@ -365,13 +386,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
365
386
|
}
|
|
366
387
|
const updates = await tg.call('getUpdates', { offset, timeout: POLL_SECONDS, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (POLL_SECONDS + 15) * 1000 });
|
|
367
388
|
for (const u of updates) {
|
|
368
|
-
offset = u.update_id + 1;
|
|
369
389
|
try {
|
|
370
390
|
if (u.message) await handleMessage(u.message, onMessage);
|
|
371
391
|
if (u.callback_query) await handleClick(u.callback_query, onAction);
|
|
372
392
|
} catch (err) {
|
|
373
393
|
logError(`telegram update ${u.update_id}:`, err);
|
|
374
394
|
}
|
|
395
|
+
// saved once handled: a crash halfway handles it again, never skips it
|
|
396
|
+
offset = u.update_id + 1;
|
|
397
|
+
const state = read();
|
|
398
|
+
state.telegramOffset = offset;
|
|
399
|
+
write(state);
|
|
375
400
|
}
|
|
376
401
|
} catch (err) {
|
|
377
402
|
logError(`telegram poll: ${err.message}`);
|
|
@@ -409,7 +434,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
409
434
|
closeDialog,
|
|
410
435
|
pin,
|
|
411
436
|
unpin,
|
|
412
|
-
pinned: () =>
|
|
437
|
+
pinned: () => Object.keys(read().pinned || {}),
|
|
413
438
|
ring,
|
|
414
439
|
markWorking,
|
|
415
440
|
markDone,
|
package/lib/core/engine.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// post(room, markdown) -> message; reply(where, markdown)
|
|
18
18
|
// postCard(room, look) -> message; editCard(message, look) look: {title, tone, markdown}
|
|
19
19
|
// postDialog(room, dialog, value) -> message; closeDialog(message, dialog, done)
|
|
20
|
-
// pin(k, room); unpin(k); pinned() -> keys; ring(k, message, text)
|
|
20
|
+
// pin(k, room, message); unpin(k); pinned() -> keys; ring(k, message, text)
|
|
21
21
|
// markWorking(message) -> token; markDone(message, token)
|
|
22
22
|
// image(buffer) -> markdown; download(message, ref, file)
|
|
23
23
|
// postFile(room, {name, data, caption}); postFolded(room, {title, markdown}); foldedBytes
|
|
@@ -623,7 +623,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
623
623
|
if (!dialog.menu) {
|
|
624
624
|
const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
|
|
625
625
|
const room = read().chats[k]?.chat;
|
|
626
|
-
if (room) await chat.pin(k, room);
|
|
626
|
+
if (room) await chat.pin(k, room, sent);
|
|
627
627
|
await chat.ring(k, sent, '⚠️ needs you');
|
|
628
628
|
} else {
|
|
629
629
|
const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog)));
|
|
@@ -631,7 +631,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
631
631
|
remember(message, dialog);
|
|
632
632
|
openCard.set(k, { message, dialog });
|
|
633
633
|
const room = read().chats[k]?.chat;
|
|
634
|
-
if (room) await chat.pin(k, room);
|
|
634
|
+
if (room) await chat.pin(k, room, message);
|
|
635
635
|
await chat.ring(k, message, '⚠️ needs you');
|
|
636
636
|
// answered while the card was on its way: nothing will close it later, so now
|
|
637
637
|
const live = views.get(session)?.terminals.get(address);
|
|
@@ -797,6 +797,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
797
797
|
if (now !== was) since.set(k, Date.now());
|
|
798
798
|
if (now !== 'blocked') {
|
|
799
799
|
postedDialog.delete(k);
|
|
800
|
+
if (now) sendWaiting(k);
|
|
800
801
|
if (chat.pinned().includes(k)) later(session, address, 'unpin', () => chat.unpin(k));
|
|
801
802
|
if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
|
|
802
803
|
return;
|
|
@@ -888,6 +889,23 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
888
889
|
}
|
|
889
890
|
}
|
|
890
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
|
+
|
|
891
909
|
async function typeInto(where, message, session, address, text) {
|
|
892
910
|
const record = views.get(session)?.terminals.get(address);
|
|
893
911
|
if (!record || record.state !== 'live') {
|
|
@@ -908,16 +926,21 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
908
926
|
return;
|
|
909
927
|
}
|
|
910
928
|
const answer = text.match(/^\/answer\s+([\s\S]+)$/);
|
|
911
|
-
|
|
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) {
|
|
912
933
|
// It waits on a dialog: words go into its "Type something" box when it has one;
|
|
913
|
-
// otherwise the dialog
|
|
934
|
+
// otherwise they wait for the dialog to be answered, then go in (never dropped).
|
|
914
935
|
const menu = menuOf(await hc.screenTail(session, address, SCREEN_LINES));
|
|
915
936
|
if (menu?.free) {
|
|
916
937
|
await answerFree(session, address, menu, text);
|
|
917
938
|
log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
|
|
918
939
|
await markWorking(key(session, address), message);
|
|
919
940
|
} else {
|
|
920
|
-
|
|
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.');
|
|
921
944
|
}
|
|
922
945
|
return;
|
|
923
946
|
}
|
|
@@ -1057,6 +1080,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1057
1080
|
const target = terminalOfRoom(room);
|
|
1058
1081
|
if (!target) {
|
|
1059
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.');
|
|
1060
1084
|
return;
|
|
1061
1085
|
}
|
|
1062
1086
|
if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
|
|
@@ -1083,6 +1107,11 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1083
1107
|
for (const session of cfg.sessions) watch(session);
|
|
1084
1108
|
ready.then(() => setTimeout(async () => {
|
|
1085
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
|
+
}
|
|
1086
1115
|
// pins left from before a restart, for terminals that no longer wait
|
|
1087
1116
|
for (const k of chat.pinned()) {
|
|
1088
1117
|
const address = addressOfKey(k);
|
package/lib/core/log.mjs
CHANGED
|
@@ -46,7 +46,10 @@ export function createLog(dir) {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
log.capture = ({ onCrash } = {}) => {
|
|
49
|
-
|
|
49
|
+
// a library that prints its errors as plain log lines ("[error]: ...", as the Feishu
|
|
50
|
+
// SDK does) gets them logged as WARN, so `herdrchat logs --errors` finds them
|
|
51
|
+
const levelOf = (args) => (/^\[(error|warn)\]/i.test(String(args[0] ?? '')) ? 'WARN' : 'INFO');
|
|
52
|
+
console.log = (...args) => write(levelOf(args), args);
|
|
50
53
|
console.info = console.log;
|
|
51
54
|
console.warn = (...args) => write('WARN', args);
|
|
52
55
|
console.error = (...args) => write('ERROR', args);
|
package/package.json
CHANGED
|
@@ -107,6 +107,27 @@ Deactivate it first in the tenant admin console,
|
|
|
107
107
|
`https://<tenant>.feishu.cn/admin/appCenter/manage/<APP_ID>` (needs a tenant admin),
|
|
108
108
|
then delete it in the developer console. Ask before each of the two steps.
|
|
109
109
|
|
|
110
|
+
### The bot menu (optional)
|
|
111
|
+
|
|
112
|
+
A menu at the bottom of the direct chat with the bot, so the commands are a tap away as
|
|
113
|
+
Telegram's "/" list is. Feishu sets it only in the developer console (no API), shows it
|
|
114
|
+
in the direct chat only (not in groups), and needs a published version (then about five
|
|
115
|
+
minutes, and a client of 5.27 or later).
|
|
116
|
+
|
|
117
|
+
1. 应用能力 -> 机器人 -> 机器人自定义菜单: edit, turn it on (开启), pick a style.
|
|
118
|
+
2. Add items whose action is 推送事件 (push an event), with these keys; the bot treats a
|
|
119
|
+
click as that command in the direct chat:
|
|
120
|
+
| Item | Event key |
|
|
121
|
+
|---|---|
|
|
122
|
+
| Overview | `ls` |
|
|
123
|
+
| Help | `help` |
|
|
124
|
+
| Act as me | `auth` |
|
|
125
|
+
3. 事件与回调 -> 事件配置 (long connection mode, as for messages) -> 添加事件:
|
|
126
|
+
机器人自定义菜单事件 (`application.bot.menu_v6`).
|
|
127
|
+
4. Publish a version. Ask before publishing.
|
|
128
|
+
|
|
129
|
+
Commands that take words (`/new`, `/kill`) stay typed.
|
|
130
|
+
|
|
110
131
|
## Scopes this bot uses
|
|
111
132
|
|
|
112
133
|
Bot identity (the bot itself) and user identity (only for `/auth`, acting as the user):
|
|
@@ -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
|
|
|
@@ -61,7 +64,8 @@ without muting each topic by hand.
|
|
|
61
64
|
| an ended group gets the "ended" tag | an ended topic's name starts with 🔚 (closing a topic is documented for supergroups only) |
|
|
62
65
|
| an image inside a card | each image as its own photo after the text |
|
|
63
66
|
| a text box on a dialog card | type the answer in the topic |
|
|
64
|
-
|
|
|
67
|
+
| pins the group that needs you | pins the dialog card in its topic until it is answered |
|
|
68
|
+
| tags, labels, `/auth` | none (a bot cannot act as the user) |
|
|
65
69
|
| the overview card | one message in the main thread, edited in place |
|
|
66
70
|
| `/` commands: typed | `/` shows a menu: ls, new, kill, screen, answer, esc, history |
|
|
67
71
|
|