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.
- package/README.md +21 -14
- package/README.zh.md +17 -14
- package/bin/cli.mjs +242 -7
- package/bin/herdrchat.mjs +13 -1
- package/config.env.example +2 -1
- package/docs/guide.md +20 -14
- package/lib/chats/contract.mjs +5 -2
- package/lib/chats/feishu/client.mjs +31 -4
- package/lib/chats/feishu/index.mjs +53 -273
- package/lib/chats/feishu/rooms/decor.mjs +175 -0
- package/lib/chats/feishu/rooms/groups.mjs +111 -0
- package/lib/chats/telegram/index.mjs +11 -3
- package/lib/core/config.mjs +23 -4
- package/lib/core/engine.mjs +54 -16
- package/lib/core/menu.mjs +3 -1
- package/lib/core/pairing.mjs +35 -0
- package/package.json +2 -2
- package/skills/herdrchat/SKILL.md +4 -2
- package/skills/herdrchat/references/feishu.md +15 -6
- package/skills/herdrchat/references/herdr.md +33 -0
- package/skills/herdrchat/references/ops.md +1 -1
- package/skills/herdrchat/references/telegram.md +9 -7
|
@@ -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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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:
|
|
2
|
-
// the bot for the overview and commands, and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
444
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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([...
|
|
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 = !
|
|
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:
|
|
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
|
+
}
|