herdrchat 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +54 -2
- package/README.zh.md +53 -0
- package/bin/cli.mjs +140 -0
- package/bin/herdrchat.mjs +1260 -0
- package/config.env.example +17 -0
- package/docs/guide.md +198 -0
- package/lib/avatar.mjs +34 -0
- package/lib/config.mjs +93 -0
- package/lib/cursor.mjs +57 -0
- package/lib/events.mjs +95 -0
- package/lib/feishu.mjs +273 -0
- package/lib/hc.mjs +84 -0
- package/lib/home.mjs +26 -0
- package/lib/log.mjs +59 -0
- package/lib/menu.mjs +61 -0
- package/lib/overview.mjs +42 -0
- package/lib/readers/claude.mjs +119 -0
- package/lib/readers/index.mjs +24 -0
- package/lib/userauth.mjs +119 -0
- package/package.json +40 -4
- package/skills/README.md +9 -0
- package/skills/herdrchat/SKILL.md +45 -0
- package/skills/herdrchat/references/feishu.md +150 -0
- package/skills/herdrchat/references/ops.md +106 -0
|
@@ -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
|
+
}
|
package/lib/userauth.mjs
ADDED
|
@@ -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,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "herdrchat",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.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
|
+
"devDependencies": {
|
|
38
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
39
|
+
"@semantic-release/git": "^11.0.1",
|
|
40
|
+
"semantic-release": "^25.0.9"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/skills/README.md
ADDED
|
@@ -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.
|