herdrchat 1.4.0 → 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.
@@ -184,8 +184,16 @@ export async function downloadImage(client, messageId, imageKey, file) {
184
184
 
185
185
  // What a received message says: its text and the keys of its images, in order, for
186
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").
187
189
  export function messageParts(messageType, content) {
188
- 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;
189
197
  if (messageType === 'text') return { text: c.text || '', images: [] };
190
198
  if (messageType === 'image') return { text: '', images: [c.image_key] };
191
199
  if (messageType !== 'post') return null;
@@ -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 { 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';
@@ -20,9 +19,6 @@ 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).
@@ -33,110 +29,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
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
  };
@@ -455,12 +220,12 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
455
220
  }
456
221
  const parts = messageParts(msg.message_type, msg.content);
457
222
  if (!parts) {
458
- 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.`);
459
224
  return;
460
225
  }
461
226
  const text = parts.text.replace(/@_user_\d+/g, '').trim();
462
227
  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 });
228
+ await onMessage({ room: rooms.roomOf(msg), where: msg.chat_id, message: msg.message_id, text, images: parts.images });
464
229
  };
465
230
  // A click must be answered within 3 seconds; the core's answer becomes Feishu's
466
231
  // callback response: a toast and, when the card changes, its new form.
@@ -487,7 +252,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
487
252
  const catchUp = async () => {
488
253
  if (!cfg.ownerOpenId) return;
489
254
  const state = read();
490
- 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 || {})]);
491
256
  for (const chatId of chats) {
492
257
  const since = state.feishuRead?.[chatId];
493
258
  if (!since) {
@@ -498,8 +263,8 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
498
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 } });
499
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));
500
265
  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);
502
- 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 } } });
503
268
  } catch (err) {
504
269
  logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
505
270
  }
@@ -544,25 +309,25 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
544
309
  name: 'feishu',
545
310
  ready: () => Boolean(cfg.ownerOpenId),
546
311
  help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
547
- roomWord: 'group',
312
+ roomWord: rooms.roomWord,
548
313
  liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
549
314
  split: (text) => chunk(text),
550
315
  start,
551
316
  command,
552
- openRoom,
553
- renameRoom,
554
- placeRoom,
555
- endRoom,
556
- reviveRoom,
317
+ openRoom: rooms.openRoom,
318
+ renameRoom: rooms.renameRoom,
319
+ placeRoom: rooms.placeRoom,
320
+ endRoom: rooms.endRoom,
321
+ reviveRoom: rooms.reviveRoom,
557
322
  post,
558
323
  postCard,
559
324
  editCard,
560
325
  postDialog,
561
326
  closeDialog,
562
- pin,
563
- unpin,
564
- pinned,
565
- ring,
327
+ pin: rooms.pin,
328
+ unpin: rooms.unpin,
329
+ pinned: rooms.pinned,
330
+ ring: rooms.ring,
566
331
  markWorking,
567
332
  markDone,
568
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
+ }
@@ -0,0 +1,111 @@
1
+ // HERDR_ROOMS=tab: one Feishu group per herdr-app tab, with you in it; what you type there
2
+ // goes to its terminal. A room is the group's chat id. The group wears its space's avatar,
3
+ // its tags and, acting as you, its mute and your label (decor.mjs); the group that needs
4
+ // you is pinned. index.mjs calls only what is returned here, and a layout of rooms in
5
+ // another file gives the same members.
6
+ import { createChat, renameChat, replyMention } from '../client.mjs';
7
+ import { inSpaces, spaceOfName } from './decor.mjs';
8
+
9
+ export function createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor }) {
10
+ const { read, write } = store;
11
+ const entryOf = (k) => ({
12
+ get: () => read().chats[k],
13
+ set: (fields) => {
14
+ const state = read();
15
+ if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
16
+ write(state);
17
+ },
18
+ });
19
+
20
+ async function openRoom({ name, intro }) {
21
+ return createChat(client, cfg.ownerOpenId, name, intro);
22
+ }
23
+
24
+ async function renameRoom(room, name) {
25
+ await renameChat(client, room, name);
26
+ }
27
+
28
+ // Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
29
+ // and in your label. Cheap when nothing is left to do.
30
+ async function placeRoom(k, { space }) {
31
+ const entry = entryOf(k);
32
+ await decor.avatar(entry, space);
33
+ await decor.botTags(entry, scopeOf(k));
34
+ if (!(await asYou())) return;
35
+ await decor.mute(entry, space);
36
+ await decor.userLabel(entry, scopeOf(k));
37
+ }
38
+
39
+ // A group whose terminal is gone keeps its history, tagged "ended".
40
+ async function endRoom(k) {
41
+ const entry = entryOf(k);
42
+ const c = entry.get();
43
+ if (!c) return;
44
+ await decor.botTags(entry, scopeOf(k));
45
+ if (!c.avatar) await decor.avatar(entry, spaceOfName(c.name));
46
+ if (!(await asYou())) return;
47
+ await decor.mute(entry, spaceOfName(c.name));
48
+ await decor.userLabel(entry, scopeOf(k));
49
+ }
50
+
51
+ async function reviveRoom(k) {
52
+ const entry = entryOf(k);
53
+ await decor.botTags(entry, scopeOf(k));
54
+ await decor.userLabel(entry, scopeOf(k));
55
+ }
56
+
57
+ // Everything at once, when acting as you turns on: live groups to their labels, ended
58
+ // ones to "ended".
59
+ function alignAll() {
60
+ for (const [k, c] of Object.entries(read().chats)) {
61
+ if (!cfg.sessions.includes(c.session)) continue;
62
+ lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
63
+ }
64
+ }
65
+
66
+ // The group that needs you is pinned while it waits (acting as you), and unpinned when
67
+ // it no longer does; which ones are pinned is kept in state.json (pinned).
68
+ async function pin(k, room) {
69
+ if (!(await asYou())) return;
70
+ try {
71
+ await userAuth.pin(room);
72
+ const state = read();
73
+ state.pinned = { ...(state.pinned || {}), [k]: room };
74
+ write(state);
75
+ } catch (err) {
76
+ logError(`pin ${room}: ${err.message}`);
77
+ }
78
+ }
79
+
80
+ async function unpin(k) {
81
+ const room = read().pinned?.[k];
82
+ if (!room) return;
83
+ try {
84
+ await userAuth.unpin(room);
85
+ } catch (err) {
86
+ logError(`unpin ${room}: ${err.message}`);
87
+ }
88
+ const state = read();
89
+ delete state.pinned[k];
90
+ write(state);
91
+ }
92
+
93
+ const pinned = () => Object.keys(read().pinned || {});
94
+
95
+ // In a muted group only a mention rings; a group that is not muted rings anyway. A
96
+ // space in HERDR_SILENT_SPACES gets no mention at all.
97
+ async function ring(k, message, text) {
98
+ const c = read().chats[k];
99
+ if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
100
+ await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
101
+ }
102
+
103
+ // A message, to the core's words: the room it came from (null: the direct chat).
104
+ const roomOf = (msg) => (msg.chat_type === 'p2p' ? null : msg.chat_id);
105
+ // The chats whose messages the bot reads (to look for what it missed), and whether a
106
+ // chat is one of them rather than the direct chat.
107
+ const roomChats = () => Object.values(read().chats || {}).filter((c) => !c.ended).map((c) => c.chat);
108
+ const isRoomChat = (chatId) => Object.values(read().chats || {}).some((c) => c.chat === chatId);
109
+
110
+ return { roomWord: 'group', openRoom, renameRoom, placeRoom, endRoom, reviveRoom, alignAll, pin, unpin, pinned, ring, roomOf, roomChats, isRoomChat };
111
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",