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
|
-
|
|
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:
|
|
2
|
-
// the bot for the overview and commands, and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { ticksOf, createClient,
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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([...
|
|
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 = !
|
|
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:
|
|
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;
|