herdrchat 1.4.0 → 1.5.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/README.md CHANGED
@@ -28,8 +28,9 @@ questions, send and receive images, and see all terminals in one overview. A new
28
28
  topic starts with the conversation so far, and your phone rings only when an agent needs
29
29
  you or is done (on Feishu once you have sent `/auth` to the bot; on Telegram right away).
30
30
 
31
- Claude Code is mirrored fully. Other agents show their screen when you type to them,
32
- until herdrchat learns to read their conversations too.
31
+ Claude Code and Codex are mirrored fully, their dialogs answered with buttons. Other
32
+ agents show their screen when you type to them, until herdrchat learns to read their
33
+ conversations too.
33
34
 
34
35
  ## You need
35
36
 
package/README.zh.md CHANGED
@@ -24,7 +24,7 @@ agent 干活时说的话实时可见,它问你的问题点按钮就能回答
24
24
  新开的群或话题会先补上之前的对话;只有 agent 需要你、或者做完的时候手机才会响
25
25
  (飞书要先给 bot 发一次 `/auth`;Telegram 直接就是这样)。
26
26
 
27
- 目前 Claude Code 是完整同步的。其他 agent 在你给它发消息时会回一次屏幕内容,
27
+ 目前 Claude Code 和 Codex 是完整同步的,它们的对话框都能点按钮回答。其他 agent 在你给它发消息时会回一次屏幕内容,
28
28
  等 herdrchat 学会读它们的对话记录以后,也会完整同步。
29
29
 
30
30
  ## 需要什么
@@ -2,7 +2,7 @@
2
2
 
3
3
  herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu and
4
4
  Telegram now, Slack and Discord later) and **agents**, each read by its reader (Claude
5
- Code now, then codex and pi). The core between them knows neither. This page says where everything is and
5
+ Code and Codex now, then pi). The core between them knows neither. This page says where everything is and
6
6
  how to add one of each.
7
7
 
8
8
  ## The parts
package/docs/guide.md CHANGED
@@ -345,8 +345,12 @@ before it is started again. Tokens and secrets are never logged. The skill's
345
345
  ## Limits
346
346
 
347
347
  - Narration blocks are a condensed version of the text Claude showed, not the exact words.
348
- - Only Claude Code has a reader so far. codex, pi and the rest post nothing by themselves
348
+ - Claude Code and Codex have readers so far. pi and the rest post nothing by themselves
349
349
  until each has a reader; what you type to them gets their screen in reply.
350
+ - Codex: its log is found as the one written in its folder since it started (Codex names
351
+ no pid outside its database), so two Codex sessions started in the same folder at the
352
+ same moment could be told apart wrongly. Its questions with choices come as text: answer
353
+ by typing. A group is named after Codex's thread name once it has one.
350
354
  - Only text, images and rich text are passed on; files, stickers and the rest are not.
351
355
  - An image that fails to upload three times is not sent; the group says so and why.
352
356
  - Feishu only, not Lark (the international version) yet: the bot talks to open.feishu.cn.
@@ -48,10 +48,26 @@ function card(markdown) {
48
48
  return JSON.stringify({ schema: '2.0', body: { elements: [{ tag: 'markdown', content: markdown }] } });
49
49
  }
50
50
 
51
+ // A card's header: its title with Feishu's own icon for what the card is, in place of the
52
+ // emoji the core leads a title with (the icon says it once), and the color of its tone.
53
+ export const TONES = {
54
+ working: { template: 'blue', icon: 'loading_outlined' },
55
+ closed: { template: 'grey', icon: 'done_outlined' },
56
+ needs: { template: 'orange', icon: 'warning_outlined' },
57
+ done: { template: 'grey', icon: 'done_outlined' },
58
+ good: { template: 'green', icon: 'done_outlined' },
59
+ bad: { template: 'red', icon: 'warning_outlined' },
60
+ };
61
+ const LEADING_EMOJI = /^(?:[\p{Extended_Pictographic}\u2600-\u27BF][\uFE0F\u200D]*\s*)+/u;
62
+ export function header(title, tone) {
63
+ const t = TONES[tone] || TONES.working;
64
+ return { title: { tag: 'plain_text', content: String(title).replace(LEADING_EMOJI, '') || String(title) }, template: t.template, icon: { tag: 'standard_icon', token: t.icon, color: t.template } };
65
+ }
66
+
51
67
  // A markdown card that can be edited after it is sent (updateCard needs update_multi
52
68
  // on the card both before and after).
53
- export function textCard(markdown, title, template) {
54
- return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template }, body: { elements: [{ tag: 'markdown', content: markdown }] } };
69
+ export function textCard(markdown, title, tone = 'working') {
70
+ return { schema: '2.0', config: { update_multi: true }, header: header(title, tone), body: { elements: [{ tag: 'markdown', content: markdown }] } };
55
71
  }
56
72
 
57
73
  export async function updateCard(client, messageId, cardJson) {
@@ -99,9 +115,17 @@ const BUTTON_TEXT = 80;
99
115
 
100
116
  function button(text, value, type = 'default') {
101
117
  const short = text.length > BUTTON_TEXT ? `${text.slice(0, BUTTON_TEXT - 1)}…` : text;
102
- return { tag: 'button', type, width: 'fill', text: { tag: 'plain_text', content: short }, behaviors: [{ type: 'callback', value }] };
118
+ return { tag: 'button', type, width: 'default', size: 'medium', text: { tag: 'plain_text', content: short }, hover_tips: { tag: 'plain_text', content: text }, behaviors: [{ type: 'callback', value }] };
103
119
  }
104
120
 
121
+ // Buttons side by side, wrapping onto the next line when the screen is narrow.
122
+ const row = (buttons) => ({ tag: 'column_set', flex_mode: 'flow', horizontal_spacing: 'small', columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })) });
123
+ // Short options (Yes / No) sit side by side; a long one would be cut there, so then each
124
+ // option is a line of its own, as wide as its words, at the left like the text above.
125
+ const SIDE_BY_SIDE = { most: 4, chars: 14 };
126
+ const fitsSideBySide = (labels) => labels.length <= SIDE_BY_SIDE.most && labels.every((l) => [...l].length <= SIDE_BY_SIDE.chars);
127
+ const options = (buttons, labels) => (fitsSideBySide(labels) ? [row(buttons)] : buttons);
128
+
105
129
  // A multi-select's boxes are checkers in a form: with no behaviors a checker is ticked on
106
130
  // the phone only, so ticking makes no call and shows no loading; Next submits the form,
107
131
  // and each box comes back in form_value under its name, box_<n> (ticksOf reads them).
@@ -109,16 +133,18 @@ export const BOX = 'box_';
109
133
  export function menuCard({ title, markdown, menu, value }) {
110
134
  const elements = [{ tag: 'markdown', content: markdown }];
111
135
  const boxes = [];
136
+ const choices = [];
112
137
  for (const o of menu.options) {
113
138
  if (o === menu.free) continue;
114
139
  if (menu.multi && o.checked !== null) {
115
140
  boxes.push({ tag: 'checker', name: `${BOX}${o.n}`, checked: o.checked, text: { tag: 'plain_text', content: `${o.n}. ${o.label}` } });
116
141
  continue;
117
142
  }
118
- elements.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
143
+ choices.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary_filled' : 'default'));
119
144
  }
145
+ if (choices.length) elements.push(...options(choices, choices.map((b) => b.text.content)));
120
146
  if (menu.multi) {
121
- elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, 'primary'), name: 'next', form_action_type: 'submit' }] });
147
+ elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, 'primary_filled'), name: 'next', form_action_type: 'submit' }] });
122
148
  }
