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,83 @@
|
|
|
1
|
+
// The core's markdown, as Telegram's HTML parse mode shows it (core.telegram.org/bots/api,
|
|
2
|
+
// "Formatting options"): only b, i, code, pre, a and blockquote are used; every other
|
|
3
|
+
// <, > and & is escaped. Text is cut so each piece stays under the 4096 characters a
|
|
4
|
+
// message may hold after parsing.
|
|
5
|
+
const LIMIT = 3800; // room for the tags around a piece
|
|
6
|
+
|
|
7
|
+
export const escapeHtml = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
8
|
+
|
|
9
|
+
// Inline marks on one line of plain text (not inside a code block).
|
|
10
|
+
function inline(line) {
|
|
11
|
+
const codes = [];
|
|
12
|
+
let s = line.replace(/`([^`\n]+)`/g, (_, c) => `\u0000${codes.push(c) - 1}\u0000`);
|
|
13
|
+
s = escapeHtml(s);
|
|
14
|
+
s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, text, url) => `<a href="${url.replace(/"/g, '"')}">${text}</a>`);
|
|
15
|
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
|
16
|
+
s = s.replace(/(^|[^\w*])_([^_\n]+)_(?=$|[^\w])/g, '$1<i>$2</i>');
|
|
17
|
+
s = s.replace(/\\([\\`*_[\]#>~|])/g, '$1'); // the core's escapes: the mark itself
|
|
18
|
+
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escapeHtml(codes[Number(i)])}</code>`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toHtml(markdown) {
|
|
22
|
+
const out = [];
|
|
23
|
+
let fence = null; // lines of the open code block
|
|
24
|
+
let quote = null; // lines of the open > quote
|
|
25
|
+
const endQuote = () => {
|
|
26
|
+
if (quote) out.push(`<blockquote>${quote.join('\n')}</blockquote>`);
|
|
27
|
+
quote = null;
|
|
28
|
+
};
|
|
29
|
+
for (const line of (markdown || '').split('\n')) {
|
|
30
|
+
if (/^\s*```/.test(line)) {
|
|
31
|
+
if (fence) {
|
|
32
|
+
out.push(`<pre>${escapeHtml(fence.join('\n'))}</pre>`);
|
|
33
|
+
fence = null;
|
|
34
|
+
} else {
|
|
35
|
+
endQuote();
|
|
36
|
+
fence = [];
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (fence) {
|
|
41
|
+
fence.push(line);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const q = line.match(/^> ?(.*)$/);
|
|
45
|
+
if (q) {
|
|
46
|
+
(quote ||= []).push(inline(q[1]));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
endQuote();
|
|
50
|
+
out.push(inline(line));
|
|
51
|
+
}
|
|
52
|
+
if (fence) out.push(`<pre>${escapeHtml(fence.join('\n'))}</pre>`);
|
|
53
|
+
endQuote();
|
|
54
|
+
return out.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Markdown cut on line boundaries into pieces of at most `limit` characters; a code
|
|
58
|
+
// block cut in two is closed and opened again, so each piece converts on its own.
|
|
59
|
+
export function split(markdown, limit = LIMIT) {
|
|
60
|
+
const pieces = [];
|
|
61
|
+
let current = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
let open = false;
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (!current.length) return;
|
|
66
|
+
pieces.push(open ? [...current, '```'].join('\n') : current.join('\n'));
|
|
67
|
+
current = open ? ['```'] : [];
|
|
68
|
+
size = current.length ? 4 : 0;
|
|
69
|
+
};
|
|
70
|
+
for (const raw of (markdown || '').split('\n')) {
|
|
71
|
+
for (let line = raw; ; ) {
|
|
72
|
+
const piece = line.length > limit ? line.slice(0, limit) : line;
|
|
73
|
+
if (size + piece.length + 1 > limit && current.length) flush();
|
|
74
|
+
current.push(piece);
|
|
75
|
+
size += piece.length + 1;
|
|
76
|
+
if (/^\s*```/.test(piece)) open = !open;
|
|
77
|
+
if (line.length <= limit) break;
|
|
78
|
+
line = line.slice(limit);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (current.length) pieces.push(current.join('\n'));
|
|
82
|
+
return pieces.length ? pieces : [''];
|
|
83
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// herdrchat's Telegram side. A bot cannot make groups (no such Bot API method), so each
|
|
2
|
+
// herdr-app tab is a topic in your private chat with the bot (topic mode must be on in
|
|
3
|
+
// @BotFather; getMe says has_topics_enabled), and the chat's main thread is the direct
|
|
4
|
+
// chat: the overview and the commands. Everything the bot posts is silent (a
|
|
5
|
+
// notification without sound); `ring` sends a short reply that does sound, for "needs
|
|
6
|
+
// you" and "done". The core (lib/core/engine.mjs) calls only what is returned here; the
|
|
7
|
+
// contract is lib/chats/contract.mjs.
|
|
8
|
+
//
|
|
9
|
+
// What Telegram does differently, and what stands in:
|
|
10
|
+
// ended rooms closeForumTopic is documented for supergroups only, so an ended
|
|
11
|
+
// topic's name gets a 🔚 in front, taken off when it is live again
|
|
12
|
+
// images a message cannot hold one, so each goes as its own photo after the text
|
|
13
|
+
// buttons callback_data holds 64 bytes, so a button carries a short id and the
|
|
14
|
+
// dialog it answers is kept here
|
|
15
|
+
// a free answer no text box on a message: you type the answer in the topic
|
|
16
|
+
// pin a topic cannot be pinned; nothing is done
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import { createClient } from './client.mjs';
|
|
19
|
+
import { escapeHtml, split, toHtml } from './format.mjs';
|
|
20
|
+
|
|
21
|
+
export const requires = ['TELEGRAM_BOT_TOKEN'];
|
|
22
|
+
|
|
23
|
+
const POLL_SECONDS = 50;
|
|
24
|
+
const RETRY_MS = 3000;
|
|
25
|
+
const ENDED_MARK = '🔚 ';
|
|
26
|
+
const NAME_MAX = 128;
|
|
27
|
+
const BUTTON_TEXT = 60;
|
|
28
|
+
const BUTTONS_KEPT = 300;
|
|
29
|
+
const WORKING_EMOJI = '👀';
|
|
30
|
+
const DONE_EMOJI = '👌';
|
|
31
|
+
// the topic icon colors Telegram allows (createForumTopic icon_color), one per space
|
|
32
|
+
const ICON_COLORS = [7322096, 16766590, 13338331, 9367192, 16749490, 16478047];
|
|
33
|
+
const STATES = { blocked: '⚠️', error: '🔴', working: '⌛️', done: '✅', idle: '❓' };
|
|
34
|
+
const RANK = { blocked: 0, error: 1, working: 2, done: 3, idle: 4 };
|
|
35
|
+
const COMMANDS = [
|
|
36
|
+
{ command: 'ls', description: 'a new overview of every terminal' },
|
|
37
|
+
{ command: 'new', description: '<session> <space>/<tab> [command]: start a terminal' },
|
|
38
|
+
{ command: 'kill', description: '<session> <address>: end a terminal' },
|
|
39
|
+
{ command: 'screen', description: 'in a topic: its terminal\'s screen' },
|
|
40
|
+
{ command: 'answer', description: 'in a topic: <text> answers what it waits on' },
|
|
41
|
+
{ command: 'esc', description: 'in a topic: press Esc' },
|
|
42
|
+
{ command: 'history', description: 'in a topic: the conversation so far, as a file' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
47
|
+
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
48
|
+
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
49
|
+
|
|
50
|
+
export function create({ cfg, store, log, logWarn, logError }) {
|
|
51
|
+
const env = cfg.env || {};
|
|
52
|
+
const owner = String(env.TELEGRAM_OWNER_ID || '').trim();
|
|
53
|
+
const tg = createClient(env.TELEGRAM_BOT_TOKEN, { log: logWarn });
|
|
54
|
+
const { read, write } = store;
|
|
55
|
+
let username = '';
|
|
56
|
+
|
|
57
|
+
// ---------- sending ----------
|
|
58
|
+
|
|
59
|
+
// Images a post holds (from `image`) are kept here until the post goes out; the text
|
|
60
|
+
// carries a mark where each was.
|
|
61
|
+
const images = new Map();
|
|
62
|
+
let imageN = 0;
|
|
63
|
+
const IMAGE_MARK = /img(\d+)/g;
|
|
64
|
+
function takeImages(markdown) {
|
|
65
|
+
const found = [...(markdown || '').matchAll(IMAGE_MARK)].map((m) => m[1]);
|
|
66
|
+
return { text: (markdown || '').replace(IMAGE_MARK, '').replace(/\n{3,}/g, '\n\n').trim(), found };
|
|
67
|
+
}
|
|
68
|
+
async function sendImages(room, ids) {
|
|
69
|
+
let last = null;
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const buffer = images.get(id);
|
|
72
|
+
if (!buffer) continue;
|
|
73
|
+
images.delete(id);
|
|
74
|
+
last = String((await tg.sendPhoto(owner, room, buffer)).message_id);
|
|
75
|
+
}
|
|
76
|
+
return last;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// HTML that Telegram refuses (an entity it cannot parse) goes again as plain text.
|
|
80
|
+
async function sendText(chatId, threadId, html, plain, { silent = true, extra = {} } = {}) {
|
|
81
|
+
const base = { chat_id: chatId, ...(threadId ? { message_thread_id: Number(threadId) } : {}), disable_notification: silent, link_preview_options: { is_disabled: true }, ...extra };
|
|
82
|
+
try {
|
|
83
|
+
return await tg.queued('sendMessage', { ...base, text: html || '(empty)', parse_mode: 'HTML' });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code !== 400 || !/parse|entit/i.test(err.message)) throw err;
|
|
86
|
+
logWarn(`telegram: HTML refused, sent as text (${err.message})`);
|
|
87
|
+
return tg.queued('sendMessage', { ...base, text: plain || '(empty)' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function post(room, markdown) {
|
|
92
|
+
const { text, found } = takeImages(markdown);
|
|
93
|
+
let last = null;
|
|
94
|
+
if (text) for (const piece of split(text)) last = String((await sendText(owner, room, toHtml(piece), piece)).message_id);
|
|
95
|
+
return (await sendImages(room, found)) || last;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lookHtml = ({ title, markdown }) => `<b>${escapeHtml(title)}</b>\n\n${toHtml(markdown)}`;
|
|
99
|
+
|
|
100
|
+
async function postCard(room, look) {
|
|
101
|
+
const { text, found } = takeImages(look.markdown);
|
|
102
|
+
const message = String((await sendText(owner, room, lookHtml({ ...look, markdown: text }), `${look.title}\n\n${text}`)).message_id);
|
|
103
|
+
await sendImages(room, found);
|
|
104
|
+
return message;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// An edit that changes nothing is refused by Telegram as "not modified": that is fine.
|
|
108
|
+
async function edit(message, html, extra = {}) {
|
|
109
|
+
try {
|
|
110
|
+
await tg.queued('editMessageText', { chat_id: owner, message_id: Number(message), text: html, parse_mode: 'HTML', link_preview_options: { is_disabled: true }, ...extra });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (!/not modified/i.test(err.message)) throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The room an edited card is in is not known here, so images added to it go to the
|
|
117
|
+
// room the card was posted in, kept by message.
|
|
118
|
+
const cardRoom = new Map();
|
|
119
|
+
async function editCard(message, look) {
|
|
120
|
+
const { text, found } = takeImages(look.markdown);
|
|
121
|
+
await edit(message, lookHtml({ ...look, markdown: text }));
|
|
122
|
+
if (found.length) await sendImages(cardRoom.get(String(message)) || null, found);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- dialogs ----------
|
|
126
|
+
|
|
127
|
+
// A button carries "<id>:<pick>"; the id names the dialog's value, kept here (a click
|
|
128
|
+
// after a restart finds nothing and is told the dialog is gone).
|
|
129
|
+
const buttons = new Map();
|
|
130
|
+
let buttonN = 0;
|
|
131
|
+
function keyboard(dialog, value) {
|
|
132
|
+
const id = (buttonN += 1).toString(36);
|
|
133
|
+
buttons.set(id, { value, labels: Object.fromEntries(dialog.menu.options.map((o) => [o.n, o.label])) });
|
|
134
|
+
if (buttons.size > BUTTONS_KEPT) buttons.delete(buttons.keys().next().value);
|
|
135
|
+
const rows = [];
|
|
136
|
+
for (const o of dialog.menu.options) {
|
|
137
|
+
if (o === dialog.menu.free) continue;
|
|
138
|
+
const box = o.checked === null || o.checked === undefined ? '' : o.checked ? '☑ ' : '☐ ';
|
|
139
|
+
rows.push([{ text: cut(`${o.n}. ${box}${o.label}`, BUTTON_TEXT), callback_data: `${id}:${o.n}` }]);
|
|
140
|
+
}
|
|
141
|
+
if (dialog.menu.multi) rows.push([{ text: 'Next ⇥', callback_data: `${id}:tab` }]);
|
|
142
|
+
rows.push([{ text: 'Esc (cancel)', callback_data: `${id}:esc` }]);
|
|
143
|
+
return { inline_keyboard: rows };
|
|
144
|
+
}
|
|
145
|
+
const dialogHtml = (dialog) => `<b>${escapeHtml(dialog.title)}</b>\n\n${toHtml(dialog.markdown)}${dialog.menu?.free ? `\n\n<i>Or type your answer here (${escapeHtml(dialog.menu.free.label)}).</i>` : ''}`;
|
|
146
|
+
const doneHtml = (dialog, done) => `<b>${escapeHtml(dialog.doneTitle)}</b>\n\n${toHtml(dialog.markdown)}\n\n<b>${escapeHtml(done)}</b>`;
|
|
147
|
+
|
|
148
|
+
async function postDialog(room, dialog, value) {
|
|
149
|
+
const res = await sendText(owner, room, dialogHtml(dialog), dialog.markdown, { extra: { reply_markup: keyboard(dialog, value) } });
|
|
150
|
+
return String(res.message_id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function closeDialog(message, dialog, done) {
|
|
154
|
+
await edit(message, doneHtml(dialog, done), { reply_markup: { inline_keyboard: [] } });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------- rooms ----------
|
|
158
|
+
|
|
159
|
+
function iconOf(space) {
|
|
160
|
+
const state = read();
|
|
161
|
+
const spaces = state.spaces || {};
|
|
162
|
+
if (spaces[space]?.icon) return spaces[space].icon;
|
|
163
|
+
const used = Object.values(spaces).map((x) => x.icon);
|
|
164
|
+
const icon = ICON_COLORS.find((c) => !used.includes(c)) || ICON_COLORS[used.length % ICON_COLORS.length];
|
|
165
|
+
state.spaces = { ...spaces, [space]: { icon } };
|
|
166
|
+
write(state);
|
|
167
|
+
return icon;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function openRoom({ name }) {
|
|
171
|
+
const topic = await tg.queued('createForumTopic', { chat_id: owner, name: cut(name, NAME_MAX), icon_color: iconOf(spaceOfName(name)) });
|
|
172
|
+
return String(topic.message_thread_id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const renameTopic = (room, name) => tg.queued('editForumTopic', { chat_id: owner, message_thread_id: Number(room), name: cut(name, NAME_MAX) });
|
|
176
|
+
|
|
177
|
+
async function renameRoom(room, name) {
|
|
178
|
+
const c = Object.values(read().chats).find((x) => x.chat === room);
|
|
179
|
+
await renameTopic(room, c?.ended ? ENDED_MARK + name : name);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const placeRoom = async () => {}; // a topic's color is set when it is made
|
|
183
|
+
async function endRoom(k) {
|
|
184
|
+
const c = read().chats[k];
|
|
185
|
+
if (c) await renameTopic(c.chat, ENDED_MARK + c.name).catch((err) => logError(`ended mark on ${c.name}: ${err.message}`));
|
|
186
|
+
}
|
|
187
|
+
async function reviveRoom(k) {
|
|
188
|
+
const c = read().chats[k];
|
|
189
|
+
if (c) await renameTopic(c.chat, c.name).catch((err) => logError(`live again ${c.name}: ${err.message}`));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------- attention ----------
|
|
193
|
+
|
|
194
|
+
const pins = new Set();
|
|
195
|
+
const pin = async (k) => { pins.add(k); };
|
|
196
|
+
const unpin = async (k) => { pins.delete(k); };
|
|
197
|
+
|
|
198
|
+
// Posts are silent; this reply sounds. A space in HERDR_SILENT_SPACES never sounds.
|
|
199
|
+
async function ring(k, message, text) {
|
|
200
|
+
const c = read().chats[k];
|
|
201
|
+
if (!message || !c || inSpaces(cfg.silent, spaceOfName(c.name))) return;
|
|
202
|
+
await sendText(owner, c.chat, escapeHtml(text), text, { silent: false, extra: { reply_parameters: { message_id: Number(message), allow_sending_without_reply: true } } })
|
|
203
|
+
.catch((err) => logError(`ring: ${err.message}`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const react = (message, emoji) => tg.queued('setMessageReaction', { chat_id: owner, message_id: Number(message), reaction: [{ type: 'emoji', emoji }] });
|
|
207
|
+
async function markWorking(message) {
|
|
208
|
+
await react(message, WORKING_EMOJI);
|
|
209
|
+
return WORKING_EMOJI;
|
|
210
|
+
}
|
|
211
|
+
const markDone = async (message) => { await react(message, DONE_EMOJI); }; // one reaction per message: it replaces
|
|
212
|
+
|
|
213
|
+
async function image(buffer) {
|
|
214
|
+
const id = String((imageN += 1));
|
|
215
|
+
images.set(id, buffer);
|
|
216
|
+
return `img${id}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function postFile(room, { name, data, caption }) {
|
|
220
|
+
return String((await tg.sendDocument(owner, room, name, data, { caption })).message_id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// An expandable quote: two or three lines shown, the rest on a tap. A quote cannot hold
|
|
224
|
+
// code blocks or another quote, so the text goes in plain, escaped.
|
|
225
|
+
async function postFolded(room, { title, markdown }) {
|
|
226
|
+
const html = `<b>${escapeHtml(title)}</b>\n<blockquote expandable>${escapeHtml(markdown)}</blockquote>`;
|
|
227
|
+
return String((await sendText(owner, room, html, `${title}\n\n${markdown}`)).message_id);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function download(message, ref, file) {
|
|
231
|
+
fs.writeFileSync(file, await tg.download(ref));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `where` is "<chat>:<topic>" (topic empty: the main thread)
|
|
235
|
+
async function reply(where, markdown) {
|
|
236
|
+
const [chatId, thread] = String(where).split(':');
|
|
237
|
+
let last = null;
|
|
238
|
+
for (const piece of split(markdown)) last = String((await sendText(chatId, thread || null, toHtml(piece), piece)).message_id);
|
|
239
|
+
return last;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function tellOwner(look) {
|
|
243
|
+
if (!owner) return;
|
|
244
|
+
await sendText(owner, null, lookHtml(look), `${look.title}\n\n${look.markdown}`, { silent: false });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- the overview, in the main thread ----------
|
|
248
|
+
|
|
249
|
+
function overviewHtml(rows) {
|
|
250
|
+
const sorted = [...rows].sort((a, b) => (RANK[a.state] ?? 5) - (RANK[b.state] ?? 5) || a.name.localeCompare(b.name));
|
|
251
|
+
const waiting = rows.filter((r) => r.state === 'blocked').length;
|
|
252
|
+
const head = `<b>${waiting ? `⚠️ ${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals</b>`;
|
|
253
|
+
if (!sorted.length) return `${head}\n\nNo terminal is shown in herdr-app.`;
|
|
254
|
+
const lines = sorted.map((r) => {
|
|
255
|
+
const name = r.room && username ? `<a href="https://t.me/${username}/${r.room}">${escapeHtml(r.name)}</a>` : escapeHtml(r.name);
|
|
256
|
+
const word = STATES[r.state] ? `${r.state === 'blocked' ? 'needs you' : r.state}${r.state === 'blocked' && r.kind ? ` (${r.kind})` : ''}` : r.program || 'no status';
|
|
257
|
+
const at = r.since ? ` · since ${new Date(r.since).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}` : '';
|
|
258
|
+
return `${STATES[r.state] || '❓'} <b>${name}</b> · ${escapeHtml(word)}${at} · <code>${escapeHtml(r.address)}</code>`;
|
|
259
|
+
});
|
|
260
|
+
return `${head}\n\n${lines.join('\n')}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// One overview message, edited in place (an edit never sounds); a fresh one (/ls) or
|
|
264
|
+
// one that cannot be edited is sent again and the old one is deleted.
|
|
265
|
+
let overviewShown = null;
|
|
266
|
+
async function showOverview(rows, { fresh = false } = {}) {
|
|
267
|
+
if (!owner) return;
|
|
268
|
+
const html = overviewHtml(rows);
|
|
269
|
+
if (html === overviewShown && !fresh) return;
|
|
270
|
+
const old = read().overview?.message;
|
|
271
|
+
if (old && !fresh) {
|
|
272
|
+
try {
|
|
273
|
+
await edit(old, html);
|
|
274
|
+
overviewShown = html;
|
|
275
|
+
return;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
logWarn(`overview ${old} not edited (${err.message}); sending a new one`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const res = await sendText(owner, null, html, html.replace(/<[^>]+>/g, ''));
|
|
281
|
+
const state = read();
|
|
282
|
+
state.overview = { message: String(res.message_id) };
|
|
283
|
+
write(state);
|
|
284
|
+
overviewShown = html;
|
|
285
|
+
if (old) await tg.queued('deleteMessage', { chat_id: owner, message_id: Number(old) }).catch(() => {});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------- in ----------
|
|
289
|
+
|
|
290
|
+
function largestPhoto(photo = []) {
|
|
291
|
+
return [...photo].sort((a, b) => (b.width * b.height) - (a.width * a.height) || (b.file_size || 0) - (a.file_size || 0))[0]?.file_id;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function handleMessage(m, onMessage) {
|
|
295
|
+
if (m.from?.is_bot) return; // the bot's own service messages (a topic it made)
|
|
296
|
+
const from = String(m.from?.id || '');
|
|
297
|
+
const where = `${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`;
|
|
298
|
+
if (!owner) {
|
|
299
|
+
log(`owner not set; message from user id ${from}`);
|
|
300
|
+
if (m.chat.type === 'private') await reply(where, `Owner is not set. Put this in config.env:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen restart the bot.`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (from !== owner || String(m.chat.id) !== owner) {
|
|
304
|
+
logWarn(`ignored message from ${from} in ${m.chat.id}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const text = String(m.text || m.caption || '').replace(new RegExp(`^(/\\w+)@${username}\\b`, 'i'), '$1').trim();
|
|
308
|
+
const photo = largestPhoto(m.photo);
|
|
309
|
+
if (!text && !photo) return; // a service message (a topic made or renamed)
|
|
310
|
+
await onMessage({ room: m.is_topic_message ? String(m.message_thread_id) : null, where, message: String(m.message_id), text, images: photo ? [photo] : [] });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A click: the button's dialog and pick, the core's answer, then the card as it is now
|
|
314
|
+
// and the toast.
|
|
315
|
+
async function handleClick(q, onAction) {
|
|
316
|
+
const answer = (text) => tg.call('answerCallbackQuery', { callback_query_id: q.id, text: (text || '').slice(0, 200) }).catch(() => {});
|
|
317
|
+
if (String(q.from?.id) !== owner) return answer('Only the owner can answer.');
|
|
318
|
+
const [id, pickRaw] = String(q.data || '').split(':');
|
|
319
|
+
const kept = buttons.get(id);
|
|
320
|
+
const message = String(q.message?.message_id || '');
|
|
321
|
+
if (!kept) {
|
|
322
|
+
await edit(message, `${q.message?.text ? escapeHtml(q.message.text) : ''}\n\n<b>This dialog is gone (the bot restarted); see the newest card.</b>`, { reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
323
|
+
return answer('This dialog is gone.');
|
|
324
|
+
}
|
|
325
|
+
const pick = /^\d+$/.test(pickRaw) ? Number(pickRaw) : pickRaw;
|
|
326
|
+
const out = await onAction({ value: { ...kept.value, pick, label: kept.labels[pick] }, message, answer: '' });
|
|
327
|
+
if (out.card?.kind === 'done') await edit(message, doneHtml(out.card.dialog, out.card.done), { reply_markup: { inline_keyboard: [] } }).catch((err) => logError(`dialog card: ${err.message}`));
|
|
328
|
+
if (out.card?.kind === 'dialog') await edit(message, dialogHtml(out.card.dialog), { reply_markup: keyboard(out.card.dialog, out.card.value) }).catch((err) => logError(`dialog card: ${err.message}`));
|
|
329
|
+
await answer(out.toast.text);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Long polling: getUpdates holds up to POLL_SECONDS for news; a failure waits RETRY_MS
|
|
333
|
+
// and tries again (409: another copy of the bot polls the same token).
|
|
334
|
+
function start({ onMessage, onAction, onReady = () => {} }) {
|
|
335
|
+
let offset = 0;
|
|
336
|
+
let connected = false;
|
|
337
|
+
(async () => {
|
|
338
|
+
for (;;) {
|
|
339
|
+
try {
|
|
340
|
+
if (!username) {
|
|
341
|
+
const me = await tg.call('getMe');
|
|
342
|
+
username = me.username;
|
|
343
|
+
if (!me.has_topics_enabled) logWarn(`telegram: topic mode is off for @${username}; turn it on in @BotFather (Bot Settings), or no room can be made`);
|
|
344
|
+
await tg.call('setMyCommands', { commands: COMMANDS }).catch((err) => logWarn(`telegram commands: ${err.message}`));
|
|
345
|
+
}
|
|
346
|
+
// the token works and Telegram answers: in (the first poll may hold for a while)
|
|
347
|
+
if (!connected) {
|
|
348
|
+
connected = true;
|
|
349
|
+
console.log(`[telegram] connected as @${username}`);
|
|
350
|
+
onReady();
|
|
351
|
+
}
|
|
352
|
+
const updates = await tg.call('getUpdates', { offset, timeout: POLL_SECONDS, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (POLL_SECONDS + 15) * 1000 });
|
|
353
|
+
for (const u of updates) {
|
|
354
|
+
offset = u.update_id + 1;
|
|
355
|
+
try {
|
|
356
|
+
if (u.message) await handleMessage(u.message, onMessage);
|
|
357
|
+
if (u.callback_query) await handleClick(u.callback_query, onAction);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
logError(`telegram update ${u.update_id}:`, err);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
logError(`telegram poll: ${err.message}`);
|
|
364
|
+
await sleep(RETRY_MS);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// the room a card was posted in, for images an edit adds
|
|
371
|
+
const postCardKept = async (room, look) => {
|
|
372
|
+
const message = await postCard(room, look);
|
|
373
|
+
cardRoom.set(message, room);
|
|
374
|
+
if (cardRoom.size > 500) cardRoom.delete(cardRoom.keys().next().value);
|
|
375
|
+
return message;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
name: 'telegram',
|
|
380
|
+
ready: () => Boolean(owner),
|
|
381
|
+
help: [],
|
|
382
|
+
liveBytes: 3500, // a message holds 4096 characters after parsing
|
|
383
|
+
split: (text) => split(text),
|
|
384
|
+
start,
|
|
385
|
+
command: async () => false,
|
|
386
|
+
openRoom,
|
|
387
|
+
renameRoom,
|
|
388
|
+
placeRoom,
|
|
389
|
+
endRoom,
|
|
390
|
+
reviveRoom,
|
|
391
|
+
post,
|
|
392
|
+
postCard: postCardKept,
|
|
393
|
+
editCard,
|
|
394
|
+
postDialog,
|
|
395
|
+
closeDialog,
|
|
396
|
+
pin,
|
|
397
|
+
unpin,
|
|
398
|
+
pinned: () => [...pins],
|
|
399
|
+
ring,
|
|
400
|
+
markWorking,
|
|
401
|
+
markDone,
|
|
402
|
+
image,
|
|
403
|
+
postFile,
|
|
404
|
+
postFolded,
|
|
405
|
+
foldedBytes: 3500, // a message holds 4096 characters after parsing
|
|
406
|
+
download,
|
|
407
|
+
reply,
|
|
408
|
+
tellOwner,
|
|
409
|
+
showOverview,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// user-token.json (your token after /auth), logs/
|
|
7
7
|
// HERDRCHAT_HOME puts one profile in one folder of your choice instead: config.env and
|
|
8
8
|
// state/ inside it. The folders are made private (mode 700). The settings in config.env:
|
|
9
|
+
// HERDRCHAT_CHAT the chat app: feishu (default); its keys follow
|
|
9
10
|
// FEISHU_APP_ID, FEISHU_APP_SECRET herdrchat's Feishu app
|
|
10
11
|
// FEISHU_OWNER_OPEN_ID your open_id for that app; the only person it listens to
|
|
12
|
+
// TELEGRAM_BOT_TOKEN herdrchat's Telegram bot (topic mode on, in @BotFather)
|
|
13
|
+
// TELEGRAM_OWNER_ID your Telegram user id; the only person it listens to
|
|
11
14
|
// HERDR_SESSIONS hc sessions to mirror, comma-separated
|
|
12
15
|
// HERDR_MUTED_SPACES spaces whose groups are muted once /auth is done (only
|
|
13
16
|
// an @ rings there): `all` (default), empty for none, or
|
|
@@ -18,6 +21,9 @@
|
|
|
18
21
|
// names (tenant tags: session or machine, and "ended")
|
|
19
22
|
// HERDR_USER_TAGS `on` (default) or `off`: your sidebar labels, once
|
|
20
23
|
// /auth is done
|
|
24
|
+
// HERDR_HISTORY what a new room gets of the conversation before it:
|
|
25
|
+
// `file,folded` (default: all of it as a file, the newest
|
|
26
|
+
// part in a folded post), `file`, `folded`, or empty / `none`
|
|
21
27
|
import fs from 'node:fs';
|
|
22
28
|
import os from 'node:os';
|
|
23
29
|
import path from 'node:path';
|
|
@@ -62,6 +68,16 @@ export function spacesOf(value, fallback) {
|
|
|
62
68
|
return { all: false, spaces: v.split(',').map((s) => s.trim()).filter(Boolean) };
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
// Which of `known` a comma-separated list names: {name: true|false}; empty or `none` is
|
|
72
|
+
// none, `fallback` is what an unset variable means. An unknown name is an error.
|
|
73
|
+
export function partsOf(value, fallback, known, key = 'value') {
|
|
74
|
+
const v = (value ?? fallback).trim();
|
|
75
|
+
const named = v === '' || v === 'none' ? [] : v.split(',').map((s) => s.trim()).filter(Boolean);
|
|
76
|
+
const unknown = named.filter((n) => !known.includes(n));
|
|
77
|
+
if (unknown.length) throw new Error(`${key}: ${unknown.join(', ')} is not one of ${known.join(', ')}`);
|
|
78
|
+
return Object.fromEntries(known.map((k) => [k, named.includes(k)]));
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
// on / off (also true, yes, 1 / false, no, 0); `fallback` is what an unset variable means.
|
|
66
82
|
export function onOff(value, fallback) {
|
|
67
83
|
const v = String(value ?? '').trim().toLowerCase();
|
|
@@ -70,12 +86,32 @@ export function onOff(value, fallback) {
|
|
|
70
86
|
return fallback;
|
|
71
87
|
}
|
|
72
88
|
|
|
73
|
-
|
|
89
|
+
// `requires`: config keys the chat app cannot start without (its module's `requires`).
|
|
90
|
+
// `env` is everything config.env and the environment hold, for the app's own keys.
|
|
91
|
+
export function loadConfig({ requires = [] } = {}) {
|
|
74
92
|
const env = { ...parseEnv(CONFIG_FILE), ...process.env };
|
|
75
93
|
const sessions = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
76
|
-
|
|
94
|
+
const missing = requires.filter((k) => !env[k]);
|
|
95
|
+
if (missing.length) throw new Error(`${CONFIG_FILE}: ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required`);
|
|
77
96
|
if (!sessions.length) throw new Error(`${CONFIG_FILE}: HERDR_SESSIONS must list at least one hc session`);
|
|
78
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
chat: env.HERDRCHAT_CHAT || 'feishu',
|
|
99
|
+
env,
|
|
100
|
+
sessions,
|
|
101
|
+
appId: env.FEISHU_APP_ID || '',
|
|
102
|
+
appSecret: env.FEISHU_APP_SECRET || '',
|
|
103
|
+
ownerOpenId: env.FEISHU_OWNER_OPEN_ID || '',
|
|
104
|
+
muted: spacesOf(env.HERDR_MUTED_SPACES, 'all'),
|
|
105
|
+
silent: spacesOf(env.HERDR_SILENT_SPACES, ''),
|
|
106
|
+
botTags: onOff(env.HERDR_BOT_TAGS, true),
|
|
107
|
+
userTags: onOff(env.HERDR_USER_TAGS, true),
|
|
108
|
+
history: partsOf(env.HERDR_HISTORY, 'file,folded', ['file', 'folded'], 'HERDR_HISTORY'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The chat app config.env names, before its keys are checked.
|
|
113
|
+
export function chatOf() {
|
|
114
|
+
return ({ ...parseEnv(CONFIG_FILE), ...process.env }).HERDRCHAT_CHAT || 'feishu';
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
export function readState() {
|