herdrchat 1.0.0 → 1.1.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/bin/cli.mjs +64 -15
- package/bin/herdrchat.mjs +63 -1246
- package/config.env.example +14 -0
- package/docs/architecture.md +76 -0
- package/docs/guide.md +18 -6
- package/lib/chats/contract.mjs +58 -0
- package/lib/{feishu.mjs → chats/feishu/client.mjs} +23 -2
- package/lib/chats/feishu/index.mjs +499 -0
- package/lib/{overview.mjs → chats/feishu/overview.mjs} +2 -2
- package/lib/chats/index.mjs +20 -0
- package/lib/chats/telegram/client.mjs +83 -0
- package/lib/chats/telegram/format.mjs +83 -0
- package/lib/chats/telegram/index.mjs +411 -0
- package/lib/{config.mjs → core/config.mjs} +39 -3
- package/lib/core/engine.mjs +1071 -0
- package/lib/{hc.mjs → core/hc.mjs} +50 -2
- package/lib/{log.mjs → core/log.mjs} +6 -3
- package/lib/{menu.mjs → core/menu.mjs} +6 -2
- package/lib/{readers → core/readers}/claude.mjs +1 -0
- package/lib/core/readers/index.mjs +49 -0
- package/lib/core/views.mjs +58 -0
- package/package.json +1 -1
- package/skills/herdrchat/SKILL.md +1 -1
- package/skills/herdrchat/references/ops.md +15 -2
- package/skills/herdrchat/references/telegram.md +23 -0
- package/lib/readers/index.mjs +0 -24
- /package/lib/{avatar.mjs → chats/feishu/avatar.mjs} +0 -0
- /package/lib/{userauth.mjs → chats/feishu/userauth.mjs} +0 -0
- /package/lib/{cursor.mjs → core/cursor.mjs} +0 -0
- /package/lib/{events.mjs → core/events.mjs} +0 -0
- /package/lib/{home.mjs → core/home.mjs} +0 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// herdrchat's Feishu side: one group per tab (a "room" to the core), the direct chat with
|
|
2
|
+
// the bot for the overview and commands, and what only Feishu has: tenant tags, group
|
|
3
|
+
// avatars, and, after /auth, acting as you (muted groups, your labels, pins, @ mentions).
|
|
4
|
+
// The core (lib/core/engine.mjs) calls only what is returned here; see "The chat side"
|
|
5
|
+
// there for what each call means.
|
|
6
|
+
import { createClient, bindTag, chunk, createChat, createTag, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, renameChat, replyMention, sendCard, sendFile, sendMarkdown, setChatAvatar, startEvents, tagName, textCard, unbindTag, updateCard, uploadImage } from './client.mjs';
|
|
7
|
+
import { COLORS, avatarPng, letterOf } from './avatar.mjs';
|
|
8
|
+
import { overviewCard, overviewText } from './overview.mjs';
|
|
9
|
+
import { createUserAuth } from './userauth.mjs';
|
|
10
|
+
|
|
11
|
+
const ENDED_TAG = '#ended';
|
|
12
|
+
const ENDED_LABEL = 'ended';
|
|
13
|
+
const YOU_CHECK_MS = 60 * 1000;
|
|
14
|
+
const OVERVIEW_TRIES = 3;
|
|
15
|
+
const WORKING_EMOJI = 'THINKING';
|
|
16
|
+
const DONE_EMOJI = 'OK';
|
|
17
|
+
const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
|
|
18
|
+
|
|
19
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
21
|
+
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
22
|
+
|
|
23
|
+
// cfg: loadConfig(); store: {read, write} of state.json; lane(k, label, fn): runs fn in
|
|
24
|
+
// the room's lane, after what is queued for it; scopeOf(k): the session or machine a
|
|
25
|
+
// room's terminal belongs to (its tag and label).
|
|
26
|
+
export const requires = ['FEISHU_APP_ID', 'FEISHU_APP_SECRET'];
|
|
27
|
+
|
|
28
|
+
export function create({ cfg, store, log, logWarn, logError, stateDir, lane, scopeOf }) {
|
|
29
|
+
const client = createClient(cfg);
|
|
30
|
+
const userAuth = createUserAuth(cfg, stateDir);
|
|
31
|
+
const { read, write } = store;
|
|
32
|
+
|
|
33
|
+
function setRoom(k, fields) {
|
|
34
|
+
const state = read();
|
|
35
|
+
if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
|
|
36
|
+
write(state);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
|
|
40
|
+
// by the first lane that needs it; the others wait for that.
|
|
41
|
+
const making = new Map();
|
|
42
|
+
function once(id, make) {
|
|
43
|
+
if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
|
|
44
|
+
return making.get(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- tags and avatars ----------
|
|
48
|
+
|
|
49
|
+
// One tenant tag per session or machine, made once, and one "ended" tag; their ids are
|
|
50
|
+
// kept in state.json (tags).
|
|
51
|
+
async function tagOf(id, name = id) {
|
|
52
|
+
return read().tags?.[id] || once(`tag ${id}`, async () => {
|
|
53
|
+
const tag = await createTag(client, name);
|
|
54
|
+
const state = read();
|
|
55
|
+
state.tags = { ...(state.tags || {}), [id]: tag };
|
|
56
|
+
write(state);
|
|
57
|
+
log(`created tag ${name} (${tag})`);
|
|
58
|
+
return tag;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Each space gets an avatar: its first letter on a color of its own (the first color no
|
|
63
|
+
// other space has), kept in state.json (spaces), so all of its groups look alike, as in
|
|
64
|
+
// herdr-app. The color is picked and saved before the first await, so spaces made side
|
|
65
|
+
// by side never pick the same one. A failure is logged and tried again at the next check.
|
|
66
|
+
function colorOf(space) {
|
|
67
|
+
const state = read();
|
|
68
|
+
const spaces = state.spaces || {};
|
|
69
|
+
if (spaces[space]?.color) return spaces[space].color;
|
|
70
|
+
const used = Object.values(spaces).map((x) => x.color);
|
|
71
|
+
const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
|
|
72
|
+
state.spaces = { ...spaces, [space]: { color } };
|
|
73
|
+
write(state);
|
|
74
|
+
return color;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function avatarOf(space) {
|
|
78
|
+
return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
|
|
79
|
+
const color = colorOf(space);
|
|
80
|
+
const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
|
|
81
|
+
const state = read();
|
|
82
|
+
state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
|
|
83
|
+
write(state);
|
|
84
|
+
log(`made avatar for space ${space} (${color})`);
|
|
85
|
+
return image;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// the space's avatar; a no-op when the group has it, so it follows a space that changes
|
|
90
|
+
async function avatarRoom(k, space) {
|
|
91
|
+
const c = read().chats[k];
|
|
92
|
+
if (!c) return;
|
|
93
|
+
try {
|
|
94
|
+
const image = await avatarOf(space);
|
|
95
|
+
if (c.avatar === image) return;
|
|
96
|
+
await setChatAvatar(client, c.chat, image);
|
|
97
|
+
setRoom(k, { avatar: image });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logError(`avatar ${space} on ${c.chat}: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
|
|
104
|
+
// its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
|
|
105
|
+
// takes off the ones already on. A failure is logged and tried again at the next check.
|
|
106
|
+
async function syncBotTags(k, scope) {
|
|
107
|
+
const c = read().chats[k];
|
|
108
|
+
if (!c) return;
|
|
109
|
+
try {
|
|
110
|
+
if (cfg.botTags) {
|
|
111
|
+
const tag = await tagOf(scope);
|
|
112
|
+
if (c.tag !== tag) {
|
|
113
|
+
await bindTag(client, tag, c.chat);
|
|
114
|
+
setRoom(k, { tag });
|
|
115
|
+
}
|
|
116
|
+
if (Boolean(c.ended) !== Boolean(c.endedTag)) {
|
|
117
|
+
const ended = await tagOf(ENDED_TAG, 'ended');
|
|
118
|
+
await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
|
|
119
|
+
setRoom(k, { endedTag: Boolean(c.ended) });
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
const tags = read().tags || {};
|
|
123
|
+
if (c.tag) {
|
|
124
|
+
await unbindTag(client, c.tag, c.chat).catch(() => {});
|
|
125
|
+
setRoom(k, { tag: null });
|
|
126
|
+
}
|
|
127
|
+
if (c.endedTag && tags[ENDED_TAG]) {
|
|
128
|
+
await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
|
|
129
|
+
setRoom(k, { endedTag: false });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logError(`bot tags on ${c.name}: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- acting as you (optional, after /auth) ----------
|
|
138
|
+
|
|
139
|
+
// With your token (userauth.mjs) the bot does what only you can: mutes its groups,
|
|
140
|
+
// puts each in one of your own labels (the session or machine; "ended" once the terminal
|
|
141
|
+
// is gone) besides its bot tag, pins the group that needs you, and @-mentions you under
|
|
142
|
+
// a card that needs you and under the turn's last answer, since in a muted group only a
|
|
143
|
+
// mention rings. Without the token none of this happens. Whether it may act as you is
|
|
144
|
+
// checked every YOU_CHECK_MS; when that turns on, every group is brought in line; when
|
|
145
|
+
// it turns off, you are told once.
|
|
146
|
+
let you = false;
|
|
147
|
+
let youCheckedAt = 0;
|
|
148
|
+
|
|
149
|
+
async function asYou() {
|
|
150
|
+
if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
|
|
151
|
+
youCheckedAt = Date.now();
|
|
152
|
+
const was = you;
|
|
153
|
+
you = await userAuth.allowed();
|
|
154
|
+
if (you && !was) {
|
|
155
|
+
log('acting as you: on');
|
|
156
|
+
setTimeout(alignAll, 0);
|
|
157
|
+
} else if (!you && was) {
|
|
158
|
+
logWarn('acting as you: off');
|
|
159
|
+
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(() => {});
|
|
160
|
+
}
|
|
161
|
+
return you;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function labelOf(name) {
|
|
165
|
+
return read().labels?.[name] || once(`label ${name}`, async () => {
|
|
166
|
+
const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
|
|
167
|
+
const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
|
|
168
|
+
if (!id) throw new Error(`no label ${name}`);
|
|
169
|
+
const state = read();
|
|
170
|
+
state.labels = { ...(state.labels || {}), [name]: id };
|
|
171
|
+
write(state);
|
|
172
|
+
log(`label ${name} (${id})`);
|
|
173
|
+
return id;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A label you deleted is made again at the next try.
|
|
178
|
+
async function intoLabel(name, chat) {
|
|
179
|
+
try {
|
|
180
|
+
await userAuth.addToFeedGroup(await labelOf(name), [chat]);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const state = read();
|
|
183
|
+
if (state.labels) delete state.labels[name];
|
|
184
|
+
write(state);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
|
|
190
|
+
// the terminal is gone; off takes the group out of it (the label itself is yours).
|
|
191
|
+
async function syncUserLabel(k, scope) {
|
|
192
|
+
if (!(await asYou())) return;
|
|
193
|
+
const c = read().chats[k];
|
|
194
|
+
if (!c) return;
|
|
195
|
+
const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
|
|
196
|
+
if ((c.label || null) === want) return;
|
|
197
|
+
try {
|
|
198
|
+
if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
|
|
199
|
+
if (want) await intoLabel(want, c.chat);
|
|
200
|
+
setRoom(k, { label: want });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
logError(`as you, label of ${c.name}: ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
|
|
207
|
+
// in config.env is applied both ways at the next start.
|
|
208
|
+
async function muteRoom(k, space) {
|
|
209
|
+
const c = read().chats[k];
|
|
210
|
+
const want = inSpaces(cfg.muted, space);
|
|
211
|
+
if (!c || Boolean(c.muted) === want) return;
|
|
212
|
+
try {
|
|
213
|
+
await userAuth.setMuted([c.chat], want);
|
|
214
|
+
setRoom(k, { muted: want });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logError(`as you, mute ${c.chat}: ${err.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Everything at once, when acting as you turns on: live groups to their labels, ended
|
|
221
|
+
// ones to "ended".
|
|
222
|
+
function alignAll() {
|
|
223
|
+
for (const [k, c] of Object.entries(read().chats)) {
|
|
224
|
+
if (!cfg.sessions.includes(c.session)) continue;
|
|
225
|
+
lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------- rooms ----------
|
|
230
|
+
|
|
231
|
+
// One group per tab, with you in it; what you type there goes to its terminal.
|
|
232
|
+
async function openRoom({ name, intro }) {
|
|
233
|
+
return createChat(client, cfg.ownerOpenId, name, intro);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function renameRoom(room, name) {
|
|
237
|
+
await renameChat(client, room, name);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
|
|
241
|
+
// and in your label. Cheap when nothing is left to do.
|
|
242
|
+
async function placeRoom(k, { space }) {
|
|
243
|
+
await avatarRoom(k, space);
|
|
244
|
+
await syncBotTags(k, scopeOf(k));
|
|
245
|
+
if (!(await asYou())) return;
|
|
246
|
+
await muteRoom(k, space);
|
|
247
|
+
await syncUserLabel(k, scopeOf(k));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A group whose terminal is gone keeps its history, tagged "ended".
|
|
251
|
+
async function endRoom(k) {
|
|
252
|
+
const c = read().chats[k];
|
|
253
|
+
if (!c) return;
|
|
254
|
+
await syncBotTags(k, scopeOf(k));
|
|
255
|
+
if (!c.avatar) await avatarRoom(k, spaceOfName(c.name));
|
|
256
|
+
if (!(await asYou())) return;
|
|
257
|
+
await muteRoom(k, spaceOfName(c.name));
|
|
258
|
+
await syncUserLabel(k, scopeOf(k));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function reviveRoom(k) {
|
|
262
|
+
await syncBotTags(k, scopeOf(k));
|
|
263
|
+
await syncUserLabel(k, scopeOf(k));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------- posting ----------
|
|
267
|
+
|
|
268
|
+
const post = (room, markdown) => sendMarkdown(client, room, markdown);
|
|
269
|
+
const card = ({ title, tone, markdown }) => textCard(markdown, title, TONES[tone] || 'blue');
|
|
270
|
+
|
|
271
|
+
async function postCard(room, look) {
|
|
272
|
+
const res = await sendCard(client, room, card(look));
|
|
273
|
+
return res?.data?.message_id || null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function editCard(message, look) {
|
|
277
|
+
await updateCard(client, message, card(look));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function postDialog(room, dialog, value) {
|
|
281
|
+
const res = await sendCard(client, room, menuCard({ title: dialog.title, markdown: dialog.markdown, menu: dialog.menu, value }));
|
|
282
|
+
return res?.data?.message_id || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function closeDialog(message, dialog, done) {
|
|
286
|
+
await updateCard(client, message, doneCard(dialog.doneTitle, dialog.markdown, done));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The group that needs you is pinned while it waits (acting as you), and unpinned when
|
|
290
|
+
// it no longer does; which ones are pinned is kept in state.json (pinned).
|
|
291
|
+
async function pin(k, room) {
|
|
292
|
+
if (!(await asYou())) return;
|
|
293
|
+
try {
|
|
294
|
+
await userAuth.pin(room);
|
|
295
|
+
const state = read();
|
|
296
|
+
state.pinned = { ...(state.pinned || {}), [k]: room };
|
|
297
|
+
write(state);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
logError(`pin ${room}: ${err.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function unpin(k) {
|
|
304
|
+
const room = read().pinned?.[k];
|
|
305
|
+
if (!room) return;
|
|
306
|
+
try {
|
|
307
|
+
await userAuth.unpin(room);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
logError(`unpin ${room}: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
const state = read();
|
|
312
|
+
delete state.pinned[k];
|
|
313
|
+
write(state);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const pinned = () => Object.keys(read().pinned || {});
|
|
317
|
+
|
|
318
|
+
// In a muted group only a mention rings; a group that is not muted rings anyway. A
|
|
319
|
+
// space in HERDR_SILENT_SPACES gets no mention at all.
|
|
320
|
+
async function ring(k, message, text) {
|
|
321
|
+
const c = read().chats[k];
|
|
322
|
+
if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
|
|
323
|
+
await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function addReaction(message, emoji) {
|
|
327
|
+
const res = await client.im.v1.messageReaction.create({ path: { message_id: message }, data: { reaction_type: { emoji_type: emoji } } });
|
|
328
|
+
return res?.data?.reaction_id;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const markWorking = (message) => addReaction(message, WORKING_EMOJI);
|
|
332
|
+
|
|
333
|
+
async function markDone(message, token) {
|
|
334
|
+
if (token) await client.im.v1.messageReaction.delete({ path: { message_id: message, reaction_id: token } });
|
|
335
|
+
await addReaction(message, DONE_EMOJI);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// An image for a post: uploaded once, shown in the card's markdown.
|
|
339
|
+
async function image(buffer) {
|
|
340
|
+
return `})`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const postFile = (room, { name, data }) => sendFile(client, room, name, data);
|
|
344
|
+
|
|
345
|
+
// A folded card that Feishu refuses goes as a plain post: never lost.
|
|
346
|
+
async function postFolded(room, { title, markdown }) {
|
|
347
|
+
try {
|
|
348
|
+
const res = await sendCard(client, room, foldedCard(title, markdown));
|
|
349
|
+
return res?.data?.message_id || null;
|
|
350
|
+
} catch (err) {
|
|
351
|
+
logWarn(`folded card refused (${err.message}); sent as a post`);
|
|
352
|
+
return post(room, `**${title}**\n\n${markdown}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function download(message, ref, file) {
|
|
357
|
+
await downloadImage(client, message, ref, file);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const reply = (where, markdown) => sendMarkdown(client, where, markdown);
|
|
361
|
+
|
|
362
|
+
async function tellOwner(look) {
|
|
363
|
+
await sendCard(client, cfg.ownerOpenId, card(look), 'open_id');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- the overview, in the direct chat ----------
|
|
367
|
+
|
|
368
|
+
// One overview card (overview.mjs), edited in place; an edit that would change nothing
|
|
369
|
+
// is skipped. Which message it is lives in state.json (overview). An edit that fails is
|
|
370
|
+
// tried again (Feishu sometimes answers 500 for a moment); only when it keeps failing,
|
|
371
|
+
// or when asked for a fresh one (/ls), a new one is sent and the old one is recalled, so
|
|
372
|
+
// the chat never shows two.
|
|
373
|
+
let overviewShown = null;
|
|
374
|
+
async function showOverview(rows, { fresh = false } = {}) {
|
|
375
|
+
if (!cfg.ownerOpenId) return;
|
|
376
|
+
const text = overviewText(rows);
|
|
377
|
+
if (text === overviewShown && !fresh) return;
|
|
378
|
+
const look = overviewCard(rows, Date.now());
|
|
379
|
+
const old = read().overview?.message;
|
|
380
|
+
if (old && !fresh) {
|
|
381
|
+
for (let attempt = 1; attempt <= OVERVIEW_TRIES; attempt += 1) {
|
|
382
|
+
try {
|
|
383
|
+
await updateCard(client, old, look);
|
|
384
|
+
overviewShown = text;
|
|
385
|
+
return;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
logWarn(`overview ${old} not edited (${err.message}); ${attempt < OVERVIEW_TRIES ? 'trying again' : 'sending a new one'}`);
|
|
388
|
+
if (attempt < OVERVIEW_TRIES) await sleep(1000 * 2 ** attempt);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const res = await sendCard(client, cfg.ownerOpenId, look, 'open_id');
|
|
393
|
+
const state = read();
|
|
394
|
+
state.overview = { message: res.data.message_id };
|
|
395
|
+
write(state);
|
|
396
|
+
overviewShown = text;
|
|
397
|
+
if (old) await recallMessage(client, old).catch((err) => logError(`old overview ${old} not recalled (${err.message})`));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------- in ----------
|
|
401
|
+
|
|
402
|
+
// Messages (deduplicated, the owner's only) and card clicks, turned into the core's
|
|
403
|
+
// shape: {room (null: the direct chat), where, message, text, images}.
|
|
404
|
+
const seen = new Set();
|
|
405
|
+
function start({ onMessage, onAction, onReady }) {
|
|
406
|
+
const handleMessage = async (data) => {
|
|
407
|
+
const msg = data.message;
|
|
408
|
+
if (seen.has(msg.message_id)) return;
|
|
409
|
+
seen.add(msg.message_id);
|
|
410
|
+
if (seen.size > 1000) seen.delete(seen.values().next().value);
|
|
411
|
+
const openId = data.sender?.sender_id?.open_id;
|
|
412
|
+
if (!cfg.ownerOpenId) {
|
|
413
|
+
log(`owner not set; message from open_id ${openId}`);
|
|
414
|
+
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Owner is not set. Put this in config.env:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen restart the bot.`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (openId !== cfg.ownerOpenId) {
|
|
418
|
+
logWarn(`ignored message from ${openId}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const parts = messageParts(msg.message_type, msg.content);
|
|
422
|
+
if (!parts) {
|
|
423
|
+
await reply(msg.chat_id, 'Only text and images are passed on.');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const text = parts.text.replace(/@_user_\d+/g, '').trim();
|
|
427
|
+
if (!text && !parts.images.length) return;
|
|
428
|
+
await onMessage({ room: msg.chat_type === 'p2p' ? null : msg.chat_id, where: msg.chat_id, message: msg.message_id, text, images: parts.images });
|
|
429
|
+
};
|
|
430
|
+
// A click must be answered within 3 seconds; the core's answer becomes Feishu's
|
|
431
|
+
// callback response: a toast and, when the card changes, its new form.
|
|
432
|
+
const handleAction = async (data) => {
|
|
433
|
+
if (data.operator?.open_id !== cfg.ownerOpenId) return { toast: { type: 'error', content: 'Only the owner can answer.' } };
|
|
434
|
+
const out = await onAction({ value: data.action?.value || {}, message: data.context?.open_message_id, answer: String(data.action?.form_value?.answer || '') });
|
|
435
|
+
const res = { toast: { type: out.toast.type, content: out.toast.text } };
|
|
436
|
+
if (out.card?.kind === 'done') res.card = { type: 'raw', data: doneCard(out.card.dialog.doneTitle, out.card.dialog.markdown, out.card.done) };
|
|
437
|
+
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 }) };
|
|
438
|
+
return res;
|
|
439
|
+
};
|
|
440
|
+
startEvents(cfg, handleMessage, handleAction, onReady);
|
|
441
|
+
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
442
|
+
asYou().catch((err) => logError(`as you: ${err.message}`));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// `/auth`: the device flow, in the direct chat. The link opens Feishu's own consent
|
|
446
|
+
// page; the bot waits for your yes in the background.
|
|
447
|
+
async function authorize(where) {
|
|
448
|
+
const device = await userAuth.startDeviceFlow();
|
|
449
|
+
await reply(where, `Open this link and allow it (the code is ${device.user_code}):\n\n${device.verification_uri_complete || device.verification_uri}\n\nIt lets the bot mute its groups for you, put them in your labels and pin the one that needs you. Nothing else.`);
|
|
450
|
+
userAuth.waitForToken(device)
|
|
451
|
+
.then(async (t) => {
|
|
452
|
+
await reply(where, `✅ Allowed${t.refresh ? '' : ' (no refresh token: it lasts about two hours)'}. Muting the groups and putting them in your labels now.`);
|
|
453
|
+
youCheckedAt = 0;
|
|
454
|
+
await asYou();
|
|
455
|
+
})
|
|
456
|
+
.catch((err) => reply(where, `Not allowed: ${err.message}`));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Commands only Feishu has, in the direct chat; true when it was one.
|
|
460
|
+
async function command(cmd, args, where) {
|
|
461
|
+
if (cmd !== '/auth') return false;
|
|
462
|
+
await authorize(where);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
name: 'feishu',
|
|
468
|
+
ready: () => Boolean(cfg.ownerOpenId),
|
|
469
|
+
help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
|
|
470
|
+
liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
|
|
471
|
+
split: (text) => chunk(text),
|
|
472
|
+
start,
|
|
473
|
+
command,
|
|
474
|
+
openRoom,
|
|
475
|
+
renameRoom,
|
|
476
|
+
placeRoom,
|
|
477
|
+
endRoom,
|
|
478
|
+
reviveRoom,
|
|
479
|
+
post,
|
|
480
|
+
postCard,
|
|
481
|
+
editCard,
|
|
482
|
+
postDialog,
|
|
483
|
+
closeDialog,
|
|
484
|
+
pin,
|
|
485
|
+
unpin,
|
|
486
|
+
pinned,
|
|
487
|
+
ring,
|
|
488
|
+
markWorking,
|
|
489
|
+
markDone,
|
|
490
|
+
image,
|
|
491
|
+
postFile,
|
|
492
|
+
postFolded,
|
|
493
|
+
foldedBytes: 20000, // a card may hold 30 KB
|
|
494
|
+
download,
|
|
495
|
+
reply,
|
|
496
|
+
tellOwner,
|
|
497
|
+
showOverview,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
@@ -15,14 +15,14 @@ const chatLink = (chat) => `https://applink.feishu.cn/client/chat/open?openChatI
|
|
|
15
15
|
const clock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
16
16
|
const escape = (text) => text.replace(/[\\`*_[\]#>~|]/g, '\\$&');
|
|
17
17
|
|
|
18
|
-
// rows: [{name, address, state, kind, program, since,
|
|
18
|
+
// rows: [{name, address, state, kind, program, since, room}]; state is the hc status
|
|
19
19
|
// word, or none. The address is what `/kill` takes.
|
|
20
20
|
export function overviewText(rows) {
|
|
21
21
|
const sorted = [...rows].sort((a, b) => (STATES[a.state] || OTHER).rank - (STATES[b.state] || OTHER).rank || a.name.localeCompare(b.name));
|
|
22
22
|
if (!sorted.length) return 'No terminal is shown in herdr-app.';
|
|
23
23
|
return sorted.map((r) => {
|
|
24
24
|
const s = STATES[r.state] || OTHER;
|
|
25
|
-
const name = r.
|
|
25
|
+
const name = r.room ? `[${escape(r.name)}](${chatLink(r.room)})` : escape(r.name);
|
|
26
26
|
const word = s === OTHER ? r.program || 'no status' : `${s.word}${r.state === 'blocked' && r.kind ? ` (${r.kind})` : ''}`;
|
|
27
27
|
return `${s.icon} **${name}** · ${escape(word)}${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
|
|
28
28
|
}).join('\n');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The chat apps herdrchat can mirror into; HERDRCHAT_CHAT in config.env picks one per
|
|
2
|
+
// profile (feishu by default). To add one, see lib/chats/contract.mjs and
|
|
3
|
+
// docs/architecture.md.
|
|
4
|
+
import * as feishu from './feishu/index.mjs';
|
|
5
|
+
import * as telegram from './telegram/index.mjs';
|
|
6
|
+
import { checkChat } from './contract.mjs';
|
|
7
|
+
|
|
8
|
+
const CHATS = { feishu, telegram };
|
|
9
|
+
|
|
10
|
+
export const chatNames = () => Object.keys(CHATS);
|
|
11
|
+
|
|
12
|
+
export function chatModule(name) {
|
|
13
|
+
const mod = CHATS[name];
|
|
14
|
+
if (!mod) throw new Error(`HERDRCHAT_CHAT "${name}": known apps are ${chatNames().join(', ')}`);
|
|
15
|
+
return mod;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createChat(name, ctx) {
|
|
19
|
+
return checkChat(chatModule(name).create(ctx));
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The Telegram Bot API over plain HTTPS (core.telegram.org/bots/api): one call is
|
|
2
|
+
// POST https://api.telegram.org/bot<token>/<method>. Updates come by long polling
|
|
3
|
+
// (getUpdates), so no public URL is needed.
|
|
4
|
+
//
|
|
5
|
+
// Telegram asks for at most one message a second in one chat and answers 429 with
|
|
6
|
+
// retry_after when a bot goes faster, so everything that sends or edits waits its turn
|
|
7
|
+
// in one queue, SEND_GAP_MS apart, and a 429 waits as long as Telegram says. Reading
|
|
8
|
+
// calls (getUpdates, getFile) and answering a click do not wait.
|
|
9
|
+
const API = 'https://api.telegram.org';
|
|
10
|
+
const SEND_GAP_MS = 1050;
|
|
11
|
+
const TRIES = 4;
|
|
12
|
+
|
|
13
|
+
export class TelegramError extends Error {
|
|
14
|
+
constructor(method, body) {
|
|
15
|
+
super(`telegram ${method} ${body.error_code}: ${body.description}`);
|
|
16
|
+
this.code = body.error_code;
|
|
17
|
+
this.retryAfter = body.parameters?.retry_after;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createClient(token, { log = () => {} } = {}) {
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
async function call(method, params = {}, { timeoutMs = 30000 } = {}) {
|
|
25
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
26
|
+
const isFile = params instanceof FormData;
|
|
27
|
+
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: isFile ? {} : { 'content-type': 'application/json' },
|
|
30
|
+
body: isFile ? params : JSON.stringify(params),
|
|
31
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
32
|
+
});
|
|
33
|
+
const body = await res.json().catch(() => ({ ok: false, error_code: res.status, description: res.statusText }));
|
|
34
|
+
if (body.ok) return body.result;
|
|
35
|
+
const err = new TelegramError(method, body);
|
|
36
|
+
if (err.code === 429 && err.retryAfter && attempt < TRIES) {
|
|
37
|
+
log(`telegram ${method}: too fast, waiting ${err.retryAfter} s`);
|
|
38
|
+
await sleep(err.retryAfter * 1000);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let queue = Promise.resolve();
|
|
46
|
+
function queued(method, params) {
|
|
47
|
+
const run = queue.then(() => call(method, params));
|
|
48
|
+
queue = run.catch(() => {}).then(() => sleep(SEND_GAP_MS));
|
|
49
|
+
return run;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A photo from a buffer, as a multipart upload (at most 10 MB).
|
|
53
|
+
function sendPhoto(chatId, threadId, buffer, { caption, silent = true } = {}) {
|
|
54
|
+
const form = new FormData();
|
|
55
|
+
form.append('chat_id', String(chatId));
|
|
56
|
+
if (threadId) form.append('message_thread_id', String(threadId));
|
|
57
|
+
if (caption) form.append('caption', caption);
|
|
58
|
+
form.append('disable_notification', String(silent));
|
|
59
|
+
form.append('photo', new Blob([buffer]), 'image.png');
|
|
60
|
+
return queued('sendPhoto', form);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A file (sendDocument: at most 50 MB by upload), silent.
|
|
64
|
+
function sendDocument(chatId, threadId, name, buffer, { caption } = {}) {
|
|
65
|
+
const form = new FormData();
|
|
66
|
+
form.append('chat_id', String(chatId));
|
|
67
|
+
if (threadId) form.append('message_thread_id', String(threadId));
|
|
68
|
+
if (caption) form.append('caption', caption.slice(0, 1024));
|
|
69
|
+
form.append('disable_notification', 'true');
|
|
70
|
+
form.append('document', new Blob([buffer]), name);
|
|
71
|
+
return queued('sendDocument', form);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A file someone sent, by its file_id (bots may download up to 20 MB).
|
|
75
|
+
async function download(fileId) {
|
|
76
|
+
const file = await call('getFile', { file_id: fileId });
|
|
77
|
+
const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
|
|
78
|
+
if (!res.ok) throw new Error(`telegram file ${file.file_path}: ${res.status}`);
|
|
79
|
+
return Buffer.from(await res.arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { call, queued, sendPhoto, sendDocument, download };
|
|
83
|
+
}
|