123
149
  if (menu.free) {
124
150
  elements.push({
@@ -126,17 +152,24 @@ export function menuCard({ title, markdown, menu, value }) {
126
152
  name: 'free',
127
153
  elements: [
128
154
  { tag: 'input', name: 'answer', required: true, placeholder: { tag: 'plain_text', content: menu.free.label } },
129
- { ...button('Send', { ...value, pick: menu.free.n, label: menu.free.label }, 'primary'), name: 'send', form_action_type: 'submit' },
155
+ { ...button('Send', { ...value, pick: menu.free.n, label: menu.free.label }, 'primary_filled'), name: 'send', form_action_type: 'submit' },
130
156
  ],
131
157
  });
132
158
  }
133
- elements.push(button('Esc (cancel)', { ...value, pick: 'esc' }, 'danger'));
134
- return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'red' }, body: { elements } };
159
+ elements.push(row([button('Esc (cancel)', { ...value, pick: 'esc' }, 'danger_text')]));
160
+ return { schema: '2.0', config: { update_multi: true }, header: header(title, 'needs'), body: { elements } };
135
161
  }
136
162
 
137
163
  // The card once it is answered: the same text, no buttons, and what was done.
164
+ // A dialog once answered (or gone): grey, its text, and what was done in small type.
138
165
  export function doneCard(title, markdown, done) {
139
- return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'grey' }, body: { elements: [{ tag: 'markdown', content: `${markdown}\n\n**${done}**` }] } };
166
+ return {
167
+ schema: '2.0',
168
+ config: { update_multi: true },
169
+ header: header(title, 'done'),
170
+ // the icon is the check: one the core leads `done` with goes
171
+ body: { elements: [{ tag: 'markdown', content: markdown }, { tag: 'markdown', content: String(done).replace(/^[✔✓]\uFE0F?\s*/u, ''), text_size: 'notation', icon: { tag: 'standard_icon', token: 'done_outlined', color: 'grey' } }] },
172
+ };
140
173
  }
141
174
 
142
175
  // Takes back a message the bot sent.
@@ -184,8 +217,16 @@ export async function downloadImage(client, messageId, imageKey, file) {
184
217
 
185
218
  // What a received message says: its text and the keys of its images, in order, for
186
219
  // text, image and rich text (post) messages; null for any other kind.
220
+ // null for what is not passed on: another type (a file, forwarded chat records, ...), or a
221
+ // content that is not JSON (a merged forward lists as "Merged and Forwarded Message").
187
222
  export function messageParts(messageType, content) {
188
- const c = JSON.parse(content);
223
+ let c;
224
+ try {
225
+ c = JSON.parse(content);
226
+ } catch {
227
+ return null;
228
+ }
229
+ if (!c || typeof c !== 'object') return null;
189
230
  if (messageType === 'text') return { text: c.text || '', images: [] };
190
231
  if (messageType === 'image') return { text: '', images: [c.image_key] };
191
232
  if (messageType !== 'post') return null;
@@ -1,28 +1,23 @@
1
- // herdrchat's Feishu side: one group per tab (a "room" to the core), the direct chat with
2
- // the bot for the overview and commands, and what only Feishu has: tenant tags, group
3
- // avatars, and, after /auth, acting as you (muted groups, your labels, pins, @ mentions).
4
- // The core (lib/core/engine.mjs) calls only what is returned here; see "The chat side"
5
- // there for what each call means.
6
- import { ticksOf, createClient, bindTag, chunk, createChat, createTag, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, renameChat, replyMention, sendCard, sendFile, sendMarkdown, setChatAvatar, startEvents, tagName, textCard, unbindTag, updateCard, uploadImage } from './client.mjs';
7
- import { COLORS, avatarPng, letterOf } from './avatar.mjs';
1
+ // herdrchat's Feishu side: the rooms (rooms/: how tabs are laid out in groups, and what a
2
+ // group wears), the direct chat with the bot for the overview and commands, and, after
3
+ // /auth, acting as you (muted groups, your labels, pins, @ mentions). The core
4
+ // (lib/core/engine.mjs) calls only what is returned here; see "The chat side" there for
5
+ // what each call means.
6
+ import { ticksOf, createClient, chunk, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, sendCard, sendFile, sendMarkdown, startEvents, textCard, updateCard, uploadImage } from './client.mjs';
8
7
  import { overviewCard, overviewText } from './overview.mjs';
9
8
  import { createUserAuth } from './userauth.mjs';
9
+ import { createDecor } from './rooms/decor.mjs';
10
+ import { createGroups } from './rooms/groups.mjs';
10
11
 
11
- const ENDED_TAG = '#ended';
12
- const ENDED_LABEL = 'ended';
13
12
  const YOU_CHECK_MS = 60 * 1000;
14
13
  const OVERVIEW_TRIES = 3;
15
14
  const WORKING_EMOJI = 'THINKING';
16
15
  const DONE_EMOJI = 'OK';
17
- const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
18
16
 
19
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
18
  // What to do after an edit to config.env, said the way this profile is run.
21
19
  const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
22
20
 
23
- const inSpaces = (set, space) => set.all || set.spaces.includes(space);
24
- const spaceOfName = (name) => (name || '').split(' / ')[0];
25
-
26
21
  // cfg: loadConfig(); store: {read, write} of state.json; lane(k, label, fn): runs fn in
27
22
  // the room's lane, after what is queued for it; scopeOf(k): the session or machine a
28
23
  // room's terminal belongs to (its tag and label).
@@ -33,110 +28,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
33
28
  const userAuth = createUserAuth(cfg, stateDir);
34
29
  const { read, write } = store;
35
30
 
36
- function setRoom(k, fields) {
37
- const state = read();
38
- if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
39
- write(state);
40
- }
41
-
42
- // Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
43
- // by the first lane that needs it; the others wait for that.
44
- const making = new Map();
45
- function once(id, make) {
46
- if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
47
- return making.get(id);
48
- }
49
-
50
- // ---------- tags and avatars ----------
51
-
52
- // One tenant tag per session or machine, made once, and one "ended" tag; their ids are
53
- // kept in state.json (tags).
54
- async function tagOf(id, name = id) {
55
- return read().tags?.[id] || once(`tag ${id}`, async () => {
56
- const tag = await createTag(client, name);
57
- const state = read();
58
- state.tags = { ...(state.tags || {}), [id]: tag };
59
- write(state);
60
- log(`created tag ${name} (${tag})`);
61
- return tag;
62
- });
63
- }
64
-
65
- // Each space gets an avatar: its first letter on a color of its own (the first color no
66
- // other space has), kept in state.json (spaces), so all of its groups look alike, as in
67
- // herdr-app. The color is picked and saved before the first await, so spaces made side
68
- // by side never pick the same one. A failure is logged and tried again at the next check.
69
- function colorOf(space) {
70
- const state = read();
71
- const spaces = state.spaces || {};
72
- if (spaces[space]?.color) return spaces[space].color;
73
- const used = Object.values(spaces).map((x) => x.color);
74
- const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
75
- state.spaces = { ...spaces, [space]: { color } };
76
- write(state);
77
- return color;
78
- }
79
-
80
- async function avatarOf(space) {
81
- return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
82
- const color = colorOf(space);
83
- const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
84
- const state = read();
85
- state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
86
- write(state);
87
- log(`made avatar for space ${space} (${color})`);
88
- return image;
89
- });
90
- }
91
-
92
- // the space's avatar; a no-op when the group has it, so it follows a space that changes
93
- async function avatarRoom(k, space) {
94
- const c = read().chats[k];
95
- if (!c) return;
96
- try {
97
- const image = await avatarOf(space);
98
- if (c.avatar === image) return;
99
- await setChatAvatar(client, c.chat, image);
100
- setRoom(k, { avatar: image });
101
- } catch (err) {
102
- logError(`avatar ${space} on ${c.chat}: ${err.message}`);
103
- }
104
- }
105
-
106
- // Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
107
- // its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
108
- // takes off the ones already on. A failure is logged and tried again at the next check.
109
- async function syncBotTags(k, scope) {
110
- const c = read().chats[k];
111
- if (!c) return;
112
- try {
113
- if (cfg.botTags) {
114
- const tag = await tagOf(scope);
115
- if (c.tag !== tag) {
116
- await bindTag(client, tag, c.chat);
117
- setRoom(k, { tag });
118
- }
119
- if (Boolean(c.ended) !== Boolean(c.endedTag)) {
120
- const ended = await tagOf(ENDED_TAG, 'ended');
121
- await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
122
- setRoom(k, { endedTag: Boolean(c.ended) });
123
- }
124
- } else {
125
- const tags = read().tags || {};
126
- if (c.tag) {
127
- await unbindTag(client, c.tag, c.chat).catch(() => {});
128
- setRoom(k, { tag: null });
129
- }
130
- if (c.endedTag && tags[ENDED_TAG]) {
131
- await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
132
- setRoom(k, { endedTag: false });
133
- }
134
- }
135
- } catch (err) {
136
- logError(`bot tags on ${c.name}: ${err.message}`);
137
- }
138
- }
139
-
140
31
  // ---------- acting as you (optional, after /auth) ----------
