herdrchat 1.2.1 → 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 +55 -7
- package/lib/chats/contract.mjs +1 -1
- package/lib/chats/feishu/client.mjs +14 -5
- package/lib/chats/feishu/index.mjs +10 -1
- package/lib/chats/telegram/index.mjs +23 -5
- package/lib/core/engine.mjs +3 -3
- 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 +2 -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
|
@@ -35,12 +35,56 @@ A Telegram bot cannot make groups, so it uses topics in your private chat with t
|
|
|
35
35
|
| a terminal that is gone | its topic stays, its name starting with 🔚 (taken off if it comes back) |
|
|
36
36
|
| herdr itself | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
|
|
37
37
|
|
|
38
|
-
What differs
|
|
39
|
-
a
|
|
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:
|
|
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:
|
|
42
40
|
`skills/herdrchat/references/telegram.md`.
|
|
43
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
|
+
|
|
44
88
|
## How it works: hc, plus a reader per agent
|
|
45
89
|
|
|
46
90
|
**hc is the backbone.** The bot follows `hc events --json` for each session (format:
|
|
@@ -151,8 +195,10 @@ delivered (the terminal has ended) says so plainly.
|
|
|
151
195
|
|
|
152
196
|
- Direct chat: `/ls` (the overview again at the bottom of the chat; the old one is
|
|
153
197
|
recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
|
|
154
|
-
`--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (
|
|
155
|
-
below).
|
|
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").
|
|
156
202
|
- In a terminal's room: any text goes to it (marked once typed in, and again when the
|
|
157
203
|
turn that took it is over); an image (alone or in a rich text message) is sent
|
|
158
204
|
beside the terminal with `hc put`, on its machine, and its path is typed in with your
|
|
@@ -185,7 +231,9 @@ Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
|
|
|
185
231
|
7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
|
|
186
232
|
The bot answers with your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
|
|
187
233
|
`herdrchat start` again.
|
|
188
|
-
8.
|
|
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").
|
|
189
237
|
|
|
190
238
|
## Running as a service
|
|
191
239
|
|
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,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);
|
|
@@ -487,7 +487,16 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
487
487
|
}
|
|
488
488
|
}
|
|
489
489
|
};
|
|
490
|
-
|
|
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);
|
|
491
500
|
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
492
501
|
asYou().catch((err) => logError(`as you: ${err.message}`));
|
|
493
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.
|
|
@@ -416,7 +434,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
416
434
|
closeDialog,
|
|
417
435
|
pin,
|
|
418
436
|
unpin,
|
|
419
|
-
pinned: () =>
|
|
437
|
+
pinned: () => Object.keys(read().pinned || {}),
|
|
420
438
|
ring,
|
|
421
439
|
markWorking,
|
|
422
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);
|
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):
|
|
@@ -64,7 +64,8 @@ without muting each topic by hand.
|
|
|
64
64
|
| an ended group gets the "ended" tag | an ended topic's name starts with 🔚 (closing a topic is documented for supergroups only) |
|
|
65
65
|
| an image inside a card | each image as its own photo after the text |
|
|
66
66
|
| a text box on a dialog card | type the answer in the topic |
|
|
67
|
-
|
|
|
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) |
|
|
68
69
|
| the overview card | one message in the main thread, edited in place |
|
|
69
70
|
| `/` commands: typed | `/` shows a menu: ls, new, kill, screen, answer, esc, history |
|
|
70
71
|
|