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.
@@ -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
@@ -58,7 +58,8 @@ const ENDED_REPLY = 'Not delivered: this terminal has ended. Nothing you send he
58
58
  const WAIT = Symbol('wait');
59
59
  const FILE_BYTES = 20 * 1000 * 1000;
60
60
 
61
- // hc: {followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill};
61
+ // hc: {followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill,
62
+ // nameTab?(tabId, name): name a tab in herdr-app, when it runs here};
62
63
  // readers: {agentOf, readerFor}; store: {read, write} of state.json; inbox: a folder for
63
64
  // images on their way to a terminal; quick: shorter waits (tests).
64
65
  export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logError, startedAt = new Date().toISOString(), inbox = path.join(os.tmpdir(), 'herdrchat-inbox'), quick = false }) {
@@ -623,7 +624,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
623
624
  if (!dialog.menu) {
624
625
  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
626
  const room = read().chats[k]?.chat;
626
- if (room) await chat.pin(k, room);
627
+ if (room) await chat.pin(k, room, sent);
627
628
  await chat.ring(k, sent, '⚠️ needs you');
628
629
  } else {
629
630
  const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog)));
@@ -631,7 +632,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
631
632
  remember(message, dialog);
632
633
  openCard.set(k, { message, dialog });
633
634
  const room = read().chats[k]?.chat;
634
- if (room) await chat.pin(k, room);
635
+ if (room) await chat.pin(k, room, message);
635
636
  await chat.ring(k, message, '⚠️ needs you');
636
637
  // answered while the card was on its way: nothing will close it later, so now
637
638
  const live = views.get(session)?.terminals.get(address);
@@ -952,8 +953,9 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
952
953
  log(`-> ${session}/${address}: ${text.slice(0, 80)}`);
953
954
  await markWorking(k, message);
954
955
  } catch (err) {
955
- const hint = /blocked/i.test(err.message) ? '\n\nIt waits on a dialog. Reply `/answer <text>` to answer it, or `/esc` to cancel it.' : '';
956
- await chat.reply(where, `Not delivered: ${plainError(err)}\n\n${await screen(session, address)}${hint}`);
956
+ // the screen only when it helps: it waits on something you can answer from there
957
+ const waits = /blocked/i.test(err.message);
958
+ await chat.reply(where, `Not delivered: ${plainError(err)}${waits ? `\n\n${await screen(session, address)}\n\nIt waits on a dialog. Reply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.` : ''}`);
957
959
  return;
958
960
  }
959
961
  // Anything but an agent posts nothing by itself: what you typed gets one look at its
@@ -1028,7 +1030,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
1028
1030
  '`/new <session> <space>/<tab> [command]` start a terminal (default `claude`); `--cwd <dir>` first to pick its folder',
1029
1031
  '`/kill <session> <address>` end a terminal',
1030
1032
  ...(chat.help || []),
1031
- 'In a terminal\'s room: type to talk to it; `/screen`, `/answer <text>`, `/esc`, `/history` (the conversation so far, as a file).',
1033
+ `In a terminal's ${chat.roomWord}: type to talk to it; \`/screen\`, \`/answer <text>\`, \`/esc\`, \`/history\` (the conversation so far, as a file).`,
1032
1034
  ].join('\n');
1033
1035
 
1034
1036
  async function newTerminal(args) {
@@ -1047,13 +1049,17 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
1047
1049
  const spaceId = sibling?.labels['herdr.space'] || `sp${crypto.randomBytes(16).toString('hex')}`;
1048
1050
  const folder = cwd || sibling?.foreground?.cwd || sibling?.cwd || os.homedir();
1049
1051
  const line = command.length ? command.join(' ') : 'claude';
1052
+ const tabId = `tb${crypto.randomBytes(16).toString('hex')}`;
1050
1053
  const out = await hc.run(session, {
1051
1054
  cwd: folder,
1052
- labels: { 'herdr.space': spaceId, 'herdr.tab': `tb${crypto.randomBytes(16).toString('hex')}`, where: `${space} / ${tab}` },
1055
+ labels: { 'herdr.space': spaceId, 'herdr.tab': tabId, where: `${space} / ${tab}` },
1053
1056
  argv: ['/bin/zsh', '-lc', `exec ${line}`], // login shell for PATH; exec so no bare shell is left behind
1054
1057
  });
1055
1058
  const address = JSON.parse(out).address;
1056
- return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its room appears in a moment.`;
1059
+ // herdr-app makes the tab and numbers it, whatever `where` says: it is named as asked,
1060
+ // by the tab id it keeps (nothing happens where herdr-app is not running)
1061
+ if (hc.nameTab) hc.nameTab(tabId, tab).catch((err) => logWarn(`tab ${tab} not named in herdr-app: ${err.message}`));
1062
+ return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its ${chat.roomWord} appears in a moment.`;
1057
1063
  }