141
32
 
142
33
  // With your token (userauth.mjs) the bot does what only you can: mutes its groups,
@@ -153,10 +44,17 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
153
44
  if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
154
45
  youCheckedAt = Date.now();
155
46
  const was = you;
156
- you = await userAuth.allowed();
47
+ try {
48
+ you = await userAuth.allowed();
49
+ } catch (err) {
50
+ // not known (Feishu not reached): it stays as it was, and is asked again soon
51
+ youCheckedAt = Date.now() - YOU_CHECK_MS + 10 * 1000;
52
+ logWarn(`acting as you: not checked (${err.message}); it stays ${you ? 'on' : 'off'}`);
53
+ return you;
54
+ }
157
55
  if (you && !was) {
158
56
  log('acting as you: on');
159
- setTimeout(alignAll, 0);
57
+ setTimeout(() => rooms.alignAll(), 0);
160
58
  } else if (!you && was) {
161
59
  logWarn('acting as you: off');
162
60
  await tellOwner({ title: '⚠️ Needs you', tone: 'needs', markdown: 'The bot can no longer act as you (mute, labels, pin). Send `/auth` to allow it again.' }).catch(() => {});
@@ -164,112 +62,15 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
164
62
  return you;
165
63
  }
166
64
 
167
- async function labelOf(name) {
168
- return read().labels?.[name] || once(`label ${name}`, async () => {
169
- const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
170
- const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
171
- if (!id) throw new Error(`no label ${name}`);
172
- const state = read();
173
- state.labels = { ...(state.labels || {}), [name]: id };
174
- write(state);
175
- log(`label ${name} (${id})`);
176
- return id;
177
- });
178
- }
179
-
180
- // A label you deleted is made again at the next try.
181
- async function intoLabel(name, chat) {
182
- try {
183
- await userAuth.addToFeedGroup(await labelOf(name), [chat]);
184
- } catch (err) {
185
- const state = read();
186
- if (state.labels) delete state.labels[name];
187
- write(state);
188
- throw err;
189
- }
190
- }
191
-
192
- // Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
193
- // the terminal is gone; off takes the group out of it (the label itself is yours).
194
- async function syncUserLabel(k, scope) {
195
- if (!(await asYou())) return;
196
- const c = read().chats[k];
197
- if (!c) return;
198
- const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
199
- if ((c.label || null) === want) return;
200
- try {
201
- if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
202
- if (want) await intoLabel(want, c.chat);
203
- setRoom(k, { label: want });
204
- } catch (err) {
205
- logError(`as you, label of ${c.name}: ${err.message}`);
206
- }
207
- }
208
-
209
- // HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
210
- // in config.env is applied both ways at the next start.
211
- async function muteRoom(k, space) {
212
- const c = read().chats[k];
213
- const want = inSpaces(cfg.muted, space);
214
- if (!c || Boolean(c.muted) === want) return;
215
- try {
216
- await userAuth.setMuted([c.chat], want);
217
- setRoom(k, { muted: want });
218
- } catch (err) {
219
- logError(`as you, mute ${c.chat}: ${err.message}`);
220
- }
221
- }
222
-
223
- // Everything at once, when acting as you turns on: live groups to their labels, ended
224
- // ones to "ended".
225
- function alignAll() {
226
- for (const [k, c] of Object.entries(read().chats)) {
227
- if (!cfg.sessions.includes(c.session)) continue;
228
- lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
229
- }
230
- }
231
-
232
65
  // ---------- rooms ----------
233
66
 
234
- // One group per tab, with you in it; what you type there goes to its terminal.
235
- async function openRoom({ name, intro }) {
236
- return createChat(client, cfg.ownerOpenId, name, intro);
237
- }
238
-
239
- async function renameRoom(room, name) {
240
- await renameChat(client, room, name);
241
- }
242
-
243
- // Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
244
- // and in your label. Cheap when nothing is left to do.
245
- async function placeRoom(k, { space }) {
246
- await avatarRoom(k, space);
247
- await syncBotTags(k, scopeOf(k));
248
- if (!(await asYou())) return;
249
- await muteRoom(k, space);
250
- await syncUserLabel(k, scopeOf(k));
251
- }
252
-
253
- // A group whose terminal is gone keeps its history, tagged "ended".
254
- async function endRoom(k) {
255
- const c = read().chats[k];
256
- if (!c) return;
257
- await syncBotTags(k, scopeOf(k));
258
- if (!c.avatar) await avatarRoom(k, spaceOfName(c.name));
259
- if (!(await asYou())) return;
260
- await muteRoom(k, spaceOfName(c.name));
261
- await syncUserLabel(k, scopeOf(k));
262
- }
263
-
264
- async function reviveRoom(k) {
265
- await syncBotTags(k, scopeOf(k));
266
- await syncUserLabel(k, scopeOf(k));
267
- }
67
+ const decor = createDecor({ client, cfg, store, log, logError, userAuth, asYou });
68
+ const rooms = createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor });
268
69
 
269
70
  // ---------- posting ----------
270
71
 
271
72
  const post = (room, markdown) => sendMarkdown(client, room, markdown);
272
- const card = ({ title, tone, markdown }) => textCard(markdown, title, TONES[tone] || 'blue');
73
+ const card = ({ title, tone, markdown }) => textCard(markdown, title, tone);
273
74
 
274
75
  async function postCard(room, look) {
275
76
  const res = await sendCard(client, room, card(look));
@@ -289,43 +90,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
289
90
  await updateCard(client, message, doneCard(dialog.doneTitle, dialog.markdown, done));
290
91
  }
291
92
 
