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 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 = 30000;
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: 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:
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` (see
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. Once it works: `herdrchat install` (see "Running as a service").
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
 
@@ -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, '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);
@@ -487,7 +487,16 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
487
487
  }
488
488
  }
489
489
  };
490
- startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)));
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; 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';
@@ -204,9 +204,27 @@ export function create({ cfg, store, log, logWarn, logError }) {
204
204
 
205
205
  // ---------- attention ----------
206
206
 
207
- const pins = new Set();
208
- const pin = async (k) => { pins.add(k); };
209
- const unpin = async (k) => { pins.delete(k); };
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: () => [...pins],
437
+ pinned: () => Object.keys(read().pinned || {}),
420
438
  ring,
421
439
  markWorking,
422
440
  markDone,
@@ -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
- console.log = (...args) => write('INFO', args);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Chat front-ends for hc: every terminal hc runs, in the chat app you use",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- | tags, labels, pins, `/auth` | none (a topic cannot be pinned; a bot cannot act as the user) |
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