1058
1064
 
1059
1065
  async function direct(where, text) {
@@ -1073,14 +1079,14 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
1073
1079
  async function onMessage({ room, where, message, text, images = [] }) {
1074
1080
  try {
1075
1081
  if (!room) {
1076
- if (images.length) await chat.reply(where, 'Send images in a terminal\'s room.');
1082
+ if (images.length) await chat.reply(where, `Send images in a terminal's ${chat.roomWord}.`);
1077
1083
  else await direct(where, text);
1078
1084
  return;
1079
1085
  }
1080
1086
  const target = terminalOfRoom(room);
1081
1087
  if (!target) {
1082
1088
  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.');
1089
+ await chat.reply(where, `Not delivered: this ${chat.roomWord} is not a terminal's. Each terminal has its own ${chat.roomWord}; the direct chat takes /ls, /new and /kill.`);
1084
1090
  return;
1085
1091
  }
1086
1092
  if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
package/lib/core/hc.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  // Thin wrapper over the hc CLI. Every call names its session (HC_SESSION),
2
2
  // never the default core.
3
3
  import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import net from 'node:net';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
4
8
 
5
9
  // The bot runs in an hc terminal itself. Called with that terminal's identity, hc would
6
10
  // wrap what the bot types in <hc from="tN"> tags, as a note from another agent; without
@@ -68,7 +72,8 @@ export function run(session, { name, cwd, labels = {}, argv }) {
68
72
 
69
73
  // force: type even when the program is blocked on a dialog (it answers the dialog).
70
74
  export function send(session, address, text, { force = false } = {}) {
71
- const args = ['send', ...(force ? ['--force'] : []), address, text];
75
+ // `--`: what you typed is never taken for an option, even when it starts with "-"
76
+ const args = ['send', ...(force ? ['--force'] : []), address, '--', text];
72
77
  return hc(session, args, { timeoutMs: 60000 });
73
78
  }
74
79
 
@@ -96,6 +101,57 @@ export function isUnavailable(err) {
96
101
  return /"kind":"unavailable"/.test(err?.message || '');
97
102
  }
98
103
 
104
+ // The hc sessions on this machine, from hc's own folder (asking hc about a session that
105
+ // does not exist would start one: a typo must not make a session).
106
+ export const hcStateRoot = (env = process.env) => path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'herdr-core');
107
+ export function sessionsHere() {
108
+ try {
109
+ return fs.readdirSync(hcStateRoot(), { withFileTypes: true }).filter((d) => d.isDirectory() && fs.existsSync(path.join(hcStateRoot(), d.name, 'core.json'))).map((d) => d.name).sort();
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ // Whether a session's hc core runs, without starting it: its socket (named in core.lock)
116
+ // takes a connection.
117
+ export function coreUp(session, timeoutMs = 1000) {
118
+ let socket;
119
+ try {
120
+ socket = fs.readFileSync(path.join(hcStateRoot(), session, 'core.lock'), 'utf8').trim().split('\n')[0];
121
+ } catch {
122
+ return Promise.resolve(false);
123
+ }
124
+ return new Promise((resolve) => {
125
+ const c = net.connect(socket);
126
+ const done = (up) => { c.destroy(); resolve(up); };
127
+ c.once('connect', () => done(true));
128
+ c.once('error', () => done(false));
129
+ setTimeout(() => done(false), timeoutMs);
130
+ });
131
+ }
132
+
133
+ // herdr-app names the tab with this id (it may take a few seconds to show it), when
134
+ // herdr-app runs on this machine; resolves false when it is not there or never shows it.
135
+ const TAB_TRIES = 10;
136
+ export async function nameTab(tabId, name, { tries = TAB_TRIES, waitMs = 1000 } = {}) {
137
+ const app = (args) => new Promise((resolve, reject) => execFile('herdr-app', args, { env: ENV, timeout: 10000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
138
+ for (let i = 0; i < tries; i += 1) {
139
+ let spaces;
140
+ try {
141
+ spaces = JSON.parse(await app(['ls'])).spaces || [];
142
+ } catch {
143
+ return false; // no herdr-app here, or no window
144
+ }
145
+ const found = spaces.flatMap((s) => s.tabs || []).find((t) => t.id === tabId || t.id.endsWith(`/${tabId}`));
146
+ if (found) {
147
+ if (found.name !== name) await app(['do', 'tab.rename', '--id', found.id, '--name', name]);
148
+ return true;
149
+ }
150
+ await new Promise((r) => setTimeout(r, waitMs));
151
+ }
152
+ return false;
153
+ }
154
+
99
155
  // A failed hc call as a person reads it: hc's own message, without the command line or
100
156
  // its JSON wrapping.
101
157
  export function plainError(err) {
package/lib/core/log.mjs CHANGED
@@ -9,6 +9,9 @@ import path from 'node:path';
9
9
  import util from 'node:util';
10
10
 
11
11
  const KEEP_DAYS = 14;
12
+
13
+ // A log file per local day (what "today" means to you); the time on each line is UTC.
14
+ export const localDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
12
15
  const CRASH_WAIT_MS = 3000;
13
16
 
14
17
  export function createLog(dir) {
@@ -19,7 +22,7 @@ export function createLog(dir) {
19
22
  const prune = (today) => {
20
23
  if (pruned === today) return;
21
24
  pruned = today;
22
- const oldest = new Date(Date.now() - KEEP_DAYS * 86400 * 1000).toISOString().slice(0, 10);
25
+ const oldest = localDay(new Date(Date.now() - KEEP_DAYS * 86400 * 1000));
23
26
  for (const f of fs.readdirSync(dir)) {
24
27
  if (/^\d{4}-\d{2}-\d{2}\.log$/.test(f) && f.slice(0, 10) < oldest) fs.rmSync(path.join(dir, f), { force: true });
25
28
  }
@@ -32,8 +35,8 @@ export function createLog(dir) {
32
35
  const line = `${now} ${level} ${text}`;
33
36
  (level === 'ERROR' ? out.error : out.log)(line);
34
37
  try {
35
- fs.appendFileSync(path.join(dir, `${now.slice(0, 10)}.log`), `${line}\n`, { mode: 0o600 }); // it quotes your messages
36
- prune(now.slice(0, 10));
38
+ fs.appendFileSync(path.join(dir, `${localDay()}.log`), `${line}\n`, { mode: 0o600 }); // it quotes your messages
39
+ prune(localDay());
37
40
  } catch {
38
41
  // the disk is the last resort; stdout has the line
39
42
  }
@@ -46,7 +49,10 @@ export function createLog(dir) {
46
49
  };
47
50
 
48
51
  log.capture = ({ onCrash } = {}) => {
49
- console.log = (...args) => write('INFO', args);
52
+ // a library that prints its errors as plain log lines ("[error]: ...", as the Feishu
53
+ // SDK does) gets them logged as WARN, so `herdrchat logs --errors` finds them
54
+ const levelOf = (args) => (/^\[(error|warn)\]/i.test(String(args[0] ?? '')) ? 'WARN' : 'INFO');
55
+ console.log = (...args) => write(levelOf(args), args);
50
56
  console.info = console.log;
51
57
  console.warn = (...args) => write('WARN', args);
52
58
  console.error = (...args) => write('ERROR', args);
@@ -16,7 +16,7 @@
16
16
  // Cursors are in ../cursor.mjs.
17
17
  //
18
18
  // To add an agent: write lib/core/readers/<agent>.mjs and list it in LISTED below
19
- // (docs/architecture.md, "Adding a harness").
19
+ // (docs/architecture.md, "Adding an agent").
20
20
  import path from 'node:path';
21
21
  import * as claude from './claude.mjs';
22
22
 
@@ -76,6 +76,7 @@ const run = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['
76
76
 
77
77
  // {kind, name, file, content, write, remove, installed, load, unload, restart, state,
78
78
  // nodeGone}, or null on a system with neither launchd nor systemd.
79
+ // `profile` here is the profile's id (placesOf: the profile, tagged when its folder is moved).
79
80
  export function serviceOf({ profile, root, node, env, stateDir, platform = process.platform, home = os.homedir() }) {
80
81
  const script = path.join(root, 'bin', 'herdrchat.mjs');
81
82
  const out = path.join(stateDir, 'logs', 'service.out.log');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
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",
@@ -1,26 +1,28 @@
1
1
  ---
2
2
  name: herdrchat
3
- description: Set up, run and look after herdrchat, the chat front-end for hc that mirrors every herdr-app tab into a chat app - Feishu (one group per tab) or Telegram (one topic per tab in the bot's private chat), with dialog buttons, an overview and quiet phones - first setup of either app, running both side by side, start and stop, status and logs, /auth, settings, fixing problems, the Feishu developer console and @BotFather. Use for anything about herdrchat or its Feishu or Telegram bot, even when the user only says "set up the Telegram bot", "is it running", "it doesn't post", "mute the workers" or "publish the bot". Trigger with "/herdrchat", "/herdrchat setup", "/herdrchat status", "/herdrchat fix".
3
+ description: Set up, run and look after herdrchat, which mirrors every herdr-app (hc) tab into Feishu (a group per tab) or Telegram (a topic per tab): first setup of either app, start, stop, status, logs, running it as a service, settings, fixing problems, the Feishu developer console and @BotFather. Use for anything about herdrchat or its Feishu or Telegram bot, even "set up the Telegram bot", "is it running", "it does not post" or "mute the workers". Trigger with "/herdrchat", "/herdrchat setup", "/herdrchat status", "/herdrchat fix".
4
4
  ---
5
5
 
6
6
  # herdrchat
7
7
 
8
8
  herdrchat mirrors every herdr-app tab into a chat app: a group per tab in Feishu, a topic
9
9
  per tab in the bot's private chat in Telegram; the conversation as it happens, dialogs as
10
- buttons, an overview in the direct chat. One profile is one chat app (`HERDRCHAT_CHAT`);
11
- to use both, run a profile each (`--profile telegram` beside the default Feishu one).
10
+ buttons, an overview in the direct chat. One profile is one chat app (`HERDRCHAT_CHAT`):
11
+ one app uses the default profile; a second app beside it gets its own (`--profile <name>`).
12
12
 
13
13
  Its command is `herdrchat` (`node bin/cli.mjs` in a clone): check with `command -v
14
14
  herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A profile
15
15
  (`--profile`, default `default`) keeps its settings in
16
16
  `~/.config/herdrchat/<profile>/config.env` and its state in
17
- `~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both.
17
+ `~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both. Files named here
18
+ from the package (`config.env.example`, `docs/guide.md`) are in the installed package,
19
+ `$(npm root -g)/herdrchat/`, or in the clone.
18
20
 
19
21
  ## Commands
20
22
 
21
23
  | Command | Do |
22
24
  |---|---|
23
- | `/herdrchat setup` | First ask which chat app (Feishu, Telegram, or both: one profile each). Telegram: `/herdrchat telegram setup`. Feishu, in order: `herdrchat init`; make the Feishu app (`references/feishu.md`: create, bot, scopes, events and callbacks); fill `config.env` with the user (app id, secret, `HERDR_SESSIONS`: the hc sessions herdr-app shows on this machine); `herdrchat start`; publish a version; get the user's open_id (they message the bot, it answers with it) into `FEISHU_OWNER_OPEN_ID`; start again. Optional: `/herdrchat auth`. |
25
+ | `/herdrchat setup` | First ask which chat app (Feishu, Telegram, or both: the second one on its own profile). Telegram: `/herdrchat telegram setup`. Feishu, in this order: create the app, the bot ability and the scopes (`references/feishu.md`); `herdrchat init` and fill `config.env` with the user (App ID, App Secret, `HERDR_SESSIONS`: `ls ~/.local/state/herdr-core` lists the sessions); `herdrchat start` (the console's long-connection check needs it running); events and callbacks; publish a version; the user messages the bot and adds the `FEISHU_OWNER_OPEN_ID` line it answers with; `herdrchat start` again; offer `/herdrchat auth` and `herdrchat install`. |
24
26
  | `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
25
27
  | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. With a service installed they go through it. |
26
28
  | `/herdrchat install`, `/herdrchat uninstall` | Run it as a service of the system (launchd on macOS, systemd on Linux): it starts at login and comes back after a crash, whatever happens to the hc cores. Recommended once it works. Read the config first; after a node upgrade, install again (`herdrchat status` says so). |
@@ -29,7 +31,7 @@ herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A profile
29
31
  | `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`, `HERDR_HISTORY`, `HERDRCHAT_CHAT`; each is explained in `config.env.example`), read the file back, then restart. |
30
32
  | `/herdrchat fix` | Find the cause from `herdrchat status`, the logs and the live result, then fix it: `references/ops.md`. |
31
33
  | `/herdrchat feishu <task>` | Anything in the Feishu developer console: scopes, callbacks, publishing, the icon, deleting an app: `references/feishu.md`. |
32
- | `/herdrchat telegram <task>` | The Telegram bot, as its own profile: `setup` (@BotFather, topic mode, token, owner id, first start), checking the token, quiet and silent spaces, what Telegram cannot do and what stands in: `references/telegram.md`. |
34
+ | `/herdrchat telegram <task>` | The Telegram bot (the default profile when it is the only app): `setup` (@BotFather, topic mode, token, owner id, first start), checking the token, quiet and silent spaces, what Telegram cannot do and what stands in: `references/telegram.md`. |
33
35
 
34
36
  With no subcommand, run `herdrchat status` and suggest the next step from it.
35
37
 
@@ -39,8 +41,7 @@ With no subcommand, run `herdrchat status` and suggest the next step from it.
39
41
  disbanding anything, sending to a real group. One yes covers one action.
40
42
  - Never print, screenshot or paste `FEISHU_APP_SECRET`, `TELEGRAM_BOT_TOKEN` or
41
43
  `user-token.json`; ask the user to paste a token into `config.env` themselves.
42
- - A check that decides an action runs on its own first (read the config, then start;
43
- scan, then commit).
44
+ - A check that decides an action runs on its own first (read the config, then start).
44
45
  - Say what was checked on the live bot and what was not.
45
46
 
46
47
  ## References
@@ -53,9 +53,10 @@ that applies or publishes something, and read it.
53
53
  ### Create the app (first setup only)
54
54
 
55
55
  Developer console home -> create a custom app (企业自建应用) with a name, a description
56
- and an icon -> Add capabilities -> Bot. Then import the scopes, set events and
57
- callbacks, start the bot (`herdrchat start`) and publish. The "Set up" section of `docs/guide.md`
58
- lists the same steps from the repo's side.
56
+ and an icon -> Add capabilities -> Bot. Then import the scopes, have the user fill
57
+ `config.env` (`herdrchat init`), start the bot (`herdrchat start`: the long-connection
58
+ check below needs it running), set events and callbacks, and publish. "Set up: Feishu"
59
+ in `docs/guide.md` lists the same steps from the user's side.
59
60
 
60
61
  ### Import scopes
61
62
 
@@ -107,6 +108,27 @@ Deactivate it first in the tenant admin console,
107
108
  `https://<tenant>.feishu.cn/admin/appCenter/manage/<APP_ID>` (needs a tenant admin),
108
109
  then delete it in the developer console. Ask before each of the two steps.
109
110
 
111
+ ### The bot menu (optional)
112
+
113
+ A menu at the bottom of the direct chat with the bot, so the commands are a tap away as
114
+ Telegram's "/" list is. Feishu sets it only in the developer console (no API), shows it
115
+ in the direct chat only (not in groups), and needs a published version (then about five
116
+ minutes, and a client of 5.27 or later).
117
+
118
+ 1. 应用能力 -> 机器人 -> 机器人自定义菜单: edit, turn it on (开启), pick a style.
119
+ 2. Add items whose action is 推送事件 (push an event), with these keys; the bot treats a
120
+ click as that command in the direct chat:
121
+ | Item | Event key |
122
+ |---|---|
123
+ | Overview | `ls` |
124
+ | Help | `help` |
125
+ | Act as me | `auth` |
126
+ 3. 事件与回调 -> 事件配置 (long connection mode, as for messages) -> 添加事件:
127
+ 机器人自定义菜单事件 (`application.bot.menu_v6`).
128
+ 4. Publish a version. Ask before publishing.
129
+
130
+ Commands that take words (`/new`, `/kill`) stay typed.
131
+
110
132
  ## Scopes this bot uses
111
133
 
112
134
  Bot identity (the bot itself) and user identity (only for `/auth`, acting as the user):
@@ -1,25 +1,27 @@
1
1
  # Ops: running and looking after herdrchat
2
2
 
3
- The bot is `bin/herdrchat.mjs`. It runs in an hc terminal named `herdrchat`, in the first
4
- hc session listed in `HERDR_SESSIONS`; `herdrchat start` (re)starts it and keeps it
5
- running (`node bin/cli.mjs` from a clone). A profile (`--profile` or `HERDRCHAT_PROFILE`,
6
- default `default`; another one runs as the hc terminal `herdrchat-<profile>`) has its
7
- settings in `~/.config/herdrchat/<profile>/config.env` and writes to
8
- `~/.local/state/herdrchat/<profile>/` (call it `D` below), unless `HERDRCHAT_HOME` holds
9
- both (`config.env`, `state/`). Its settings in Feishu itself (scopes, callbacks, icon,
10
- versions) are in `references/feishu.md`.
3
+ The bot (`bin/herdrchat.mjs` in the installed package) runs one of two ways:
11
4
 
12
- Below, `S` is the first session in `HERDR_SESSIONS`.
5
+ - as a **service** of the system, after `herdrchat install` (launchd on macOS, systemd on
6
+ Linux): it starts at login and comes back after a crash; recommended;
7
+ - in an **hc terminal** named `herdrchat` (`herdrchat-<profile>` for another profile), in
8
+ the first session of `HERDR_SESSIONS`, after `herdrchat start` without a service.
9
+
10
+ `herdrchat status` says which, and where the profile's files are: settings in
11
+ `~/.config/herdrchat/<profile>/config.env`, state and logs in
12
+ `~/.local/state/herdrchat/<profile>/` (call it `D` below). The app's own settings (Feishu
13
+ scopes, callbacks; the Telegram bot's topic mode) are in `references/feishu.md` and
14
+ `references/telegram.md`.
13
15
 
14
16
  ## Is it running
15
17
 
16
18
  ```sh
17
- HC_SESSION=S hc get herdrchat --json # state "live"
18
- HC_SESSION=S hc read herdrchat --lines 40 # its terminal: start lines, "[feishu] connected"
19
- herdrchat status # running, folders, /auth, errors today
20
- herdrchat start # restart: stops it in every session, waits until it reaches its chat app
21
- herdrchat install # run it as a service (launchd/systemd): starts at login, back after a crash
22
- launchctl print gui/$(id -u)/app.herdrchat.<profile> # the service on macOS: state, pid, last exit
19
+ herdrchat status # how it runs, sessions, keys set or missing, errors today
20
+ herdrchat logs --errors # what went wrong today ("[feishu] connected" / "[telegram] connected as @…" when in)
21
+ herdrchat start # restart: stops it everywhere, waits up to a minute until it is in
22
+ herdrchat install # make it a service: starts at login, back after a crash
23
+ launchctl print gui/$(id -u)/app.herdrchat.<profile> # macOS: the service's state, pid, last exit
24
+ systemctl --user status herdrchat-<profile> # Linux: the same
23
25
  ```
24
26
 
25
27
  A restart is needed after any code or `config.env` change. It is safe: what was posted
@@ -28,7 +30,7 @@ is in `D/state.json`, so it picks up where it stopped.
28
30
  While the bot is down the user hears nothing, so:
29
31
 
30
32
  - Never end with the bot down: `herdrchat start` exits 1 with its errors when the bot
31
- does not reach Feishu within 30 s; fix that before anything else.
33
+ does not reach its chat app within 60 s; fix that before anything else.
32
34
  - Never stop or restart the hc core (`hc server stop|restart`) of the session the bot
33
35
  runs in without saying so first, and run `herdrchat start` right after.
34
36
  - Never start an hc core from an agent's shell: the core passes that shell's variables
@@ -73,49 +75,17 @@ grep -h ' <- \| -> ' D/logs/$(date -u +%F).log | tail # posts out, your messag
73
75
  Read it with a short `node -e` and print only what you need. `D/user-token.json` is
74
76
  your token after `/auth`: never print it.
75
77
 
76
- ## Did the change take effect
77
-
78
- Check the live result, not only the code: count what actually happened in Feishu with
79
- the bot's own client (`lib/chats/feishu/client.mjs` `createClient` with `loadConfig()`), for example:
80
-
81
- - mentions in a group since a time: `im.v1.message.list` on the chat, then each item's
82
- `mentions` (a mention inside a card is not counted by Feishu, so it does not ring);
83
- - a group's bot tags: `im.v2.bizEntityTagRelation.get` with `tag_biz_type: 'chat'`;
84
- - whether a card was edited: `im.v1.message.get`, compare `update_time` with `create_time`.
85
-
86
- Say which checks were run and which were not; "the code is there" is not "it works".
87
-
88
78
  ## Usual problems
89
79
 
90
80
  | You see | Likely cause | Do |
91
81
  |---|---|---|
92
- | nothing posts | bot down, or `FEISHU_OWNER_OPEN_ID` unset | check it runs; the log says "owner not set" |
82
+ | nothing posts | bot down, or the owner not set (`FEISHU_OWNER_OPEN_ID`, `TELEGRAM_OWNER_ID`) | `herdrchat status`; the log says "owner not set" |
83
+ | Telegram: no topic appears | topic mode off | log: "topic mode is off"; BotFather's mini app, the bot, Threaded Mode, then start again |
84
+ | Telegram: `telegram poll: ... 409` | another copy polls the same token | stop it (another machine?), then start once |
93
85
  | a button gives error 200340 | the card callback is not set up | `references/feishu.md`: events and card callbacks |
94
86
  | a dialog had no card | answered in the terminal before the card; or no numbered menu on screen | log: `blocked`; the card says `/answer` when it saw no menu |
95
87
  | the phone rings too much | Feishu: not `/auth`ed; either app: settings | Feishu `/auth`; `HERDR_MUTED_SPACES` (quiet but for needs you and done), `HERDR_SILENT_SPACES` (nothing rings), per profile |
96
88
  | a ⚠️ warning in the direct chat about acting as you | the token could not be refreshed | send `/auth` again |
97
89
  | a group keeps an old name | its terminal is not live | it renames when the terminal is back |
98
90
  | a Feishu call fails with 400 in a burst | a rate limit (tags: 5 a second) | space the calls; the bot queues tag calls |
99
-
100
- ## Trying a Feishu API safely
101
-
102
- 1. Read the official doc first (open.feishu.cn); write down the scope and limits.
103
- 2. Make real calls only with this repo's own code (the SDK or `fetch`), never through a
104
- third-party tool; reading another tool's docs or a dry run to learn a path is fine.
105
- 3. Test in a throwaway group the bot creates for it (`zz-test ...`), never in a
106
- terminal's group; label each test message ("Test 2/3: ...") so the user can tell
107
- which one rang.
108
- 4. Measure the result (an API field, a read-back) instead of assuming it; ask the user
109
- only what only a phone shows (did it ring).
110
- 5. Clean up in the same run: disband the test group, delete test labels and tags you
111
- made, recall test messages, delete scratch files.
112
-
113
- ## Before a commit
114
-
115
- 1. `npm test` passes.
116
- 2. Stage, then, as its own step, scan the staged files for anything real: session and
117
- machine names, the app id (`cli_`), open_ids (`ou_`), chat ids (`oc_`), message ids
118
- (`om_`), label ids (`ofg_`), the tenant's name, people's names, local paths. Read the
119
- result before committing; never chain the scan and the commit in one command.
120
- 3. Show the commit message (Conventional Commits; the body says why) and commit only
121
- after the user's yes. No push without its own yes.
91
+ | `herdrchat start` says it did not reach its chat app | wrong keys, or no network | `herdrchat logs --errors`; check the keys in `config.env` |
@@ -3,9 +3,11 @@
3
3
  A Telegram bot cannot make groups (the Bot API has no such method), so herdrchat puts each
4
4
  herdr-app tab in a **topic** of your private chat with the bot: each has its own name,
5
5
  icon color (one per space), unread count, and can be muted on its own. The chat's main
6
- thread holds the overview and the commands. It runs as its **own profile**, beside a
7
- Feishu one; each bot follows the same hc terminals, so an answer in either reaches the
8
- terminal, and a dialog answered in one closes its card in the other.
6
+ thread holds the overview and the commands. When Telegram is the only chat app, it uses
7
+ the default profile; beside a Feishu bot it gets its own (`--profile telegram`), and both
8
+ follow the same hc terminals, so an answer in either reaches the terminal and a dialog
9
+ answered in one closes its card in the other. Below, `P` is ` --profile <name>` when it
10
+ has its own profile, else nothing.
9
11
 
10
12
  ## Setup (`/herdrchat telegram setup`)
11
13
 
@@ -14,34 +16,26 @@ Do these in order; the user does the Telegram steps, you check each one.
14
16
  1. **The bot.** The user messages **@BotFather**: `/newbot`, a name, a username ending in
15
17
  `bot`. BotFather answers with a token. The user keeps it: never ask them to paste it
16
18
  into the chat.
17
- 2. **Topic mode.** It is not in BotFather's `/mybots` → Bot Settings list; it is in
18
- BotFather's mini app (the **Open** button at the bottom left of the BotFather chat):
19
- pick the bot, turn on Threaded Mode / topics. Check it (step 5): `has_topics_enabled`
20
- must be `true`, or no topic can be made.
21
- 3. **The profile's config.** Make `~/.config/herdrchat/telegram/config.env` (folder mode
22
- 700, file 600) with:
23
- ```
24
- HERDRCHAT_CHAT=telegram
25
- TELEGRAM_BOT_TOKEN=
26
- TELEGRAM_OWNER_ID=
27
- HERDR_SESSIONS=<the same sessions as the Feishu profile>
28
- ```
29
- and ask the user to paste the token after `TELEGRAM_BOT_TOKEN=` themselves
30
- (`open -e <file>` opens it), no quotes, no spaces.
31
- 4. **First start, to learn the owner.** `herdrchat start --profile telegram`; the user
32
- messages the bot anything; it answers `TELEGRAM_OWNER_ID=<digits>`. The user puts that
33
- in the config. Until then the bot makes no topic.
34
- 5. **Check, without printing secrets.** In a short `node -e`: read the token from the
35
- config, call `https://api.telegram.org/bot<token>/getMe`, print only `username` and
36
- `has_topics_enabled`; for the owner, print only whether `TELEGRAM_OWNER_ID` is digits.
37
- 6. **Start again.** Read the config (secrets hidden) in one step, then
38
- `herdrchat start --profile telegram` in the next. One topic per tab appears; Telegram
39
- takes about one message a second in a chat, so twenty tabs take twenty seconds. A new
40
- topic gets the conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it
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.
19
+ 2. **The config.** `herdrchat init --chat telegram P` makes the profile's `config.env`
20
+ (private) and prints where it is. Ask the user to paste the token after
21
+ `TELEGRAM_BOT_TOKEN=` themselves (`open -e <file>` opens it), no quotes, no spaces, and
22
+ fill `HERDR_SESSIONS` with them (`ls ~/.local/state/herdr-core` lists the sessions).
23
+ 3. **Topic mode, checked before the first start.** It is not in BotFather's `/mybots` →
24
+ Bot Settings list; it is in BotFather's mini app (the **Open** button at the bottom
25
+ left of the BotFather chat): pick the bot, turn on Threaded Mode. Check it without
26
+ printing the token: a short `node -e` reads the token from the config, calls
27
+ `https://api.telegram.org/bot<token>/getMe` and prints only `username` and
28
+ `has_topics_enabled`, which must be `true`.
29
+ 4. **First start, to learn the owner.** `herdrchat start P`; the user messages the bot
30
+ anything; it answers with the line to add, `TELEGRAM_OWNER_ID=<digits>`, and where the
31
+ file is. The user adds it. Until then the bot makes no topic.
32
+ 5. **Start again.** Read the config (secrets hidden) in one step, then
33
+ `herdrchat start P` in the next. One topic per tab appears; Telegram takes about one
34
+ message a second in a chat, so twenty tabs take twenty seconds. A new topic gets the
35
+ conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it again.
36
+ 6. **Make it a service.** Once it works: `herdrchat install P`. It then starts at login
37
+ and comes back by itself after a crash, whatever happens to the hc cores;
38
+ `herdrchat status P` says it runs as the service.
45
39
 
46
40
  ## What rings
47
41
 
@@ -64,7 +58,8 @@ without muting each topic by hand.
64
58
  | an ended group gets the "ended" tag | an ended topic's name starts with 🔚 (closing a topic is documented for supergroups only) |
65
59
  | an image inside a card | each image as its own photo after the text |
66
60
  | 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) |
61
+ | pins the group that needs you | pins the dialog card in its topic until it is answered |
62
+ | tags, labels, `/auth` | none (a bot cannot act as the user) |
68
63
  | the overview card | one message in the main thread, edited in place |
69
64
  | `/` commands: typed | `/` shows a menu: ls, new, kill, screen, answer, esc, history |
70
65
 
@@ -72,9 +67,9 @@ without muting each topic by hand.
72
67
 
73
68
  | Seen | Cause | Do |
74
69
  |---|---|---|
75
- | log: `topic mode is off for @<bot>` | step 2 not done | turn it on in BotFather's mini app, check `has_topics_enabled`, start again |
76
- | the bot answers `Owner is not set` | `TELEGRAM_OWNER_ID` empty | step 4 |
77
- | `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 5) |
78
- | log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop --profile telegram`, find the other one (another machine?), start once |
70
+ | log: `topic mode is off for @<bot>` | step 3 not done | turn it on in BotFather's mini app, check `has_topics_enabled`, start again |
71
+ | the bot answers "To make you this bot's owner…" | `TELEGRAM_OWNER_ID` empty | step 4 |
72
+ | `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 3) |
73
+ | log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop P`, find the other one (another machine?), start once |
79
74
  | log: `too fast, waiting N s` | Telegram's flood limit; the bot waits as told | nothing; it catches up |
80
75
  | a click answers "This dialog is gone" | the card is older than the last 300 dialog cards | answer the newest card, or in the terminal |