herdrchat 1.3.3 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,14 +102,24 @@ function button(text, value, type = 'default') {
102
102
  return { tag: 'button', type, width: 'fill', text: { tag: 'plain_text', content: short }, behaviors: [{ type: 'callback', value }] };
103
103
  }
104
104
 
105
+ // A multi-select's boxes are checkers in a form: with no behaviors a checker is ticked on
106
+ // the phone only, so ticking makes no call and shows no loading; Next submits the form,
107
+ // and each box comes back in form_value under its name, box_<n> (ticksOf reads them).
108
+ export const BOX = 'box_';
105
109
  export function menuCard({ title, markdown, menu, value }) {
106
110
  const elements = [{ tag: 'markdown', content: markdown }];
111
+ const boxes = [];
107
112
  for (const o of menu.options) {
108
113
  if (o === menu.free) continue;
109
- const box = o.checked === null ? '' : o.checked ? '☑ ' : '☐ ';
110
- elements.push(button(`${o.n}. ${box}${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
114
+ if (menu.multi && o.checked !== null) {
115
+ boxes.push({ tag: 'checker', name: `${BOX}${o.n}`, checked: o.checked, text: { tag: 'plain_text', content: `${o.n}. ${o.label}` } });
116
+ continue;
117
+ }
118
+ elements.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
119
+ }
120
+ if (menu.multi) {
121
+ elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, 'primary'), name: 'next', form_action_type: 'submit' }] });
111
122
  }
112
- if (menu.multi) elements.push(button('Next ⇥', { ...value, pick: 'tab' }, 'primary'));
113
123
  if (menu.free) {
114
124
  elements.push({
115
125
  tag: 'form',
@@ -174,8 +184,16 @@ export async function downloadImage(client, messageId, imageKey, file) {
174
184
 
175
185
  // What a received message says: its text and the keys of its images, in order, for
176
186
  // text, image and rich text (post) messages; null for any other kind.
187
+ // null for what is not passed on: another type (a file, forwarded chat records, ...), or a
188
+ // content that is not JSON (a merged forward lists as "Merged and Forwarded Message").
177
189
  export function messageParts(messageType, content) {
178
- const c = JSON.parse(content);
190
+ let c;
191
+ try {
192
+ c = JSON.parse(content);
193
+ } catch {
194
+ return null;
195
+ }
196
+ if (!c || typeof c !== 'object') return null;
179
197
  if (messageType === 'text') return { text: c.text || '', images: [] };
180
198
  if (messageType === 'image') return { text: '', images: [c.image_key] };
181
199
  if (messageType !== 'post') return null;
@@ -306,3 +324,12 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, on
306
324
  });
307
325
  return ws;
308
326
  }
327
+
328
+ // The boxes as ticked, from a submitted form: {<n>: true|false}, or null when form_value
329
+ // has none of them (then nothing can be known, and nothing should be pressed). A checker's
330
+ // value is read as true when it is true or "true".
331
+ export function ticksOf(formValue) {
332
+ const entries = Object.entries(formValue || {}).filter(([name]) => name.startsWith(BOX));
333
+ if (!entries.length) return null;
334
+ return Object.fromEntries(entries.map(([name, v]) => [Number(name.slice(BOX.length)), v === true || v === 'true']));
335
+ }
@@ -1,15 +1,14 @@
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 { 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';
@@ -20,123 +19,16 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
19
  // What to do after an edit to config.env, said the way this profile is run.
21
20
  const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
22
21
 
23
- const inSpaces = (set, space) => set.all || set.spaces.includes(space);
24
- const spaceOfName = (name) => (name || '').split(' / ')[0];
25
-
26
22
  // cfg: loadConfig(); store: {read, write} of state.json; lane(k, label, fn): runs fn in
27
23
  // the room's lane, after what is queued for it; scopeOf(k): the session or machine a
28
24
  // room's terminal belongs to (its tag and label).
29
25
  export const requires = ['FEISHU_APP_ID', 'FEISHU_APP_SECRET'];
30
26
 
31
- export function create({ cfg, store, log, logWarn, logError, stateDir, lane, scopeOf }) {
27
+ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, scopeOf, claimOwner = () => 'none' }) {
32
28
  const client = createClient(cfg);
33
29
  const userAuth = createUserAuth(cfg, stateDir);
34
30
  const { read, write } = store;
35
31
 
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
32
  // ---------- acting as you (optional, after /auth) ----------
141
33
 
142
34
  // With your token (userauth.mjs) the bot does what only you can: mutes its groups,
@@ -156,7 +48,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
156
48
  you = await userAuth.allowed();
157
49
  if (you && !was) {
158
50
  log('acting as you: on');
159
- setTimeout(alignAll, 0);
51
+ setTimeout(() => rooms.alignAll(), 0);
160
52
  } else if (!you && was) {
161
53
  logWarn('acting as you: off');
162
54
  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,107 +56,10 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
164
56
  return you;
165
57
  }
166
58
 
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
59
  // ---------- rooms ----------
233
60
 
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
- }
61
+ const decor = createDecor({ client, cfg, store, log, logError, userAuth, asYou });
62
+ const rooms = createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor });
268
63
 
269
64
  // ---------- posting ----------
270
65
 
@@ -289,43 +84,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
289
84
  await updateCard(client, message, doneCard(dialog.doneTitle, dialog.markdown, done));
290
85
  }
291
86
 
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
87
  async function addReaction(message, emoji) {
330
88
  const res = await client.im.v1.messageReaction.create({ path: { message_id: message }, data: { reaction_type: { emoji_type: emoji } } });
331
89
  return res?.data?.reaction_id;
@@ -432,7 +190,14 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
432
190
  seen.add(msg.message_id);
433
191
  if (seen.size > 2000) seen.delete(seen.values().next().value);
434
192
  queued(msg.chat_id, async () => {
435
- await handleMessage(data);
193
+ // whatever goes wrong, you are told it did not get through, and it is not tried
194
+ // again at every connection
195
+ try {
196
+ await handleMessage(data);
197
+ } catch (err) {
198
+ logError(`feishu message ${msg.message_id} in ${msg.chat_id}:`, err);
199
+ 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(() => {});
200
+ }
436
201
  markRead(msg.chat_id, Number(msg.create_time) || Date.now());
437
202
  });
438
203
  };
@@ -440,8 +205,13 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
440
205
  const msg = data.message;
441
206
  const openId = data.sender?.sender_id?.open_id;
442
207
  if (!cfg.ownerOpenId) {
443
- log(`owner not set; message from open_id ${openId}`);
444
- if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen run ${startAgain(cfg)}. Your terminals' groups appear then.`);
208
+ if (msg.chat_type !== 'p2p') return;
209
+ const said = messageParts(msg.message_type, msg.content)?.text || '';
210
+ const paired = said.trim().startsWith('/') ? 'none' : claimOwner(said, 'FEISHU_OWNER_OPEN_ID', openId);
211
+ log(`owner not set; message from open_id ${openId} (pairing: ${paired})`);
212
+ if (paired === 'ok') await reply(msg.chat_id, '✅ Paired: you are this bot\'s owner now. It starts again, and your terminals\' groups appear in a moment.\n\nThen: type in a terminal\'s group to talk to it; here, /ls shows them all, /new starts one, /auth lets the bot mute its groups.');
213
+ else if (paired === 'wrong') await reply(msg.chat_id, 'That is not the code. Send the 6 digits `herdrchat setup` shows.');
214
+ else await reply(msg.chat_id, `Hello! This bot has no owner yet. Run \`herdrchat setup${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\` and send me the code it shows; or add this line to ${cfg.configFile || 'config.env'} yourself and run ${startAgain(cfg)}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\``);
445
215
  return;
446
216
  }
447
217
  if (openId !== cfg.ownerOpenId) {
@@ -450,18 +220,28 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
450
220
  }
451
221
  const parts = messageParts(msg.message_type, msg.content);
452
222
  if (!parts) {
453
- await reply(msg.chat_id, 'Only text and images are passed on.');
223
+ 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.`);
454
224
  return;
455
225
  }
456
226
  const text = parts.text.replace(/@_user_\d+/g, '').trim();
457
227
  if (!text && !parts.images.length) return;
458
- await onMessage({ room: msg.chat_type === 'p2p' ? null : msg.chat_id, where: msg.chat_id, message: msg.message_id, text, images: parts.images });
228
+ await onMessage({ room: rooms.roomOf(msg), where: msg.chat_id, message: msg.message_id, text, images: parts.images });
459
229
  };
460
230
  // A click must be answered within 3 seconds; the core's answer becomes Feishu's
461
231
  // callback response: a toast and, when the card changes, its new form.
462
232
  const handleAction = async (data) => {
463
233
  if (data.operator?.open_id !== cfg.ownerOpenId) return { toast: { type: 'error', content: 'Only the owner can answer.' } };
464
- const out = await onAction({ value: data.action?.value || {}, message: data.context?.open_message_id, answer: String(data.action?.form_value?.answer || '') });
234
+ const value = data.action?.value || {};
235
+ const form = data.action?.form_value;
236
+ let ticks = null;
237
+ if (value.pick === 'tab' && form) {
238
+ ticks = ticksOf(form);
239
+ // what each box sent, by type only (never what it says): to see the shape Feishu uses
240
+ log(`card form: ${Object.entries(form).map(([n, v]) => `${n}=${typeof v}:${typeof v === 'boolean' ? v : String(v).length}`).join(' ') || 'empty'}`);
241
+ // no box came back: the ticks are unknown, and a wrong answer is worse than none
242
+ if (!ticks) return { toast: { type: 'error', content: 'No ticked box came through, so nothing was sent. Tick at least one, or answer it in herdr-app.' } };
243
+ }
244
+ const out = await onAction({ value, message: data.context?.open_message_id, answer: String(form?.answer || ''), ticks });
465
245
  const res = { toast: { type: out.toast.type, content: out.toast.text } };
466
246
  if (out.card?.kind === 'done') res.card = { type: 'raw', data: doneCard(out.card.dialog.doneTitle, out.card.dialog.markdown, out.card.done) };
467
247
  if (out.card?.kind === 'dialog') res.card = { type: 'raw', data: menuCard({ title: out.card.dialog.title, markdown: out.card.dialog.markdown, menu: out.card.dialog.menu, value: out.card.value }) };
@@ -472,7 +252,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
472
252
  const catchUp = async () => {
473
253
  if (!cfg.ownerOpenId) return;
474
254
  const state = read();
475
- const chats = new Set([...Object.values(state.chats || {}).filter((c) => !c.ended).map((c) => c.chat), ...Object.keys(state.feishuRead || {})]);
255
+ const chats = new Set([...rooms.roomChats(), ...Object.keys(state.feishuRead || {})]);
476
256
  for (const chatId of chats) {
477
257
  const since = state.feishuRead?.[chatId];
478
258
  if (!since) {
@@ -483,8 +263,8 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
483
263
  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 } });
484
264
  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));
485
265
  if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
486
- const p2p = !Object.values(state.chats || {}).some((c) => c.chat === chatId);
487
- 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 } } });
266
+ const p2p = !rooms.isRoomChat(chatId);
267
+ for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group', root_id: m.root_id, thread_id: m.thread_id, message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
488
268
  } catch (err) {
489
269
  logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
490
270
  }
@@ -529,25 +309,25 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
529
309
  name: 'feishu',
530
310
  ready: () => Boolean(cfg.ownerOpenId),
531
311
  help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
532
- roomWord: 'group',
312
+ roomWord: rooms.roomWord,
533
313
  liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
534
314
  split: (text) => chunk(text),
535
315
  start,
536
316
  command,
537
- openRoom,
538
- renameRoom,
539
- placeRoom,
540
- endRoom,
541
- reviveRoom,
317
+ openRoom: rooms.openRoom,
318
+ renameRoom: rooms.renameRoom,
319
+ placeRoom: rooms.placeRoom,
320
+ endRoom: rooms.endRoom,
321
+ reviveRoom: rooms.reviveRoom,
542
322
  post,
543
323
  postCard,
544
324
  editCard,
545
325
  postDialog,
546
326
  closeDialog,
547
- pin,
548
- unpin,
549
- pinned,
550
- ring,
327
+ pin: rooms.pin,
328
+ unpin: rooms.unpin,
329
+ pinned: rooms.pinned,
330
+ ring: rooms.ring,
551
331
  markWorking,
552
332
  markDone,
553
333
  image,
@@ -0,0 +1,175 @@
1
+ // What a Feishu group wears, whichever way rooms are laid out (groups.mjs: one group per
2
+ // tab; a space's group, later): the space's avatar, the bot tags, and, acting as you, your
3
+ // label and its mute. Each call takes an `entry`, {get() -> record, set(fields)}: the record
4
+ // of the group in state.json, {chat, name, avatar, tag, ended, endedTag, label, muted}.
5
+ import { bindTag, createTag, setChatAvatar, tagName, unbindTag, uploadImage } from '../client.mjs';
6
+ import { COLORS, avatarPng, letterOf } from '../avatar.mjs';
7
+
8
+ const ENDED_TAG = '#ended';
9
+ const ENDED_LABEL = 'ended';
10
+
11
+ export const inSpaces = (set, space) => set.all || set.spaces.includes(space);
12
+ export const spaceOfName = (name) => (name || '').split(' / ')[0];
13
+
14
+ // asYou() -> whether the bot may act as you now (index.mjs keeps that)
15
+ export function createDecor({ client, cfg, store, log, logError, userAuth, asYou }) {
16
+ const { read, write } = store;
17
+
18
+ // Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
19
+ // by the first lane that needs it; the others wait for that.
20
+ const making = new Map();
21
+ function once(id, make) {
22
+ if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
23
+ return making.get(id);
24
+ }
25
+
26
+ // ---------- tags and avatars ----------
27
+
28
+ // One tenant tag per session or machine, made once, and one "ended" tag; their ids are
29
+ // kept in state.json (tags).
30
+ async function tagOf(id, name = id) {
31
+ return read().tags?.[id] || once(`tag ${id}`, async () => {
32
+ const tag = await createTag(client, name);
33
+ const state = read();
34
+ state.tags = { ...(state.tags || {}), [id]: tag };
35
+ write(state);
36
+ log(`created tag ${name} (${tag})`);
37
+ return tag;
38
+ });
39
+ }
40
+
41
+ // Each space gets an avatar: its first letter on a color of its own (the first color no
42
+ // other space has), kept in state.json (spaces), so all of its groups look alike, as in
43
+ // herdr-app. The color is picked and saved before the first await, so spaces made side
44
+ // by side never pick the same one. A failure is logged and tried again at the next check.
45
+ function colorOf(space) {
46
+ const state = read();
47
+ const spaces = state.spaces || {};
48
+ if (spaces[space]?.color) return spaces[space].color;
49
+ const used = Object.values(spaces).map((x) => x.color);
50
+ const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
51
+ state.spaces = { ...spaces, [space]: { color } };
52
+ write(state);
53
+ return color;
54
+ }
55
+
56
+ async function avatarOf(space) {
57
+ return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
58
+ const color = colorOf(space);
59
+ const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
60
+ const state = read();
61
+ state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
62
+ write(state);
63
+ log(`made avatar for space ${space} (${color})`);
64
+ return image;
65
+ });
66
+ }
67
+
68
+ // the space's avatar; a no-op when the group has it, so it follows a space that changes
69
+ async function avatar(entry, space) {
70
+ const c = entry.get();
71
+ if (!c) return;
72
+ try {
73
+ const image = await avatarOf(space);
74
+ if (c.avatar === image) return;
75
+ await setChatAvatar(client, c.chat, image);
76
+ entry.set({ avatar: image });
77
+ } catch (err) {
78
+ logError(`avatar ${space} on ${c.chat}: ${err.message}`);
79
+ }
80
+ }
81
+
82
+ // Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
83
+ // its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
84
+ // takes off the ones already on. A failure is logged and tried again at the next check.
85
+ async function botTags(entry, scope) {
86
+ const c = entry.get();
87
+ if (!c) return;
88
+ try {
89
+ if (cfg.botTags) {
90
+ const tag = await tagOf(scope);
91
+ if (c.tag !== tag) {
92
+ await bindTag(client, tag, c.chat);
93
+ entry.set({ tag });
94
+ }
95
+ if (Boolean(c.ended) !== Boolean(c.endedTag)) {
96
+ const ended = await tagOf(ENDED_TAG, 'ended');
97
+ await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
98
+ entry.set({ endedTag: Boolean(c.ended) });
99
+ }
100
+ } else {
101
+ const tags = read().tags || {};
102
+ if (c.tag) {
103
+ await unbindTag(client, c.tag, c.chat).catch(() => {});
104
+ entry.set({ tag: null });
105
+ }
106
+ if (c.endedTag && tags[ENDED_TAG]) {
107
+ await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
108
+ entry.set({ endedTag: false });
109
+ }
110
+ }
111
+ } catch (err) {
112
+ logError(`bot tags on ${c.name}: ${err.message}`);
113
+ }
114
+ }
115
+
116
+ // ---------- acting as you ----------
117
+
118
+ async function labelOf(name) {
119
+ return read().labels?.[name] || once(`label ${name}`, async () => {
120
+ const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
121
+ const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
122
+ if (!id) throw new Error(`no label ${name}`);
123
+ const state = read();
124
+ state.labels = { ...(state.labels || {}), [name]: id };
125
+ write(state);
126
+ log(`label ${name} (${id})`);
127
+ return id;
128
+ });
129
+ }
130
+
131
+ // A label you deleted is made again at the next try.
132
+ async function intoLabel(name, chat) {
133
+ try {
134
+ await userAuth.addToFeedGroup(await labelOf(name), [chat]);
135
+ } catch (err) {
136
+ const state = read();
137
+ if (state.labels) delete state.labels[name];
138
+ write(state);
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ // Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
144
+ // the terminal is gone; off takes the group out of it (the label itself is yours).
145
+ async function userLabel(entry, scope) {
146
+ if (!(await asYou())) return;
147
+ const c = entry.get();
148
+ if (!c) return;
149
+ const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
150
+ if ((c.label || null) === want) return;
151
+ try {
152
+ if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
153
+ if (want) await intoLabel(want, c.chat);
154
+ entry.set({ label: want });
155
+ } catch (err) {
156
+ logError(`as you, label of ${c.name}: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ // HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
161
+ // in config.env is applied both ways at the next start.
162
+ async function mute(entry, space) {
163
+ const c = entry.get();
164
+ const want = inSpaces(cfg.muted, space);
165
+ if (!c || Boolean(c.muted) === want) return;
166
+ try {
167
+ await userAuth.setMuted([c.chat], want);
168
+ entry.set({ muted: want });
169
+ } catch (err) {
170
+ logError(`as you, mute ${c.chat}: ${err.message}`);
171
+ }
172
+ }
173
+
174
+ return { avatar, botTags, userLabel, mute };
175
+ }