292
- // The group that needs you is pinned while it waits (acting as you), and unpinned when
293
- // it no longer does; which ones are pinned is kept in state.json (pinned).
294
- async function pin(k, room) {
295
- if (!(await asYou())) return;
296
- try {
297
- await userAuth.pin(room);
298
- const state = read();
299
- state.pinned = { ...(state.pinned || {}), [k]: room };
300
- write(state);
301
- } catch (err) {
302
- logError(`pin ${room}: ${err.message}`);
303
- }
304
- }
305
-
306
- async function unpin(k) {
307
- const room = read().pinned?.[k];
308
- if (!room) return;
309
- try {
310
- await userAuth.unpin(room);
311
- } catch (err) {
312
- logError(`unpin ${room}: ${err.message}`);
313
- }
314
- const state = read();
315
- delete state.pinned[k];
316
- write(state);
317
- }
318
-
319
- const pinned = () => Object.keys(read().pinned || {});
320
-
321
- // In a muted group only a mention rings; a group that is not muted rings anyway. A
322
- // space in HERDR_SILENT_SPACES gets no mention at all.
323
- async function ring(k, message, text) {
324
- const c = read().chats[k];
325
- if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
326
- await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
327
- }
328
-
329
93
  async function addReaction(message, emoji) {
330
94
  const res = await client.im.v1.messageReaction.create({ path: { message_id: message }, data: { reaction_type: { emoji_type: emoji } } });
331
95
  return res?.data?.reaction_id;
@@ -432,7 +196,14 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
432
196
  seen.add(msg.message_id);
433
197
  if (seen.size > 2000) seen.delete(seen.values().next().value);
434
198
  queued(msg.chat_id, async () => {
435
- await handleMessage(data);
199
+ // whatever goes wrong, you are told it did not get through, and it is not tried
200
+ // again at every connection
201
+ try {
202
+ await handleMessage(data);
203
+ } catch (err) {
204
+ logError(`feishu message ${msg.message_id} in ${msg.chat_id}:`, err);
205
+ if (data.sender?.sender_id?.open_id === cfg.ownerOpenId) await reply(msg.chat_id, `⚠️ Not delivered: it could not be handled (${err.message}). Send it again; \`herdrchat logs\` has the details.`).catch(() => {});
206
+ }
436
207
  markRead(msg.chat_id, Number(msg.create_time) || Date.now());
437
208
  });
438
209
  };
@@ -455,12 +226,12 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
455
226
  }
456
227
  const parts = messageParts(msg.message_type, msg.content);
457
228
  if (!parts) {
458
- await reply(msg.chat_id, 'Only text and images are passed on.');
229
+ await reply(msg.chat_id, `Not delivered: only text and images are passed on, not this (${msg.message_type}). Copy its text into a message.`);
459
230
  return;
460
231
  }
461
232
  const text = parts.text.replace(/@_user_\d+/g, '').trim();
462
233
  if (!text && !parts.images.length) return;
463
- await onMessage({ room: msg.chat_type === 'p2p' ? null : msg.chat_id, where: msg.chat_id, message: msg.message_id, text, images: parts.images });
234
+ await onMessage({ room: rooms.roomOf(msg), where: msg.chat_id, message: msg.message_id, text, images: parts.images });
464
235
  };
465
236
  // A click must be answered within 3 seconds; the core's answer becomes Feishu's
466
237
  // callback response: a toast and, when the card changes, its new form.
