herdrchat 0.0.0-stage → 0.1.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.
@@ -0,0 +1,24 @@
1
+ // Readers: how the bot gets what an agent said from the agent's own records, so nothing
2
+ // is lost and the group shows it while the agent works. hc stays the backbone (which
3
+ // terminals exist, their status, when a turn ends, typing into them); a reader adds the
4
+ // conversation. A program with no reader gets the default: its screen through `hc read`.
5
+ //
6
+ // A reader is a module that exports:
7
+ // app the name the agent gives in its hc status (`status.app`)
8
+ // after(session, address, cursor) -> {items, file} | null: what is new since `cursor`
9
+ // (null: this terminal is not readable); `file` is
10
+ // the log it read, kept in the cursor
11
+ // rest(session, address, cursor) -> items: what `cursor.file` still holds after the
12
+ // program is gone
13
+ // Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
14
+ // answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
15
+ // Cursors are in ../cursor.mjs.
16
+ //
17
+ // To add an agent: write lib/readers/<agent>.mjs and list it here.
18
+ import * as claude from './claude.mjs';
19
+
20
+ const READERS = new Map([claude].map((r) => [r.app, r]));
21
+
22
+ export function readerFor(app) {
23
+ return READERS.get(app) || null;
24
+ }
@@ -0,0 +1,119 @@
1
+ // Acting as you (user_access_token), for the few chat-list settings Feishu lets only you
2
+ // change: mute a group, put it in one of your feed groups (labels), pin it. You allow it
3
+ // once with the OAuth device flow (`/auth` in the direct chat), which needs no public address: the bot shows a link
4
+ // and a code, you confirm in Feishu, the bot polls for the token. The token is refreshed
5
+ // before it expires and kept in user-token.json in the state folder (mode 600), never logged.
6
+ // Only the calls in this file are made with it.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const ACCOUNTS = 'https://accounts.feishu.cn';
11
+ const OPEN = 'https://open.feishu.cn';
12
+ export const USER_SCOPES = ['im:chat.user_setting:write', 'im:feed_group_v1:read', 'im:feed_group_v1:write', 'im:feed.shortcut:write', 'offline_access'];
13
+ const EARLY_MS = 5 * 60 * 1000; // refresh this long before the token runs out
14
+
15
+ export function createUserAuth(cfg, dir) {
16
+ const file = path.join(dir, 'user-token.json');
17
+ const basic = Buffer.from(`${cfg.appId}:${cfg.appSecret}`).toString('base64');
18
+
19
+ const load = () => {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+ const save = (t) => {
27
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(t), { mode: 0o600 });
28
+ fs.renameSync(`${file}.tmp`, file);
29
+ };
30
+ const keep = (res) => {
31
+ const now = Date.now();
32
+ const old = load() || {};
33
+ const t = {
34
+ access: res.access_token,
35
+ accessUntil: now + (res.expires_in || 7200) * 1000,
36
+ refresh: res.refresh_token || old.refresh,
37
+ refreshUntil: res.refresh_token ? now + (res.refresh_token_expires_in || 7 * 86400) * 1000 : old.refreshUntil,
38
+ scope: res.scope || old.scope,
39
+ };
40
+ save(t);
41
+ return t;
42
+ };
43
+
44
+ async function form(url, body, auth) {
45
+ const res = await fetch(url, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(auth ? { Authorization: `Basic ${basic}` } : {}) },
48
+ body: new URLSearchParams(body),
49
+ });
50
+ return res.json();
51
+ }
52
+
53
+ // Step 1: a link and a code for you. Step 2 (`waitForToken`) polls until you confirm.
54
+ async function startDeviceFlow() {
55
+ const r = await form(`${ACCOUNTS}/oauth/v1/device_authorization`, { client_id: cfg.appId, scope: USER_SCOPES.join(' ') }, true);
56
+ if (!r.device_code) throw new Error(`device authorization: ${JSON.stringify(r).slice(0, 200)}`);
57
+ return r;
58
+ }
59
+
60
+ async function waitForToken(device) {
61
+ let interval = (device.interval || 5) * 1000;
62
+ const until = Date.now() + (device.expires_in || 240) * 1000;
63
+ while (Date.now() < until) {
64
+ await new Promise((ok) => setTimeout(ok, interval));
65
+ const r = await form(`${ACCOUNTS}/oauth/v3/token`, {
66
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
67
+ device_code: device.device_code,
68
+ client_id: cfg.appId,
69
+ client_secret: cfg.appSecret,
70
+ });
71
+ if (r.access_token) return keep(r);
72
+ if (r.error === 'slow_down') interval += 5000;
73
+ else if (r.error !== 'authorization_pending') throw new Error(`authorization: ${r.error || JSON.stringify(r).slice(0, 200)}`);
74
+ }
75
+ throw new Error('authorization: the code expired before it was confirmed');
76
+ }
77
+
78
+ async function token() {
79
+ const t = load();
80
+ if (!t?.access) return null;
81
+ if (Date.now() < t.accessUntil - EARLY_MS) return t.access;
82
+ if (!t.refresh || Date.now() > (t.refreshUntil || 0)) return null;
83
+ const res = await fetch(`${ACCOUNTS}/oauth/v3/token`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
86
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: t.refresh, client_id: cfg.appId, client_secret: cfg.appSecret }),
87
+ });
88
+ const r = await res.json();
89
+ if (!r.access_token) throw new Error(`token refresh: ${r.error || JSON.stringify(r).slice(0, 200)}`);
90
+ return keep(r).access;
91
+ }
92
+
93
+ // One call as you; null when you have not allowed it (or it ran out).
94
+ async function call(method, apiPath, body) {
95
+ const access = await token();
96
+ if (!access) return null;
97
+ const res = await fetch(`${OPEN}${apiPath}`, {
98
+ method,
99
+ headers: { Authorization: `Bearer ${access}`, 'Content-Type': 'application/json; charset=utf-8' },
100
+ body: body ? JSON.stringify(body) : undefined,
101
+ });
102
+ const r = await res.json();
103
+ if (r.code) throw new Error(`${method} ${apiPath} ${r.code}: ${r.msg}`);
104
+ return r.data || {};
105
+ }
106
+
107
+ return {
108
+ startDeviceFlow,
109
+ waitForToken,
110
+ allowed: async () => Boolean(await token().catch(() => null)),
111
+ setMuted: (chatIds, muted) => call('POST', '/open-apis/im/v1/chat_user_setting/batch_update', { chat_settings: chatIds.map((chat_id) => ({ chat_id, is_muted: muted })) }),
112
+ feedGroups: () => call('GET', '/open-apis/im/v1/groups?page_size=50&page_token='),
113
+ createFeedGroup: (name) => call('POST', '/open-apis/im/v1/groups', { feed_group_creator: { name, type: 'normal' } }),
114
+ addToFeedGroup: (groupId, chatIds) => call('POST', `/open-apis/im/v1/groups/${groupId}/batch_add_item`, { items: chatIds.map((feed_id) => ({ feed_id, feed_type: 'chat' })) }),
115
+ removeFromFeedGroup: (groupId, chatIds) => call('POST', `/open-apis/im/v1/groups/${groupId}/batch_remove_item`, { items: chatIds.map((feed_id) => ({ feed_id, feed_type: 'chat' })) }),
116
+ pin: (chatId) => call('POST', '/open-apis/im/v2/feed_shortcuts', { is_header: true, shortcuts: [{ feed_card_id: chatId, type: 1 }] }),
117
+ unpin: (chatId) => call('POST', '/open-apis/im/v2/feed_shortcuts/remove', { shortcuts: [{ feed_card_id: chatId, type: 1 }] }),
118
+ };
119
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Chat front-ends for hc: every terminal hc runs, in the chat app you use",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/zhangxiangliang/herdrchat.git"
8
+ },
9
+ "homepage": "https://github.com/zhangxiangliang/herdrchat#readme",
10
+ "bugs": "https://github.com/zhangxiangliang/herdrchat/issues",
11
+ "scripts": {
12
+ "start": "node bin/cli.mjs run",
13
+ "test": "node --test test/*.test.mjs"
14
+ },
15
+ "author": "zhangxiangliang",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@larksuiteoapi/node-sdk": "^1.76.0"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "herdrchat": "bin/cli.mjs"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "lib/",
27
+ "skills/",
28
+ "docs/",
29
+ "config.env.example",
30
+ "README.md",
31
+ "README.zh.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ }
37
+ }
@@ -0,0 +1,9 @@
1
+ # Skills
2
+
3
+ `herdrchat/` is the one skill: an agent that reads `SKILL.md` (Claude Code and others) uses
4
+ it to set up, run and look after herdrchat. Its commands are `/herdrchat <command>`.
5
+
6
+ Each part of the system has one reference under `herdrchat/references/`, named after the
7
+ part: a chat app (`feishu.md`, later `slack.md`, `discord.md`, `telegram.md`) or a part of
8
+ the core (`ops.md`, later `reader.md`). New work goes into the reference of its part, or
9
+ a new one for a new part; `SKILL.md` only lists commands and points to references.
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: herdrchat
3
+ description: Set up, run and look after herdrchat, the chat front-end for hc that mirrors every hc terminal into Feishu (one group per terminal, dialog buttons, an overview, quiet phones) - first setup with the Feishu app, start and stop, status and logs, /auth, settings, fixing problems, and the Feishu developer console (with Ego, or guided click by click). Use for anything about herdrchat or its Feishu bot, even when the user only says "set up the Feishu bot", "is it running", "it doesn't post", "mute the workers" or "publish the bot". Trigger with "/herdrchat", "/herdrchat setup", "/herdrchat status", "/herdrchat fix".
4
+ ---
5
+
6
+ # herdrchat
7
+
8
+ herdrchat mirrors every terminal hc runs into a chat app, Feishu first: one group per
9
+ terminal, the conversation as it happens, dialogs as buttons, an overview in the direct
10
+ chat. Its command is `herdrchat` (`node bin/cli.mjs` in a clone): check with
11
+ `command -v herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A
12
+ profile (`--profile`, default `default`) keeps its settings
13
+ in `~/.config/herdrchat/<profile>/config.env` and its state in
14
+ `~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both.
15
+
16
+ ## Commands
17
+
18
+ | Command | Do |
19
+ |---|---|
20
+ | `/herdrchat setup` | First time, 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`. |
21
+ | `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
22
+ | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. |
23
+ | `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
24
+ | `/herdrchat auth` | The user sends `/auth` to the bot and allows it on Feishu's page; the app needs the user scopes in `references/feishu.md`. Then groups are muted, labels and pins follow. |
25
+ | `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`; each is explained in `config.env.example`), read the file back, then restart. |
26
+ | `/herdrchat fix` | Find the cause from `herdrchat status`, the logs and the live result, then fix it: `references/ops.md`. |
27
+ | `/herdrchat feishu <task>` | Anything in the Feishu developer console: scopes, callbacks, publishing, the icon, deleting an app: `references/feishu.md`. |
28
+
29
+ With no subcommand, run `herdrchat status` and suggest the next step from it.
30
+
31
+ ## Rules
32
+
33
+ - Ask before anything outward-facing: publishing a version, applying scopes, deleting or
34
+ disbanding anything, sending to a real group. One yes covers one action.
35
+ - Never print, screenshot or paste `FEISHU_APP_SECRET` or `user-token.json`.
36
+ - A check that decides an action runs on its own first (read the config, then start;
37
+ scan, then commit).
38
+ - Say what was checked on the live bot and what was not.
39
+
40
+ ## References
41
+
42
+ - `references/feishu.md`: the bot's app in the Feishu developer console, with Ego or as a
43
+ guided walk-through; the scopes this bot uses.
44
+ - `references/ops.md`: running and checking the bot, its logs and state, the usual
45
+ problems, trying a Feishu API safely, committing without a leak.
@@ -0,0 +1,150 @@
1
+ # Feishu: the bot's app in the developer console
2
+
3
+ herdrchat's Feishu bot has settings that live in Feishu, not in this repo; they are
4
+ changed in the Feishu developer console (`https://open.feishu.cn/app`). This reference does
5
+ that with ego-browser, in the user's own logged-in browser.
6
+
7
+ ## Before anything
8
+
9
+ 1. **ego-browser.** Run `command -v ego-browser`. When it is there, run
10
+ `ego-browser skill` and follow its usage guide. When it is missing, do not fall back
11
+ to another browser; ask the user which they want:
12
+ - install Ego Lite (https://lite.ego.app/), sign in to Feishu in it, and ask again,
13
+ so the agent does the clicking; or
14
+ - a guided walk-through instead (see "Guide mode" below): the user clicks, the agent
15
+ says what to click.
16
+ 2. **Which profile.** Ask which Ego profile is signed in to Feishu as an admin of the
17
+ tenant that owns the app (`await profiles()` lists them); never guess. Create one task
18
+ space for the whole job with that `profileId`, and close it at the end with
19
+ `await task.finish({ keep: [] })`.
20
+ 3. **Which app.** The app id is `FEISHU_APP_ID` in the profile's `config.env`
21
+ (`~/.config/herdrchat/<profile>/config.env`, or in `HERDRCHAT_HOME`). Read only that
22
+ key; never print, screenshot or paste `FEISHU_APP_SECRET`, and never write a real app
23
+ id, open_id, chat id or tenant name into a file of this repo.
24
+ 4. **Ask first** before anything outward-facing: publishing a version, applying scopes,
25
+ deleting or deactivating an app. One yes covers one action.
26
+
27
+ ## Guide mode (no Ego)
28
+
29
+ The same procedures, done by the user. Give one step at a time, with the page link and
30
+ the exact button text as Feishu shows it (the Chinese labels below), and wait for the
31
+ user to say it is done before the next step. Where a procedure says to check something
32
+ (the confirm list, 已开通, 已发布), ask the user what the page shows. Never ask the user
33
+ to paste the app secret into the chat.
34
+
35
+ ## Pages
36
+
37
+ | What | URL |
38
+ |---|---|
39
+ | Basic info, icon, credentials | `https://open.feishu.cn/app/<APP_ID>/baseinfo` |
40
+ | Add capabilities (bot) | `https://open.feishu.cn/app/<APP_ID>/capability` |
41
+ | Scopes | `https://open.feishu.cn/app/<APP_ID>/auth` |
42
+ | Events and callbacks | `https://open.feishu.cn/app/<APP_ID>/event` (`?tab=callback` for callbacks) |
43
+ | Versions and publishing | `https://open.feishu.cn/app/<APP_ID>/version` |
44
+
45
+ The console is a slow single-page app: after `goto` or a click, wait (2-3 s, or
46
+ `waitForFunction` on the text you expect) before reading it. Find buttons by their
47
+ visible text with `page.evaluate` (`[...document.querySelectorAll("button")]`), not by
48
+ position: the layout shifts when a banner appears. Take a screenshot before each click
49
+ that applies or publishes something, and read it.
50
+
51
+ ## Procedures
52
+
53
+ ### Create the app (first setup only)
54
+
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.
59
+
60
+ ### Import scopes
61
+
62
+ Scopes page -> 批量处理 -> 批量导入/导出权限. Paste the JSON from "Scopes this bot uses"
63
+ below (only the ones still missing is fine; the dialog skips what is there).
64
+
65
+ - **Click inside the editor's text first, then select all and replace.** Pressing
66
+ select-all before the editor has focus selects the whole page, and the import then
67
+ submits the editor's default sample (`docx:document:readonly`) instead of yours.
68
+ - Read the editor back before going on: the lines are in `.view-lines .view-line`.
69
+ - Next step (下一步,确认新增权限) opens a confirm dialog listing the scopes it will add,
70
+ by their Chinese names, split into bot (应用身份) and user (用户身份) tabs. Check that
71
+ list matches what you meant to add, then (after the user's yes) 申请开通.
72
+ - Verify on the scopes page that each one reads 已开通. Most of these are review-free
73
+ and live at once; the page header then still says 当前修改均已发布. When it says
74
+ 版本发布后,当前修改方可生效 instead, publish a version.
75
+ - Unknown scope names are dropped silently, so a missing one in the confirm list means
76
+ a typo or a scope that does not exist.
77
+
78
+ ### Events and card callbacks
79
+
80
+ Both must use the long connection (长连接), which needs the bot running.
81
+
82
+ - Events (事件配置): subscription mode -> long connection; add `im.message.receive_v1`.
83
+ - Callbacks (回调配置): edit the subscription mode -> 使用长连接接收回调 -> 验证 (it must
84
+ answer 连接成功; if not, the bot is not running) -> 保存 -> 添加回调 -> card ->
85
+ `card.action.trigger` (not the old `card.action.trigger_v1`, which cannot use the long
86
+ connection) -> 添加. Without it, a click on a dialog button fails with error 200340.
87
+ - Publish a version afterwards.
88
+
89
+ ### Publish a version
90
+
91
+ Versions page -> 创建版本 (the form loads slowly; wait for the 保存 button). Check the
92
+ version number (one above the last) and that the green bar says 本次发布免审核; if it
93
+ does not, the version goes to the tenant admin for review - tell the user. 保存 ->
94
+ 确认发布 -> reload the versions page and check the new version reads 已发布.
95
+
96
+ ### Change the app icon (the bot's avatar)
97
+
98
+ Basic info -> the edit pen next to 综合信息 -> `page.setInputFiles("input[type=file]",
99
+ [<absolute path>])` with a square JPEG/PNG/SVG/BMP over 240x240 px, under 2 MB, without
100
+ rounded corners -> 保存 in the crop dialog -> 保存 under the form. Then publish a
101
+ version; the client may need a while or a restart to show it.
102
+
103
+ ### Delete an app
104
+
105
+ The developer console's delete button stays disabled while the app is enabled.
106
+ Deactivate it first in the tenant admin console,
107
+ `https://<tenant>.feishu.cn/admin/appCenter/manage/<APP_ID>` (needs a tenant admin),
108
+ then delete it in the developer console. Ask before each of the two steps.
109
+
110
+ ## Scopes this bot uses
111
+
112
+ Bot identity (the bot itself) and user identity (only for `/auth`, acting as the user):
113
+
114
+ ```json
115
+ {
116
+ "scopes": {
117
+ "tenant": [
118
+ "im:message:send_as_bot",
119
+ "im:message.p2p_msg:readonly",
120
+ "im:message.group_msg",
121
+ "im:message:readonly",
122
+ "im:message.reactions:write_only",
123
+ "im:resource",
124
+ "im:chat:create",
125
+ "im:chat:update",
126
+ "im:tag:write",
127
+ "im:biz_entity_tag_relation:write"
128
+ ],
129
+ "user": [
130
+ "im:chat.user_setting:write",
131
+ "im:feed_group_v1:read",
132
+ "im:feed_group_v1:write",
133
+ "im:feed.shortcut:write",
134
+ "offline_access"
135
+ ]
136
+ }
137
+ }
138
+ ```
139
+
140
+ What each is for is in `docs/guide.md` ("Set up" and "Optional: let the bot act as you").
141
+ A scope the code does not call is not added.
142
+
143
+ ## Traps seen
144
+
145
+ - Select-all before the editor has focus selects the page (see Import scopes).
146
+ - A button found by position moved under a banner; find it by its text.
147
+ - `evaluate` on the version form ran before it loaded and found no 保存; wait for it.
148
+ - The callback "verify" fails when the bot is not running; start it first.
149
+ - Changes on the scopes page that need a version say so in the header; review-free
150
+ scopes do not, and publishing for them is not needed.
@@ -0,0 +1,106 @@
1
+ # Ops: running and looking after herdrchat
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`.
11
+
12
+ Below, `S` is the first session in `HERDR_SESSIONS`.
13
+
14
+ ## Is it running
15
+
16
+ ```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 the old one first)
21
+ ```
22
+
23
+ A restart is needed after any code or `config.env` change. It is safe: what was posted
24
+ is in `D/state.json`, so it picks up where it stopped.
25
+
26
+ ## Logs
27
+
28
+ `D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`:
29
+
30
+ ```sh
31
+ grep -h ERROR D/logs/*.log | tail -20 # what went wrong
32
+ grep -h WARN D/logs/*.log | tail -20 # retries that recovered, lost /auth
33
+ grep -h ' <- \| -> ' D/logs/$(date -u +%F).log | tail # posts out, your messages in
34
+ ```
35
+
36
+ | Line | Means |
37
+ |---|---|
38
+ | `<- S/tN: 3 items (claude-code, live card)` | posted to a group, into the live card |
39
+ | `<- S/tN: blocked (4 options)` | a dialog card went out |
40
+ | `-> S/tN: picked 1. Yes` | a card button was pressed |
41
+ | `-> S/tN: <text>` | your message was typed into the terminal |
42
+ | `turn S/tN: done` | hc said a turn ended |
43
+ | `acting as you: on` / `off` | `/auth` token usable / lost |
44
+ | `uncaught:` + stack | the bot crashed; it was started again |
45
+
46
+ ## State
47
+
48
+ `D/state.json` (never edit it while the bot runs: `herdrchat stop` first,
49
+ `herdrchat start` after):
50
+
51
+ | Key | Holds |
52
+ |---|---|
53
+ | `chats` | per terminal (`S|tN`, or `@machine|machine/tN`): group id, name, tags, label, muted, ended |
54
+ | `cursors` | per terminal: what was posted (ids, time), the open live card |
55
+ | `tags`, `labels`, `spaces` | ids of the bot tags, your labels, each space's avatar color |
56
+ | `overview`, `pinned`, `reacts` | the overview card, pinned groups, messages waiting for their OK |
57
+
58
+ Read it with a short `node -e` and print only what you need. `D/user-token.json` is
59
+ your token after `/auth`: never print it.
60
+
61
+ ## Did the change take effect
62
+
63
+ Check the live result, not only the code: count what actually happened in Feishu with
64
+ the bot's own client (`lib/feishu.mjs` `createClient` with `loadConfig()`), for example:
65
+
66
+ - mentions in a group since a time: `im.v1.message.list` on the chat, then each item's
67
+ `mentions` (a mention inside a card is not counted by Feishu, so it does not ring);
68
+ - a group's bot tags: `im.v2.bizEntityTagRelation.get` with `tag_biz_type: 'chat'`;
69
+ - whether a card was edited: `im.v1.message.get`, compare `update_time` with `create_time`.
70
+
71
+ Say which checks were run and which were not; "the code is there" is not "it works".
72
+
73
+ ## Usual problems
74
+
75
+ | You see | Likely cause | Do |
76
+ |---|---|---|
77
+ | nothing posts | bot down, or `FEISHU_OWNER_OPEN_ID` unset | check it runs; the log says "owner not set" |
78
+ | a button gives error 200340 | the card callback is not set up | `references/feishu.md`: events and card callbacks |
79
+ | 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 |
80
+ | the phone rings too much | not `/auth`ed, or settings | `/auth`; `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES` |
81
+ | a ⚠️ warning in the direct chat about acting as you | the token could not be refreshed | send `/auth` again |
82
+ | a group keeps an old name | its terminal is not live | it renames when the terminal is back |
83
+ | a Feishu call fails with 400 in a burst | a rate limit (tags: 5 a second) | space the calls; the bot queues tag calls |
84
+
85
+ ## Trying a Feishu API safely
86
+
87
+ 1. Read the official doc first (open.feishu.cn); write down the scope and limits.
88
+ 2. Make real calls only with this repo's own code (the SDK or `fetch`), never through a
89
+ third-party tool; reading another tool's docs or a dry run to learn a path is fine.
90
+ 3. Test in a throwaway group the bot creates for it (`zz-test ...`), never in a
91
+ terminal's group; label each test message ("Test 2/3: ...") so the user can tell
92
+ which one rang.
93
+ 4. Measure the result (an API field, a read-back) instead of assuming it; ask the user
94
+ only what only a phone shows (did it ring).
95
+ 5. Clean up in the same run: disband the test group, delete test labels and tags you
96
+ made, recall test messages, delete scratch files.
97
+
98
+ ## Before a commit
99
+
100
+ 1. `npm test` passes.
101
+ 2. Stage, then, as its own step, scan the staged files for anything real: session and
102
+ machine names, the app id (`cli_`), open_ids (`ou_`), chat ids (`oc_`), message ids
103
+ (`om_`), label ids (`ofg_`), the tenant's name, people's names, local paths. Read the
104
+ result before committing; never chain the scan and the commit in one command.
105
+ 3. Show the commit message (Conventional Commits; the body says why) and commit only
106
+ after the user's yes. No push without its own yes.