@@ -487,7 +258,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
487
258
  const catchUp = async () => {
488
259
  if (!cfg.ownerOpenId) return;
489
260
  const state = read();
490
- const chats = new Set([...Object.values(state.chats || {}).filter((c) => !c.ended).map((c) => c.chat), ...Object.keys(state.feishuRead || {})]);
261
+ const chats = new Set([...rooms.roomChats(), ...Object.keys(state.feishuRead || {})]);
491
262
  for (const chatId of chats) {
492
263
  const since = state.feishuRead?.[chatId];
493
264
  if (!since) {
@@ -498,7 +269,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
498
269
  const res = await client.im.v1.message.list({ params: { container_id_type: 'chat', container_id: chatId, start_time: String(Math.floor(since / 1000)), sort_type: 'ByCreateTimeAsc', page_size: 50 } });
499
270
  const missed = (res?.data?.items || []).filter((m) => Number(m.create_time) > since && m.sender?.sender_type === 'user' && m.sender?.id === cfg.ownerOpenId && !seen.has(m.message_id));
500
271
  if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
501
- const p2p = !Object.values(state.chats || {}).some((c) => c.chat === chatId);
272
+ const p2p = !rooms.isRoomChat(chatId);
502
273
  for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group', message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
503
274
  } catch (err) {
504
275
  logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
@@ -544,25 +315,25 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
544
315
  name: 'feishu',
545
316
  ready: () => Boolean(cfg.ownerOpenId),
546
317
  help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
547
- roomWord: 'group',
318
+ roomWord: rooms.roomWord,
548
319
  liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
549
320
  split: (text) => chunk(text),
550
321
  start,
551
322
  command,
552
- openRoom,
553
- renameRoom,
554
- placeRoom,
555
- endRoom,
556
- reviveRoom,
323
+ openRoom: rooms.openRoom,
324
+ renameRoom: rooms.renameRoom,
325
+ placeRoom: rooms.placeRoom,
326
+ endRoom: rooms.endRoom,
327
+ reviveRoom: rooms.reviveRoom,
557
328
  post,
558
329
  postCard,
559
330
  editCard,
560
331
  postDialog,
561
332
  closeDialog,
562
- pin,
563
- unpin,
564
- pinned,
565
- ring,
333
+ pin: rooms.pin,
334
+ unpin: rooms.unpin,
335
+ pinned: rooms.pinned,
336
+ ring: rooms.ring,
566
337
  markWorking,
567
338
  markDone,
568
339
  image,
@@ -1,15 +1,17 @@
1
1
  // The overview: one card in the direct chat with the bot, as herdr-app's sidebar shows it.
2
- // Every terminal is a line: its state, its group (a link that opens it), what it waits on
3
- // and since when. What waits for you comes first. The card is edited in place, so it
4
- // never rings.
2
+ // Every terminal is a line: its state as a colored tag, its group (a link that opens it),
3
+ // what it waits on and since when. What waits for you
4
+ // comes first. The card is edited in place, so it never rings.
5
+ import { header } from './client.mjs';
6
+
5
7
  const STATES = {
6
- blocked: { icon: '⚠️', word: 'needs you', rank: 0 },
7
- error: { icon: '🔴', word: 'error', rank: 1 },
8
- working: { icon: '⌛️', word: 'working', rank: 2 },
9
- done: { icon: '✅', word: 'done', rank: 3 },
10
- idle: { icon: '❓', word: 'idle', rank: 4 },
8
+ blocked: { color: 'orange', word: 'needs you', rank: 0 },
9
+ error: { color: 'red', word: 'error', rank: 1 },
10
+ working: { color: 'blue', word: 'working', rank: 2 },
11
+ done: { color: 'green', word: 'done', rank: 3 },
12
+ idle: { color: 'neutral', word: 'idle', rank: 4 },
11
13
  };
12
- const OTHER = { icon: '❓', word: '', rank: 5 };
14
+ const OTHER = { color: 'neutral', word: '', rank: 5 };
13
15
 
14
16
  const chatLink = (chat) => `https://applink.feishu.cn/client/chat/open?openChatId=${chat}`;
15
17
  const clock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
@@ -24,19 +26,19 @@ export function overviewText(rows) {
24
26
  const s = STATES[r.state] || OTHER;
25
27
  const name = r.room ? `[${escape(r.name)}](${chatLink(r.room)})` : escape(r.name);
26
28
  const word = s === OTHER ? r.program || 'no status' : `${s.word}${r.state === 'blocked' && r.kind ? ` (${r.kind})` : ''}`;
27
- return `${s.icon} **${name}** · ${escape(word)}${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
29
+ return `<text_tag color='${s.color}'>${escape(word)}</text_tag> **${name}**${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
28
30
  }).join('\n');
29
31
  }
30
32
 
31
33
  export function overviewCard(rows, now) {
32
34
  const waiting = rows.filter((r) => r.state === 'blocked').length;
33
35
  const working = rows.filter((r) => r.state === 'working').length;
34
- const template = waiting ? 'orange' : working ? 'blue' : 'green';
35
- const title = `${waiting ? `⚠️ ${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals`;
36
+ const tone = waiting ? 'needs' : working ? 'working' : 'good';
37
+ const title = `${waiting ? `${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals`;
36
38
  return {
37
39
  schema: '2.0',
38
40
  config: { update_multi: true },
39
- header: { title: { tag: 'plain_text', content: title }, template },
40
- body: { elements: [{ tag: 'markdown', content: `${overviewText(rows)}\n\n<font color='grey'>Updated ${clock(now)}</font>` }] },
41
+ header: header(title, tone),
42
+ body: { elements: [{ tag: 'markdown', content: overviewText(rows) }, { tag: 'markdown', content: `Updated ${clock(now)}`, text_size: 'notation' }] },
41
43
  };
42
44
  }
@@ -0,0 +1,174 @@
1
+ // What a Feishu group wears (groups.mjs makes one per tab): the space's avatar, the bot
2
+ // tags, and, acting as you, your label and its mute. Each call takes an `entry`, {get() -> record, set(fields)}: the record
3
+ // of the group in state.json, {chat, name, avatar, tag, ended, endedTag, label, muted}.
4
+ import { bindTag, createTag, setChatAvatar, tagName, unbindTag, uploadImage } from '../client.mjs';
5
+ import { COLORS, avatarPng, letterOf } from '../avatar.mjs';
6
+
7
+ const ENDED_TAG = '#ended';
8
+ const ENDED_LABEL = 'ended';
9
+
10
+ export const inSpaces = (set, space) => set.all || set.spaces.includes(space);
11
+ export const spaceOfName = (name) => (name || '').split(' / ')[0];
12
+
13
+ // asYou() -> whether the bot may act as you now (index.mjs keeps that)
14
+ export function createDecor({ client, cfg, store, log, logError, userAuth, asYou }) {
15
+ const { read, write } = store;
16
+
17
+ // Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
18
+ // by the first lane that needs it; the others wait for that.
19
+ const making = new Map();
20
+ function once(id, make) {
21
+ if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
22
+ return making.get(id);
23
+ }
24
+
25
+ // ---------- tags and avatars ----------
26
+
27
+ // One tenant tag per session or machine, made once, and one "ended" tag; their ids are
28
+ // kept in state.json (tags).
29
+ async function tagOf(id, name = id) {
30
+ return read().tags?.[id] || once(`tag ${id}`, async () => {
31
+ const tag = await createTag(client, name);
32
+ const state = read();
33
+ state.tags = { ...(state.tags || {}), [id]: tag };
34
+ write(state);
35
+ log(`created tag ${name} (${tag})`);
36
+ return tag;
37
+ });
38
+ }
39
+
40
+ // Each space gets an avatar: its first letter on a color of its own (the first color no
41
+ // other space has), kept in state.json (spaces), so all of its groups look alike, as in
42
+ // herdr-app. The color is picked and saved before the first await, so spaces made side
43
+ // by side never pick the same one. A failure is logged and tried again at the next check.
44
+ function colorOf(space) {
45
+ const state = read();
46
+ const spaces = state.spaces || {};
47
+ if (spaces[space]?.color) return spaces[space].color;
48
+ const used = Object.values(spaces).map((x) => x.color);
49
+ const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
50
+ state.spaces = { ...spaces, [space]: { color } };
51
+ write(state);
52
+ return color;
53
+ }
54
+
55
+ async function avatarOf(space) {
56
+ return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
57
+ const color = colorOf(space);
58
+ const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
59
+ const state = read();
60
+ state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
61
+ write(state);
62
+ log(`made avatar for space ${space} (${color})`);
63
+ return image;
64
+ });
65
+ }
66
+
67
+ // the space's avatar; a no-op when the group has it, so it follows a space that changes
68
+ async function avatar(entry, space) {
69
+ const c = entry.get();
70
+ if (!c) return;
71
+ try {
72
+ const image = await avatarOf(space);
73
+ if (c.avatar === image) return;
74
+ await setChatAvatar(client, c.chat, image);
75
+ entry.set({ avatar: image });
76
+ } catch (err) {
77
+ logError(`avatar ${space} on ${c.chat}: ${err.message}`);
78
+ }
79
+ }
80
+
81
+ // Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
82
+ // its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
83
+ // takes off the ones already on. A failure is logged and tried again at the next check.
84
+ async function botTags(entry, scope) {
85
+ const c = entry.get();
86
+ if (!c) return;
87
+ try {
88
+ if (cfg.botTags) {
89
+ const tag = await tagOf(scope);
90
+ if (c.tag !== tag) {
91
+ await bindTag(client, tag, c.chat);
92
+ entry.set({ tag });
93
+ }
94
+ if (Boolean(c.ended) !== Boolean(c.endedTag)) {
95
+ const ended = await tagOf(ENDED_TAG, 'ended');
96
+ await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
97
+ entry.set({ endedTag: Boolean(c.ended) });
98
+ }
99
+ } else {
100
+ const tags = read().tags || {};
101
+ if (c.tag) {
102
+ await unbindTag(client, c.tag, c.chat).catch(() => {});
103
+ entry.set({ tag: null });
104
+ }
105
+ if (c.endedTag && tags[ENDED_TAG]) {
106
+ await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
107
+ entry.set({ endedTag: false });
108
+ }
109
+ }
110
+ } catch (err) {
111
+ logError(`bot tags on ${c.name}: ${err.message}`);
112
+ }
113
+ }
114
+
115
+ // ---------- acting as you ----------
116
+
117
+ async function labelOf(name) {
118
+ return read().labels?.[name] || once(`label ${name}`, async () => {
119
+ const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
120
+ const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
121
+ if (!id) throw new Error(`no label ${name}`);
122
+ const state = read();
123
+ state.labels = { ...(state.labels || {}), [name]: id };
124
+ write(state);
125
+ log(`label ${name} (${id})`);
126
+ return id;
127
+ });
128
+ }
129
+
130
+ // A label you deleted is made again at the next try.
131
+ async function intoLabel(name, chat) {
132
+ try {
133
+ await userAuth.addToFeedGroup(await labelOf(name), [chat]);
134
+ } catch (err) {
135
+ const state = read();
136
+ if (state.labels) delete state.labels[name];
137
+ write(state);
138
+ throw err;
139
+ }
140
+ }
141
+
142
+ // Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
143
+ // the terminal is gone; off takes the group out of it (the label itself is yours).
144
+ async function userLabel(entry, scope) {
145
+ if (!(await asYou())) return;
146
+ const c = entry.get();
147
+ if (!c) return;
148
+ const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
149
+ if ((c.label || null) === want) return;
150
+ try {
151
+ if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
152
+ if (want) await intoLabel(want, c.chat);
153
+ entry.set({ label: want });
154
+ } catch (err) {
155
+ logError(`as you, label of ${c.name}: ${err.message}`);
156
+ }
157
+ }
158
+
159
+ // HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
160
+ // in config.env is applied both ways at the next start.
161
+ async function mute(entry, space) {
162
+ const c = entry.get();
163
+ const want = inSpaces(cfg.muted, space);
164
+ if (!c || Boolean(c.muted) === want) return;
165
+ try {
166
+ await userAuth.setMuted([c.chat], want);
167
+ entry.set({ muted: want });
168
+ } catch (err) {
169
+ logError(`as you, mute ${c.chat}: ${err.message}`);
170
+ }
171
+ }
172
+
173
+ return { avatar, botTags, userLabel, mute };
174
+ }
@@ -0,0 +1,110 @@
1
+ // One Feishu group per herdr-app tab, with you in it; what you type there goes to its
2
+ // terminal. A room is the group's chat id. The group wears its space's avatar, its tags
3
+ // and, acting as you, its mute and your label (decor.mjs); the group that needs you is
4
+ // pinned. index.mjs calls only what is returned here.
5
+ import { createChat, renameChat, replyMention } from '../client.mjs';
6
+ import { inSpaces, spaceOfName } from './decor.mjs';
7
+
8
+ export function createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor }) {
9
+ const { read, write } = store;
10
+ const entryOf = (k) => ({
11
+ get: () => read().chats[k],
12
+ set: (fields) => {
13
+ const state = read();
14
+ if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
15
+ write(state);
16
+ },
17
+ });
18
+
19
+ async function openRoom({ name, intro }) {
20
+ return createChat(client, cfg.ownerOpenId, name, intro);
21
+ }
22
+
23
+ async function renameRoom(room, name) {
24
+ await renameChat(client, room, name);
25
+ }
26
+
27
+ // Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
28
+ // and in your label. Cheap when nothing is left to do.
29
+ async function placeRoom(k, { space }) {
30
+ const entry = entryOf(k);
31
+ await decor.avatar(entry, space);
32
+ await decor.botTags(entry, scopeOf(k));
33
+ if (!(await asYou())) return;
34
+ await decor.mute(entry, space);
35
+ await decor.userLabel(entry, scopeOf(k));
36
+ }
37
+
38
+ // A group whose terminal is gone keeps its history, tagged "ended".
39
+ async function endRoom(k) {
40
+ const entry = entryOf(k);
41
+ const c = entry.get();
42
+ if (!c) return;
43
+ await decor.botTags(entry, scopeOf(k));
44
+ if (!c.avatar) await decor.avatar(entry, spaceOfName(c.name));
45
+ if (!(await asYou())) return;
46
+ await decor.mute(entry, spaceOfName(c.name));
47
+ await decor.userLabel(entry, scopeOf(k));
48
+ }
49
+
50
+ async function reviveRoom(k) {
51
+ const entry = entryOf(k);
52
+ await decor.botTags(entry, scopeOf(k));
53
+ await decor.userLabel(entry, scopeOf(k));
54
+ }
55
+
56
+ // Everything at once, when acting as you turns on: live groups to their labels, ended
57
+ // ones to "ended".
58
+ function alignAll() {
59
+ for (const [k, c] of Object.entries(read().chats)) {
60
+ if (!cfg.sessions.includes(c.session)) continue;
61
+ lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
62
+ }
63
+ }
64
+
65
+ // The group that needs you is pinned while it waits (acting as you), and unpinned when
66
+ // it no longer does; which ones are pinned is kept in state.json (pinned).
67
+ async function pin(k, room) {
68
+ if (!(await asYou())) return;
69
+ try {
70
+ await userAuth.pin(room);
71
+ const state = read();
72
+ state.pinned = { ...(state.pinned || {}), [k]: room };
73
+ write(state);
74
+ } catch (err) {
75
+ logError(`pin ${room}: ${err.message}`);
76
+ }
77
+ }
78
+
79
+ async function unpin(k) {
80
+ const room = read().pinned?.[k];
81
+ if (!room) return;
82
+ try {
83
+ await userAuth.unpin(room);
84
+ } catch (err) {
85
+ logError(`unpin ${room}: ${err.message}`);
86
+ }
87
+ const state = read();
88
+ delete state.pinned[k];
89
+ write(state);
90
+ }
91
+
92
+ const pinned = () => Object.keys(read().pinned || {});
93
+
94
+ // In a muted group only a mention rings; a group that is not muted rings anyway. A
95
+ // space in HERDR_SILENT_SPACES gets no mention at all.
96
+ async function ring(k, message, text) {
97
+ const c = read().chats[k];
98
+ if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
99
+ await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
100
+ }
101
+
102
+ // A message, to the core's words: the room it came from (null: the direct chat).
103
+ const roomOf = (msg) => (msg.chat_type === 'p2p' ? null : msg.chat_id);
104
+ // The chats whose messages the bot reads (to look for what it missed), and whether a
105
+ // chat is one of them rather than the direct chat.
106
+ const roomChats = () => Object.values(read().chats || {}).filter((c) => !c.ended).map((c) => c.chat);
107
+ const isRoomChat = (chatId) => Object.values(read().chats || {}).some((c) => c.chat === chatId);
108
+
109
+ return { roomWord: 'group', openRoom, renameRoom, placeRoom, endRoom, reviveRoom, alignAll, pin, unpin, pinned, ring, roomOf, roomChats, isRoomChat };
110
+ }
@@ -107,7 +107,16 @@ export function createUserAuth(cfg, dir) {
107
107
  return {
108
108
  startDeviceFlow,
109
109
  waitForToken,
110
- allowed: async () => Boolean(await token().catch(() => null)),
110
+ // false only when there is no token or Feishu refused to refresh it; a call that did not
111
+ // get through (the network) throws, so a moment offline is not taken for a revoked /auth
112
+ allowed: async () => {
113
+ try {
114
+ return Boolean(await token());
115
+ } catch (err) {
116
+ if (/^token refresh:/.test(err.message)) return false;
117
+ throw err;
118
+ }
119
+ },
111
120
  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
121
  feedGroups: () => call('GET', '/open-apis/im/v1/groups?page_size=50&page_token='),
113
122
  createFeedGroup: (name) => call('POST', '/open-apis/im/v1/groups', { feed_group_creator: { name, type: 'normal' } }),
@@ -399,6 +399,9 @@ export function create({ cfg, store, log, logWarn, logError, claimOwner = () =>
399
399
  if (u.callback_query) await handleClick(u.callback_query, onAction);
400
400
  } catch (err) {
401
401
  logError(`telegram update ${u.update_id}:`, err);
402
+ // a message that could not be handled: you are told, never left guessing
403
+ const m = u.message;
404
+ if (m && String(m.from?.id || '') === owner) await reply(`${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`, `⚠️ Not delivered: it could not be handled (${err.message}). Send it again; \`herdrchat logs\` has the details.`).catch(() => {});
402
405
  }
403
406
  // saved once handled: a crash halfway handles it again, never skips it
404
407
  offset = u.update_id + 1;
@@ -19,7 +19,10 @@ function after(cursor, at, id) {
19
19
 
20
20
  function idOf(entry) {
21
21
  if (entry.uuid) return entry.uuid;
22
- return crypto.createHash('sha1').update(`${entry.type}|${entry.timestamp}|${entry.operation || ''}|${entry.content || ''}`).digest('hex');
22
+ // Codex keeps what an entry is in `payload` (an item has its own id): two entries in the
23
+ // same millisecond must not share an id, or the second would count as posted
24
+ const payload = entry.payload ? `|${entry.payload.item?.id || entry.payload.id || entry.payload.call_id || JSON.stringify(entry.payload).slice(0, 4000)}` : '';
25
+ return crypto.createHash('sha1').update(`${entry.type}|${entry.timestamp}|${entry.operation || ''}|${entry.content || ''}${payload}`).digest('hex');
23
26
  }
24
27
 
25
28
  // The items after `cursor` in log `lines`, in order, using `entryItems(entry)` (the
@@ -160,7 +160,9 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
160
160
  // dropped, so a spinner in a title never renames a room.
161
161
  function tabName(where, record) {
162
162
  if (!/^\d+$/.test(where.tab)) return where.tab;
163
- const title = String(record.title || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
163
+ // an agent whose title holds more than a name (Codex: "<thread> | <folder>") says which part
164
+ const titled = agentOf(record)?.titleOf?.(record.title) ?? record.title;
165
+ const title = String(titled || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
164
166
  const name = title || record.name || '';
165
167
  if (!name) return where.tab;
166
168
  return name.length > TITLE_MAX ? `${name.slice(0, TITLE_MAX - 1)}…` : name;
package/lib/core/home.mjs CHANGED
@@ -17,6 +17,16 @@ export async function homeFile(session, machine, rel, { tail } = {}) {
17
17
  return hc(session, ['exec', '--on', machine, ...argv], { timeoutMs: 30000 });
18
18
  }
19
19
 
20
+ // A short shell command in the home folder of the machine a terminal runs on: here, or
21
+ // through `hc exec --on <machine>`. Its output; empty when it fails.
22
+ export async function homeShell(session, machine, script) {
23
+ if (!machine) {
24
+ const { execFile } = await import('node:child_process');
25
+ return new Promise((resolve) => execFile('sh', ['-c', script], { cwd: os.homedir(), timeout: 30000 }, (err, stdout) => resolve(err && !stdout ? '' : String(stdout))));
26
+ }
27
+ return hc(session, ['exec', '--on', machine, 'sh', '-c', script], { timeoutMs: 30000 }).catch(() => '');
28
+ }
29
+
20
30
  // The items after `cursor` in a .jsonl log: its tail first, the whole file when the tail
21
31
  // does not reach back to the cursor.
22
32
  export async function logAfter(session, machine, rel, cursor, entryItems) {
package/lib/core/menu.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // The menu a blocked program shows on its screen, as Claude Code draws its permission
2
- // and question dialogs:
2
+ // and question dialogs (Codex draws its approvals alike, with › for the cursor, no border,
3
+ // and the dialog under the "• Running …" line of what it asks about):
3
4
  // ❯ 1. Yes a digit key picks an option
4
5
  // 2. [✔] Red in a multi-select, a digit toggles it; tab goes on
5
6
  // 3. Type something. picking it lets you type your own answer
@@ -20,7 +21,9 @@
20
21
  // nor of the key; a question's own left border (│) goes too.
21
22
  import crypto from 'node:crypto';
22
23
 
23
- const OPTION = /^\s*(?:❯\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\s*$/;
24
+ const OPTION = /^\s*(?:[❯›]\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\s*$/;
25
+ const CURSOR = /[❯›]/;
26
+ const ITEM = /^•\s/; // a Codex item, at the left edge: what its dialog asks about
24
27
  const RULE = /^[\s─╌━-]*$/;
25
28
  const BORDER = /^\s*─{10,}/;
26
29
  const FREE = /^Type something/i;
@@ -31,10 +34,14 @@ const ownPart = (line) => line.replace(BESIDE, '').replace(LEFT_BORDER, '$1');
31
34
 
32
35
  const TEXT_LINES = 15; // a dialog with no border found: this many lines above its options
33
36
 
37
+ // {top, framed}: the dialog's top border; with none above it, the nearest Codex item line
38
+ // (also a known edge); else TEXT_LINES above its options, not known as a dialog
34
39
  function topOf(lines, first) {
35
40
  let top = first;
36
41
  while (top > 0 && !BORDER.test(lines[top])) top -= 1;
37
- return BORDER.test(lines[top]) ? top : Math.max(0, first - TEXT_LINES);
42
+ if (BORDER.test(lines[top])) return { top, framed: true };
43
+ for (let i = first - 1; i >= 0; i -= 1) if (ITEM.test(lines[i])) return { top: i, framed: true };
44
+ return { top: Math.max(0, first - TEXT_LINES), framed: false };
38
45
  }
39
46
 
40
47
  const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
@@ -42,7 +49,7 @@ const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
42
49
  // Joined with spaces, not line breaks: the same dialog wraps at other places when the
43
50
  // terminal is resized (herdr-app opened on a phone), and it is still the same dialog.
44
51
  function keyOf(lines, top, last) {
45
- const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
52
+ const body = lines.slice(top, last + 1).map((l) => l.replace(/[❯›]/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
46
53
  return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
47
54
  }
48
55
 
@@ -65,16 +72,16 @@ export function menuOf(screen) {
65
72
  menu = run;
66
73
  });
67
74
  if (!menu) return null;
68
- const top = topOf(lines, menu.first);
75
+ const { top, framed } = topOf(lines, menu.first);
69
76
  // A question with previews takes no digit (its keys say "↑/↓ to navigate"): an option
70
77
  // is reached from the cursor (❯) with the arrows, then Enter.
71
78
  const beside = raw.slice(menu.first, menu.last + 1).some((l) => BESIDE.test(l));
72
- const cursor = menu.options.find((o, i) => raw.slice(menu.first, menu.last + 1).some((l) => /❯/.test(l) && new RegExp(`^\\s*❯\\s*${o.n}\\.`).test(l)))?.n || 1;
79
+ const cursor = menu.options.find((o) => raw.slice(menu.first, menu.last + 1).some((l) => CURSOR.test(l) && new RegExp(`^\\s*[❯›]\\s*${o.n}\\.`).test(l)))?.n || 1;
73
80
  return {
74
81
  beside,
75
82
  cursor,
76
83
  key: keyOf(lines, top, menu.last),
77
- framed: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
84
+ framed, // a dialog's known top; a numbered list in text has none
78
85
  text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
79
86
  options: menu.options,
80
87
  multi: menu.options.some((o) => o.checked !== null),
@@ -0,0 +1,134 @@
1
+ // Reader for Codex (hc status app "codex"): its own rollout log.
2
+ // Where: Codex writes each session to ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl,
3
+ // whose first line (session_meta) names its cwd. It does not keep the file open or
4
+ // name its pid anywhere outside its database, so the log is found as the one written
5
+ // since the terminal's codex started, in the cwd it runs in; with two such, the one
6
+ // whose session began nearest after that start (a resumed session began before it,
7
+ // and then the newest file wins). Found once per codex process.
8
+ // What becomes an item: the "item_completed" events, one per finished item, as Codex
9
+ // itself shows them:
10
+ // you UserMessage: what you typed
11
+ // ai AgentMessage: what it said; `phase` "commentary" while it works, "final_answer"
12
+ // at the end, which marks `end` (a question it asks the async way, with its
13
+ // choices in the text, is a final answer too, but the turn goes on after it)
14
+ // tool CommandExecution (a command it ran), FileChange (files it added, changed or
15
+ // removed), and any other item but reasoning, as one line
16
+ import path from 'node:path';
17
+ import { getTerminal, machineOf } from '../hc.mjs';
18
+ import { homeShell, logAfter } from '../home.mjs';
19
+ import { itemsAfter } from '../cursor.mjs';
20
+
21
+ export const app = 'codex';
22
+ export const processes = ['codex'];
23
+
24
+ const textOf = (content) => (Array.isArray(content) ? content.filter((c) => /^text$/i.test(c.type) && c.text).map((c) => c.text).join('\n') : '');
25
+ const short = (s, max = 120) => {
26
+ const one = String(s || '').replace(/\s+/g, ' ').trim();
27
+ return one.length > max ? `${one.slice(0, max - 3)}...` : one;
28
+ };
29
+
30
+ function commandOf(item) {
31
+ const argv = Array.isArray(item.command) ? item.command : [item.command];
32
+ // a shell's `-lc <command>` shows as the command itself
33
+ const at = argv.findIndex((a) => a === '-lc' || a === '-c');
34
+ return at >= 0 && argv[at + 1] ? argv[at + 1] : argv.join(' ');
35
+ }
36
+
37
+ function entryItems(entry) {
38
+ if (entry.type !== 'event_msg' || entry.payload?.type !== 'item_completed') return [];
39
+ const item = entry.payload.item || {};
40
+ switch (item.type) {
41
+ case 'UserMessage': {
42
+ const t = textOf(item.content).trim();
43
+ return t ? [{ role: 'you', text: t }] : [];
44
+ }
45
+ case 'AgentMessage': {
46
+ const t = textOf(item.content).trim();
47
+ return t ? [{ role: 'ai', text: t, end: item.phase === 'final_answer' && item.delivery !== 'async' }] : [];
48
+ }
49
+ case 'CommandExecution':
50
+ return [{ role: 'tool', text: `▸ exec: ${short(commandOf(item))}` }];
51
+ case 'FileChange': {
52
+ const changes = Object.entries(item.changes || {}).map(([file, c]) => `${c.type || 'change'} ${path.basename(file)}`);
53
+ return [{ role: 'tool', text: `▸ edit: ${short(changes.join(', ') || 'files')}` }];
54
+ }
55
+ default:
56
+ return item.type && !/reasoning/i.test(item.type) ? [{ role: 'tool', text: `▸ ${item.type}` }] : [];
57
+ }
58
+ }
59
+
60
+ export function conversationAfter(lines, cursor) {
61
+ return itemsAfter(lines, cursor, entryItems);
62
+ }
63
+
64
+ // "[[dd-]hh:]mm:ss", as ps prints how long a process has run
65
+ export function seconds(etime) {
66
+ const t = String(etime).trim();
67
+ const [days, rest] = t.includes('-') ? t.split('-') : ['0', t];
68
+ const parts = rest.split(':').map(Number);
69
+ while (parts.length < 3) parts.unshift(0);
70
+ return Number(days) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
71
+ }
72
+
73
+ // lines "file\t"cwd":"…"\t"timestamp":"…"" -> the log of the codex that started at `started`
74
+ export function pickLog(list, cwd, started) {
75
+ const mine = list.split('\n').map((l) => l.split('\t')).filter(([f, c]) => f && c === `"cwd":"${cwd}"`)
76
+ .map(([file, , ts]) => ({ file, began: Date.parse(String(ts || '').replace(/^"timestamp":"|"$/g, '')) || 0 }));
77
+ if (!mine.length) return null;
78
+ const fresh = mine.filter((m) => m.began >= started - 5000).sort((a, b) => a.began - b.began);
79
+ return (fresh[0] || [...mine].sort((a, b) => b.file.localeCompare(a.file))[0]).file;
80
+ }
81
+
82
+ const LIST = (minutes) => [
83
+ `find .codex/sessions -name 'rollout-*.jsonl' -mmin -${minutes} 2>/dev/null | while read -r f; do`,
84
+ `printf '%s\\t%s\\t%s\\n' "$f" "$(head -n1 "$f" | grep -o '"cwd":"[^"]*"' | head -1)" "$(head -n1 "$f" | grep -o '"timestamp":"[^"]*"' | head -1)";`,
85
+ 'done',
86
+ ].join(' ');
87
+
88
+ const found = new Map(); // `${session}|${address}|${pid}` -> the log (relative to home)
89
+
90
+ async function logOf(session, address, fg) {
91
+ const key = `${session}|${address}|${fg.pid}`;
92
+ if (found.has(key)) return found.get(key);
93
+ const machine = machineOf(address);
94
+ const age = (await homeShell(session, machine, `ps -o etime= -p ${Number(fg.pid)}`)).trim();
95
+ if (!age) return null;
96
+ const started = Date.now() - seconds(age) * 1000;
97
+ const minutes = Math.ceil((Date.now() - started) / 60000) + 1;
98
+ const file = pickLog(await homeShell(session, machine, LIST(minutes)), fg.cwd, started);
99
+ if (file) found.set(key, file);
100
+ return file;
101
+ }
102
+
103
+ // What is new since `cursor`: {items, file}, or null when the terminal runs no codex whose
104
+ // log is found. A new codex in the same terminal (a new process) reads its own log; what
105
+ // is left in the cursor's old one comes first.
106
+ export async function after(session, address, cursor) {
107
+ const fg = (await getTerminal(session, address)).foreground;
108
+ if (!fg?.pid || !fg.cwd) return null;
109
+ const file = await logOf(session, address, fg);
110
+ if (!file) return null;
111
+ const machine = machineOf(address);
112
+ const items = [];
113
+ if (cursor.file && cursor.file !== file) {
114
+ try {
115
+ items.push(...(await logAfter(session, machine, cursor.file, cursor, entryItems)));
116
+ } catch {
117
+ // the old log is gone; nothing left to read there
118
+ }
119
+ }
120
+ items.push(...(await logAfter(session, machine, file, cursor, entryItems)));
121
+ return { items, file };
122
+ }
123
+
124
+ export function rest(session, address, cursor) {
125
+ return logAfter(session, machineOf(address), cursor.file, cursor, entryItems);
126
+ }
127
+
128
+ // The room's name from the terminal's title: Codex titles it "<thread> | <folder>", led by
129
+ // a spinner while it works and by "[ . ] Action Required | " while it waits on you. The
130
+ // thread is "codex" until Codex names it; the name stays put while its state changes.
131
+ export function titleOf(title) {
132
+ const plain = String(title || '').replace(/^[^\p{L}\p{N}[]+/u, '').replace(/^\[[^\]]*\]\s*Action Required\s*\|\s*/i, '');
133
+ return plain.split(' | ')[0].trim();
134
+ }
@@ -11,6 +11,8 @@
11
11
  // the log it read, kept in the cursor
12
12
  // rest(session, address, cursor) -> items: what `cursor.file` still holds after the
13
13
  // program is gone
14
+ // titleOf(title) (optional) the room's name from the terminal's title,
15
+ // when the agent's title holds more than a name
14
16
  // Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
15
17
  // answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
16
18
  // Cursors are in ../cursor.mjs.
@@ -19,6 +21,7 @@
19
21
  // (docs/architecture.md, "Adding an agent").
20
22
  import path from 'node:path';
21
23
  import * as claude from './claude.mjs';
24
+ import * as codex from './codex.mjs';
22
25
 
23
26
  // What every reader exports (see above); checked for each one listed here when this
24
27
  // loads, so a reader that misses one fails at start, not when its agent first runs.
@@ -29,7 +32,7 @@ export function checkReader(reader) {
29
32
  return reader;
30
33
  }
31
34
 
32
- export const LISTED = [claude].map(checkReader);
35
+ export const LISTED = [claude, codex].map(checkReader);
33
36
  const READERS = new Map(LISTED.map((r) => [r.app, r]));
34
37
 
35
38
  export function readerFor(app) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "The chat client for hc (herdr): see and answer every hc terminal from Feishu or Telegram",
5
5
  "repository": {
6
6
  "type": "git",