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.
@@ -0,0 +1,111 @@
1
+ // HERDR_ROOMS=tab: one Feishu group per herdr-app tab, with you in it; what you type there
2
+ // goes to its terminal. A room is the group's chat id. The group wears its space's avatar,
3
+ // its tags and, acting as you, its mute and your label (decor.mjs); the group that needs
4
+ // you is pinned. index.mjs calls only what is returned here, and a layout of rooms in
5
+ // another file gives the same members.
6
+ import { createChat, renameChat, replyMention } from '../client.mjs';
7
+ import { inSpaces, spaceOfName } from './decor.mjs';
8
+
9
+ export function createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor }) {
10
+ const { read, write } = store;
11
+ const entryOf = (k) => ({
12
+ get: () => read().chats[k],
13
+ set: (fields) => {
14
+ const state = read();
15
+ if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
16
+ write(state);
17
+ },
18
+ });
19
+
20
+ async function openRoom({ name, intro }) {
21
+ return createChat(client, cfg.ownerOpenId, name, intro);
22
+ }
23
+
24
+ async function renameRoom(room, name) {
25
+ await renameChat(client, room, name);
26
+ }
27
+
28
+ // Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
29
+ // and in your label. Cheap when nothing is left to do.
30
+ async function placeRoom(k, { space }) {
31
+ const entry = entryOf(k);
32
+ await decor.avatar(entry, space);
33
+ await decor.botTags(entry, scopeOf(k));
34
+ if (!(await asYou())) return;
35
+ await decor.mute(entry, space);
36
+ await decor.userLabel(entry, scopeOf(k));
37
+ }
38
+
39
+ // A group whose terminal is gone keeps its history, tagged "ended".
40
+ async function endRoom(k) {
41
+ const entry = entryOf(k);
42
+ const c = entry.get();
43
+ if (!c) return;
44
+ await decor.botTags(entry, scopeOf(k));
45
+ if (!c.avatar) await decor.avatar(entry, spaceOfName(c.name));
46
+ if (!(await asYou())) return;
47
+ await decor.mute(entry, spaceOfName(c.name));
48
+ await decor.userLabel(entry, scopeOf(k));
49
+ }
50
+
51
+ async function reviveRoom(k) {
52
+ const entry = entryOf(k);
53
+ await decor.botTags(entry, scopeOf(k));
54
+ await decor.userLabel(entry, scopeOf(k));
55
+ }
56
+
57
+ // Everything at once, when acting as you turns on: live groups to their labels, ended
58
+ // ones to "ended".
59
+ function alignAll() {
60
+ for (const [k, c] of Object.entries(read().chats)) {
61
+ if (!cfg.sessions.includes(c.session)) continue;
62
+ lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
63
+ }
64
+ }
65
+
66
+ // The group that needs you is pinned while it waits (acting as you), and unpinned when
67
+ // it no longer does; which ones are pinned is kept in state.json (pinned).
68
+ async function pin(k, room) {
69
+ if (!(await asYou())) return;
70
+ try {
71
+ await userAuth.pin(room);
72
+ const state = read();
73
+ state.pinned = { ...(state.pinned || {}), [k]: room };
74
+ write(state);
75
+ } catch (err) {
76
+ logError(`pin ${room}: ${err.message}`);
77
+ }
78
+ }
79
+
80
+ async function unpin(k) {
81
+ const room = read().pinned?.[k];
82
+ if (!room) return;
83
+ try {
84
+ await userAuth.unpin(room);
85
+ } catch (err) {
86
+ logError(`unpin ${room}: ${err.message}`);
87
+ }
88
+ const state = read();
89
+ delete state.pinned[k];
90
+ write(state);
91
+ }
92
+
93
+ const pinned = () => Object.keys(read().pinned || {});
94
+
95
+ // In a muted group only a mention rings; a group that is not muted rings anyway. A
96
+ // space in HERDR_SILENT_SPACES gets no mention at all.
97
+ async function ring(k, message, text) {
98
+ const c = read().chats[k];
99
+ if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
100
+ await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
101
+ }
102
+
103
+ // A message, to the core's words: the room it came from (null: the direct chat).
104
+ const roomOf = (msg) => (msg.chat_type === 'p2p' ? null : msg.chat_id);
105
+ // The chats whose messages the bot reads (to look for what it missed), and whether a
106
+ // chat is one of them rather than the direct chat.
107
+ const roomChats = () => Object.values(read().chats || {}).filter((c) => !c.ended).map((c) => c.chat);
108
+ const isRoomChat = (chatId) => Object.values(read().chats || {}).some((c) => c.chat === chatId);
109
+
110
+ return { roomWord: 'group', openRoom, renameRoom, placeRoom, endRoom, reviveRoom, alignAll, pin, unpin, pinned, ring, roomOf, roomChats, isRoomChat };
111
+ }
@@ -53,7 +53,7 @@ const inSpaces = (set, space) => set.all || set.spaces.includes(space);
53
53
  const spaceOfName = (name) => (name || '').split(' / ')[0];
54
54
  const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
55
55
 
56
- export function create({ cfg, store, log, logWarn, logError }) {
56
+ export function create({ cfg, store, log, logWarn, logError, claimOwner = () => 'none' }) {
57
57
  const env = cfg.env || {};
58
58
  const owner = String(env.TELEGRAM_OWNER_ID || '').trim();
59
59
  const tg = createClient(env.TELEGRAM_BOT_TOKEN, { log: logWarn });
@@ -331,8 +331,13 @@ export function create({ cfg, store, log, logWarn, logError }) {
331
331
  const from = String(m.from?.id || '');
332
332
  const where = `${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`;
333
333
  if (!owner) {
334
- log(`owner not set; message from user id ${from}`);
335
- if (m.chat.type === 'private') await reply(where, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen run ${startAgain(cfg)}. Your terminals' topics appear then.`);
334
+ if (m.chat.type !== 'private') return;
335
+ // Telegram sends /start when someone first opens the bot: a command is never a guess
336
+ const paired = String(m.text || '').startsWith('/') ? 'none' : claimOwner(m.text, 'TELEGRAM_OWNER_ID', from);
337
+ log(`owner not set; message from user id ${from} (pairing: ${paired})`);
338
+ if (paired === 'ok') await reply(where, '✅ Paired: you are this bot\'s owner now. It starts again, and your terminals\' topics appear in a moment.\n\nThen: type in a terminal\'s topic to talk to it; here, /ls shows them all, /new starts one.');
339
+ else if (paired === 'wrong') await reply(where, 'That is not the code. Send the 6 digits `herdrchat setup` shows.');
340
+ else await reply(where, `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\`TELEGRAM_OWNER_ID=${from}\``);
336
341
  return;
337
342
  }
338
343
  if (from !== owner || String(m.chat.id) !== owner) {
@@ -394,6 +399,9 @@ export function create({ cfg, store, log, logWarn, logError }) {
394
399
  if (u.callback_query) await handleClick(u.callback_query, onAction);
395
400
  } catch (err) {
396
401
  logError(`telegram update ${u.update_id}:`, err);
402
+ // a message that could not be handled: you are told, never left guessing
403
+ const m = u.message;
404
+ if (m && String(m.from?.id || '') === owner) await reply(`${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`, `⚠️ Not delivered: it could not be handled (${err.message}). Send it again; \`herdrchat logs\` has the details.`).catch(() => {});
397
405
  }
398
406
  // saved once handled: a crash halfway handles it again, never skips it
399
407
  offset = u.update_id + 1;
@@ -11,7 +11,8 @@
11
11
  // FEISHU_OWNER_OPEN_ID your open_id for that app; the only person it listens to
12
12
  // TELEGRAM_BOT_TOKEN herdrchat's Telegram bot (topic mode on, in @BotFather)
13
13
  // TELEGRAM_OWNER_ID your Telegram user id; the only person it listens to
14
- // HERDR_SESSIONS hc sessions to mirror, comma-separated
14
+ // HERDR_SESSIONS hc sessions to mirror, comma-separated; empty: `default`,
15
+ // hc's own (with herdr-app it reaches every session it shows)
15
16
  // HERDR_MUTED_SPACES spaces whose rooms are quiet, so only "needs you" and
16
17
  // "done" ring (Feishu: groups muted, once /auth is done,
17
18
  // and an @; Telegram: silent posts and a reply that
@@ -76,6 +77,20 @@ function parseEnv(file) {
76
77
  return env;
77
78
  }
78
79
 
80
+ // Sets one key in a config.env, keeping every other line (and its comments) as it was:
81
+ // the key's line is replaced, or the key is added at the end. The file stays private.
82
+ export function setConfigValue(file, key, value) {
83
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [];
84
+ const at = lines.findIndex((l) => new RegExp(`^\\s*${key}\\s*=`).test(l));
85
+ if (at >= 0) lines[at] = `${key}=${value}`;
86
+ else {
87
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
88
+ lines.push(`${key}=${value}`, '');
89
+ }
90
+ fs.writeFileSync(file, lines.join('\n'), { mode: 0o600 });
91
+ fs.chmodSync(file, 0o600);
92
+ }
93
+
79
94
  // A list of spaces: `all`, empty or `none` for none, or the spaces listed. `fallback` is
80
95
  // what an unset variable means.
81
96
  export function spacesOf(value, fallback) {
@@ -105,19 +120,23 @@ export function onOff(value, fallback) {
105
120
 
106
121
  // `requires`: config keys the chat app cannot start without (its module's `requires`).
107
122
  // `env` is everything config.env and the environment hold, for the app's own keys.
108
- // `sessions: false`: an empty HERDR_SESSIONS is not an error (status shows what is set).
123
+ // An empty HERDR_SESSIONS means hc's own session, `default` (`sessionsSet` says whether
124
+ // it was named). `sessions: false` is kept for callers that only read what is set.
125
+ export const DEFAULT_SESSION = 'default';
109
126
  export function loadConfig({ requires = [], sessions: needSessions = true } = {}) {
110
127
  const env = { ...parseEnv(CONFIG_FILE), ...process.env };
111
- const sessions = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
128
+ const named = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
129
+ const sessions = named.length ? named : [DEFAULT_SESSION];
112
130
  const missing = requires.filter((k) => !env[k]);
113
131
  if (missing.length) throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} empty in ${CONFIG_FILE}: fill ${missing.length > 1 ? 'them' : 'it'} in (the comment above each says where it comes from), then start again.`);
114
- if (needSessions && !sessions.length) throw new Error(`HERDR_SESSIONS is empty in ${CONFIG_FILE}: put the hc sessions to mirror there, comma-separated (\`ls ~/.local/state/herdr-core\` lists the sessions on this machine), then start again.`);
132
+ void needSessions; // an empty list is `default` now: never an error here
115
133
  return {
116
134
  profile: places.profile,
117
135
  configFile: CONFIG_FILE,
118
136
  chat: env.HERDRCHAT_CHAT || 'feishu',
119
137
  env,
120
138
  sessions,
139
+ sessionsSet: named.length > 0,
121
140
  appId: env.FEISHU_APP_ID || '',
122
141
  appSecret: env.FEISHU_APP_SECRET || '',
123
142
  ownerOpenId: env.FEISHU_OWNER_OPEN_ID || '',
@@ -567,7 +567,8 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
567
567
  const markdown = menu ? escapeMarkdown(menu.text) : `\`\`\`\n${tidyScreen(screenText, DIALOG_LINES)}\n\`\`\``;
568
568
  return { menu, title, markdown, doneTitle: DONE_TITLE };
569
569
  }
570
- const dialogValue = (session, address, dialog) => ({ s: session, a: address, k: dialog.menu.key });
570
+ // c: the terminal's width when the card was made, to tell a resize from a new dialog
571
+ const dialogValue = (session, address, dialog, cols) => ({ s: session, a: address, k: dialog.menu.key, ...(cols ? { c: cols } : {}) });
571
572
 
572
573
  // One dialog gets one card. A dialog is named by its menu key (or, with no menu, by its
573
574
  // status words); the name is claimed before anything is sent, so the turn event, a
@@ -620,6 +621,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
620
621
  return;
621
622
  }
622
623
  if (!claimDialog(k, dialog, record.status) && !again) return;
624
+ try {
625
+ await sendBlocked(session, address, record, k, dialog);
626
+ } catch (err) {
627
+ // not posted: let the next event (or a held message) post it, never "posted already"
628
+ if (postedDialog.get(k) === dialogName(dialog, record.status)) postedDialog.delete(k);
629
+ throw err;
630
+ }
631
+ log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
632
+ }
633
+ async function sendBlocked(session, address, record, k, dialog) {
623
634
  await closeCard(k, 'It moved on; see the next card.');
624
635
  if (!dialog.menu) {
625
636
  const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
@@ -627,7 +638,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
627
638
  if (room) await chat.pin(k, room, sent);
628
639
  await chat.ring(k, sent, '⚠️ needs you');
629
640
  } else {
630
- const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog)));
641
+ const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog, record.cols)));
631
642
  if (message) {
632
643
  remember(message, dialog);
633
644
  openCard.set(k, { message, dialog });
@@ -637,9 +648,10 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
637
648
  // answered while the card was on its way: nothing will close it later, so now
638
649
  const live = views.get(session)?.terminals.get(address);
639
650
  if (live?.status?.state !== 'blocked' || postedDialog.get(k) !== dialogName(dialog, record.status)) await closeCard(k, '✔ Answered.');
651
+ } else {
652
+ throw new Error('the room for its card is gone');
640
653
  }
641
654
  }
642
- log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
643
655
  }
644
656
 
645
657
  // The keys that pick an option: its digit, or, in a question with previews (which takes
@@ -664,7 +676,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
664
676
  // unless it still shows the dialog the card was made from (same key) with the same
665
677
  // option. After the key, the card turns into the dialog's next step (the next question,
666
678
  // the review, a toggled box) or says what was picked.
667
- async function onAction({ value: v = {}, message, answer = '' }) {
679
+ async function onAction({ value: v = {}, message, answer = '', ticks = null }) {
668
680
  const started = Date.now();
669
681
  const shown = dialogCards.get(message) || { title: NEEDS_TITLE, markdown: '', doneTitle: DONE_TITLE };
670
682
  // A box in a multi-select is ticked on the card only, at once; Next sends them all.
@@ -680,25 +692,37 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
680
692
  log(`-> ${v.s}/${v.a}: click on ${message} not pressed: the terminal has ended`);
681
693
  return answered(shown, 'The terminal has ended.');
682
694
  }
683
- const status = (await hc.getTerminal(v.s, v.a)).status;
695
+ const term = await hc.getTerminal(v.s, v.a);
696
+ const status = term.status;
684
697
  const menu = status?.state === 'blocked' ? menuOf(await hc.screenTail(v.s, v.a, SCREEN_LINES)) : null;
698
+ const k = key(v.s, v.a);
685
699
  if (!menu || menu.key !== v.k) {
686
- log(`-> ${v.s}/${v.a}: click on ${message} not pressed: ${menu ? `the dialog changed (card ${v.k}, screen ${menu.key})` : `nothing waits (status ${status?.state || 'none'})`}`);
687
- if (menu) later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status }));
688
- return answered(shown, menu ? 'It now asks something else; see the new card.' : 'It no longer waits on this.');
700
+ log(`-> ${v.s}/${v.a}: click on ${message} not pressed: ${menu ? `the dialog changed (card ${v.k}, screen ${menu.key}; width then ${v.c || '?'}, now ${term.cols || '?'})` : `nothing waits (status ${status?.state || 'none'})`}`);
701
+ if (!menu) return answered(shown, 'It no longer waits on this.');
702
+ // The card in your hand becomes what the screen asks now, to tap again. Never "see the
703
+ // new card": a new card can be held back as already posted, and then nothing is left
704
+ // to answer while your messages wait on the dialog.
705
+ const now = dialogOf(status, await hc.screenTail(v.s, v.a, SCREEN_LINES));
706
+ if (!now.menu) return answered(shown, 'It no longer waits on this.');
707
+ if (openCard.get(k) && openCard.get(k).message !== message) await closeCard(k, 'It moved on; see the next card.');
708
+ postedDialog.set(k, dialogName(now, status));
709
+ remember(message, now);
710
+ openCard.set(k, { message, dialog: now });
711
+ return { ...toast('warning', 'It changed before your tap, so nothing was pressed. The card shows it now: tap again.'), card: { kind: 'dialog', dialog: now, value: dialogValue(v.s, v.a, now, term.cols) } };
689
712
  }
690
- const k = key(v.s, v.a);
691
713
  if (openCard.get(k)?.message === message) openCard.delete(k);
692
714
  let picked;
693
715
  if (v.pick === 'tab' && shown.menu?.multi) {
694
- // the boxes the card shows, made so on the screen: each one that differs is pressed
695
- const changed = shown.menu.options.filter((o) => o.checked !== null && o.checked !== menu.options.find((m) => m.n === o.n)?.checked);
716
+ // the boxes as ticked on the card (sent with Next by a form, or kept per tap), made
717
+ // so on the screen: each one that differs is pressed
718
+ const wanted = ticks ? { ...shown.menu, options: shown.menu.options.map((o) => (o.checked === null ? o : { ...o, checked: Boolean(ticks[o.n]) })) } : shown.menu;
719
+ const changed = wanted.options.filter((o) => o.checked !== null && o.checked !== menu.options.find((m) => m.n === o.n)?.checked);
696
720
  for (const o of changed) {
697
721
  await hc.keys(v.s, v.a, [String(o.n)]);
698
722
  await sleep(BOX_GAP_MS);
699
723
  }
700
724
  await hc.keys(v.s, v.a, ['tab']);
701
- const ticked = shown.menu.options.filter((o) => o.checked).map((o) => o.label);
725
+ const ticked = wanted.options.filter((o) => o.checked).map((o) => o.label);
702
726
  picked = ticked.length ? ticked.join(', ') : 'nothing ticked';
703
727
  } else if (v.pick === 'esc' || v.pick === 'tab') {
704
728
  await hc.keys(v.s, v.a, [v.pick]);
@@ -738,9 +762,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
738
762
  if (next.menu && (next.menu.key === v.k || claimDialog(k, next, nextStatus))) {
739
763
  remember(message, next);
740
764
  openCard.set(k, { message, dialog: next });
741
- return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next) } };
765
+ return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next, term.cols) } };
766
+ }
767
+ // its card is out already, unless that card was closed: then this one shows it
768
+ if (next.menu && openCard.has(k)) return answered(shown, `✔ ${picked}`);
769
+ if (next.menu) {
770
+ postedDialog.set(k, dialogName(next, nextStatus));
771
+ remember(message, next);
772
+ openCard.set(k, { message, dialog: next });
773
+ return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next, term.cols) } };
742
774
  }
743
- if (next.menu) return answered(shown, `✔ ${picked}`); // its card is out already
744
775
  // still waiting, on something with no menu: hc sends no new turn for it, so post it
745
776
  later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status: nextStatus }));
746
777
  }
@@ -939,9 +970,16 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
939
970
  log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
940
971
  await markWorking(key(session, address), message);
941
972
  } else {
942
- hold(key(session, address), { where, message, session, address, text });
973
+ const k = key(session, address);
974
+ hold(k, { where, message, session, address, text });
943
975
  log(`-> ${session}/${address}: held until its dialog is answered: ${text.slice(0, 80)}`);
944
- await chat.reply(where, 'It asks something first (the card above). Your message goes in once that is answered.');
976
+ // "the card above" must be there: with a menu and no open card, post it now
977
+ const status = (await hc.getTerminal(session, address).catch(() => record))?.status;
978
+ if (menu && !openCard.has(k)) {
979
+ logWarn(`${session}/${address}: waits on a dialog with no open card; posting it again`);
980
+ await postBlocked(session, address, { ...record, status }, { again: true }).catch((err) => logError(`dialog card for ${session}/${address} not posted (${err.message})`));
981
+ }
982
+ await chat.reply(where, openCard.has(k) || !menu ? 'It asks something first (the card above). Your message goes in once that is answered.' : 'It asks something first, in herdr-app (its card could not be posted here). Your message goes in once that is answered.');
945
983
  }
946
984
  return;
947
985
  }
package/lib/core/menu.mjs CHANGED
@@ -39,8 +39,10 @@ function topOf(lines, first) {
39
39
 
40
40
  const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
41
41
 
42
+ // Joined with spaces, not line breaks: the same dialog wraps at other places when the
43
+ // terminal is resized (herdr-app opened on a phone), and it is still the same dialog.
42
44
  function keyOf(lines, top, last) {
43
- const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '').replace(/\s+/g, ' ').trim()).join('\n');
45
+ const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
44
46
  return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
45
47
  }
46
48
 
@@ -0,0 +1,35 @@
1
+ // Pairing: how the bot learns who its owner is without anyone copying an id. `herdrchat
2
+ // setup` shows a short code and keeps it in state.json; the first person who sends that
3
+ // code to the bot becomes its owner. The code lasts PAIR_MINUTES and allows PAIR_TRIES
4
+ // wrong guesses, so nobody can guess their way in.
5
+ import crypto from 'node:crypto';
6
+
7
+ export const PAIR_MINUTES = 15;
8
+ export const PAIR_TRIES = 5;
9
+
10
+ // store: {read, write} of state.json
11
+ export function startPairing(store, now = Date.now()) {
12
+ const code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
13
+ const state = store.read();
14
+ state.pairing = { code, until: now + PAIR_MINUTES * 60 * 1000, tries: 0 };
15
+ store.write(state);
16
+ return code;
17
+ }
18
+
19
+ // 'ok' (the code, used up), 'wrong' (a guess counted), or 'none' (no pairing running)
20
+ export function tryPairing(store, text, now = Date.now()) {
21
+ const state = store.read();
22
+ const p = state.pairing;
23
+ if (!p || now > p.until) return 'none';
24
+ if (String(text || '').trim() === p.code) {
25
+ delete state.pairing;
26
+ store.write(state);
27
+ return 'ok';
28
+ }
29
+ p.tries = (p.tries || 0) + 1;
30
+ if (p.tries >= PAIR_TRIES) delete state.pairing;
31
+ store.write(state);
32
+ return 'wrong';
33
+ }
34
+
35
+ export const pairingRunning = (store, now = Date.now()) => Boolean(store.read().pairing && now <= store.read().pairing.until);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.3.3",
4
- "description": "A chat front-end for herdr: every terminal hc runs, in Feishu or Telegram",
3
+ "version": "1.4.1",
4
+ "description": "The chat client for hc (herdr): see and answer every hc terminal from Feishu or Telegram",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/zhangxiangliang/herdrchat.git"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: herdrchat
3
3
  description: >-
4
- Set up, run and look after herdrchat, which mirrors every herdr-app (hc) tab into
4
+ Set up, run and look after herdrchat, the chat client for hc (herdr), which mirrors every herdr-app tab into
5
5
  Feishu (a group per tab) or Telegram (a topic per tab): first setup of either app,
6
6
  start, stop, status, logs, running it as a service, settings, fixing problems, the
7
7
  Feishu developer console and @BotFather. Use for anything about herdrchat or its
@@ -29,7 +29,7 @@ from the package (`config.env.example`, `docs/guide.md`) are in the installed pa
29
29
 
30
30
  | Command | Do |
31
31
  |---|---|
32
- | `/herdrchat setup` | First check herdr is there (`command -v hc`): herdrchat is built on herdr and does nothing without it; when it is missing, say so, point to https://herdr.dev, and stop until it is installed. Then ask which chat app (Feishu, Telegram, or both: the second one on its own profile). Telegram: `/herdrchat telegram setup`. Feishu, in this order: create the app, the bot ability and the scopes (`references/feishu.md`); `herdrchat init` and fill `config.env` with the user (App ID, App Secret, `HERDR_SESSIONS`: `ls ~/.local/state/herdr-core` lists the sessions); `herdrchat start` (the console's long-connection check needs it running); events and callbacks; publish a version; the user messages the bot and adds the `FEISHU_OWNER_OPEN_ID` line it answers with; `herdrchat start` again; offer `/herdrchat auth` and `herdrchat install`. |
32
+ | `/herdrchat setup` | First check herdr is there (`command -v hc`): herdrchat is built on herdr and does nothing without it. When it is missing, offer to install it and, on a yes, follow `references/herdr.md` (from herdr's own instructions, read now, each command shown and agreed first). Then ask which chat app (Feishu, Telegram, or both: the second one on its own profile). Telegram: `/herdrchat telegram setup`. Feishu, in this order (with ego-browser the agent clicks the console; without it, offer to install Ego Lite or walk the user through it, `references/feishu.md` "Before anything"): create the app, the bot ability and the scopes; `herdrchat init` and fill `config.env` with the user (App ID, App Secret; `HERDR_SESSIONS` empty means hc's default session, `ls ~/.local/state/herdr-core` lists the sessions); `herdrchat start` (the console's long-connection check needs it running); events and callbacks; publish a version; `herdrchat pair` (in the background: it prints a 6-digit code and waits; the user sends the code to the bot, which writes `FEISHU_OWNER_OPEN_ID` itself and starts again; it also proves the events work); offer `/herdrchat auth` and `herdrchat install`. Never run `herdrchat setup` yourself: it asks questions in a terminal, and this shell has none. It is the same steps for a user without an agent; yours add the Feishu console through ego-browser. When there is no hc session yet, say so and stop until herdr runs one. |
33
33
  | `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
34
34
  | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. With a service installed they go through it. |
35
35
  | `/herdrchat install`, `/herdrchat uninstall` | Run it as a service of the system (launchd on macOS, systemd on Linux): it starts at login and comes back after a crash, whatever happens to the hc cores. Recommended once it works. Read the config first; after a node upgrade, install again (`herdrchat status` says so). |
@@ -55,6 +55,8 @@ With no subcommand, run `herdrchat status` and suggest the next step from it.
55
55
 
56
56
  - `references/feishu.md`: the bot's app in the Feishu developer console, with Ego or as a
57
57
  guided walk-through; the scopes this bot uses.
58
+ - `references/herdr.md`: what herdrchat is built on, and installing herdr with the user
59
+ when hc is missing.
58
60
  - `references/telegram.md`: the Telegram bot, from @BotFather to the first topic; what
59
61
  it does differently from Feishu.
60
62
  - `references/ops.md`: running and checking the bot, its logs and state, the usual
@@ -6,13 +6,20 @@ that with ego-browser, in the user's own logged-in browser.
6
6
 
7
7
  ## Before anything
8
8
 
9
- 1. **ego-browser.** Run `command -v ego-browser`. When it is there, run
9
+ 1. **ego-browser, or the guide.** Only Feishu needs this (Telegram's bot is made in a
10
+ chat, with no browser). Run `command -v ego-browser`. When it is there, run
10
11
  `ego-browser skill` and follow its usage guide. When it is missing, do not fall back
11
12
  to another browser; ask the user which they want:
12
- - install Ego Lite (https://lite.ego.app/), sign in to Feishu in it, and ask again,
13
- so the agent does the clicking; or
14
- - a guided walk-through instead (see "Guide mode" below): the user clicks, the agent
15
- says what to click.
13
+ - **Install Ego Lite**, so the agent does the clicking. Use Ego's own instructions,
14
+ read now at https://lite.ego.app/ (not from memory): say what it installs, and
15
+ install only on a yes. Then check `command -v ego-browser` again (a new folder may
16
+ need a new shell), and have the user sign in to Feishu in Ego as an admin of the
17
+ tenant. If any of it fails or the user stops, take the guide below; do not retry
18
+ other ways.
19
+ - **The guide** ("Guide mode" below): the user clicks, the agent says what to click.
20
+ Either way is the same procedures. When Ego stops working in the middle (signed out,
21
+ a page that does not match), say so and go on in guide mode from that step: what is
22
+ done stays done.
16
23
  2. **Which profile.** Ask which Ego profile is signed in to Feishu as an admin of the
17
24
  tenant that owns the app (`await profiles()` lists them); never guess. Create one task
18
25
  space for the whole job with that `profileId`, and close it at the end with
@@ -20,7 +27,9 @@ that with ego-browser, in the user's own logged-in browser.
20
27
  3. **Which app.** The app id is `FEISHU_APP_ID` in the profile's `config.env`
21
28
  (`~/.config/herdrchat/<profile>/config.env`, or in `HERDRCHAT_HOME`). Read only that
22
29
  key; never print, screenshot or paste `FEISHU_APP_SECRET`, and never write a real app
23
- id, open_id, chat id or tenant name into a file of this repo.
30
+ id, open_id, chat id or tenant name into a file of this repo. With Ego too, the agent
31
+ does not read or reveal the secret on the credentials page: the user copies it into
32
+ `config.env` themselves (the agent says the file and the line).
24
33
  4. **Ask first** before anything outward-facing: publishing a version, applying scopes,
25
34
  deleting or deactivating an app. One yes covers one action.
26
35
 
@@ -0,0 +1,33 @@
1
+ # herdr: what herdrchat is built on, and putting it in place
2
+
3
+ herdrchat mirrors the terminals that **hc** (herdr-core) runs; **herdr-app** is herdr's
4
+ desktop and phone app that shows them in spaces and tabs. Without hc on the machine
5
+ herdrchat does nothing. Check: `command -v hc && hc --version`.
6
+
7
+ When hc is missing, help the user install it. Do it from herdr's own instructions, read
8
+ now, never from memory or a script kept here: install steps change, and a fixed script
9
+ breaks on another system or version.
10
+
11
+ ## Installing it, with the user
12
+
13
+ 1. **Say what and why.** "herdrchat is built on herdr: it mirrors the terminals that hc
14
+ runs. hc is not on this machine. Shall I install herdr for you?" Wait for a yes.
15
+ 2. **Read the official way, now.** Open https://herdr.dev and follow its install or
16
+ download instructions for this system (macOS or Linux; say so if it is neither). Use
17
+ only what herdr's own site or its official releases give. If the page does not say
18
+ how, stop and tell the user where you looked.
19
+ 3. **Show before running.** Give the user the exact command or download, where it comes
20
+ from, where it installs, and what it changes (a folder, the PATH, a login item).
21
+ When the instructions pipe a script into a shell, download the script first and read
22
+ it, then say what it does. Run it only after the user says yes to that command.
23
+ 4. **Check it worked.** `hc --version` answers. If hc is not found, the new folder may
24
+ not be on PATH yet in this shell: say which folder, and how the instructions add it.
25
+ 5. **A first session.** `hc ls` (or the instructions' own first step) starts the default
26
+ session; `ls ~/.local/state/herdr-core` then lists it. herdrchat's `HERDR_SESSIONS`
27
+ names the sessions to mirror. herdr-app is optional for herdrchat but is how terminals
28
+ get their space and tab names; offer it when the user wants the desktop app too.
29
+ 6. **On a failure, stop.** Show the error as it is and what was tried. Do not try other
30
+ install ways the user did not agree to, and never run anything with sudo without
31
+ asking for that command.
32
+
33
+ Then go back to `/herdrchat setup`.
@@ -79,7 +79,7 @@ your token after `/auth`: never print it.
79
79
 
80
80
  | You see | Likely cause | Do |
81
81
  |---|---|---|
82
- | nothing posts | bot down, or the owner not set (`FEISHU_OWNER_OPEN_ID`, `TELEGRAM_OWNER_ID`) | `herdrchat status`; the log says "owner not set" |
82
+ | nothing posts | bot down, or the owner not set (`FEISHU_OWNER_OPEN_ID`, `TELEGRAM_OWNER_ID`) | `herdrchat status`; the log says "owner not set": `herdrchat pair` |
83
83
  | Telegram: no topic appears | topic mode off | log: "topic mode is off"; BotFather's mini app, the bot, Threaded Mode, then start again |
84
84
  | Telegram: `telegram poll: ... 409` | another copy polls the same token | stop it (another machine?), then start once |
85
85
  | a button gives error 200340 | the card callback is not set up | `references/feishu.md`: events and card callbacks |
@@ -19,18 +19,20 @@ Do these in order; the user does the Telegram steps, you check each one.
19
19
  2. **The config.** `herdrchat init --chat telegram P` makes the profile's `config.env`
20
20
  (private) and prints where it is. Ask the user to paste the token after
21
21
  `TELEGRAM_BOT_TOKEN=` themselves (`open -e <file>` opens it), no quotes, no spaces, and
22
- fill `HERDR_SESSIONS` with them (`ls ~/.local/state/herdr-core` lists the sessions).
22
+ leave `HERDR_SESSIONS` empty for hc's default session, or name others
23
+ (`ls ~/.local/state/herdr-core` lists the sessions).
23
24
  3. **Topic mode, checked before the first start.** It is not in BotFather's `/mybots` →
24
25
  Bot Settings list; it is in BotFather's mini app (the **Open** button at the bottom
25
26
  left of the BotFather chat): pick the bot, turn on Threaded Mode. Check it without
26
27
  printing the token: a short `node -e` reads the token from the config, calls
27
28
  `https://api.telegram.org/bot<token>/getMe` and prints only `username` and
28
29
  `has_topics_enabled`, which must be `true`.
29
- 4. **First start, to learn the owner.** `herdrchat start P`; the user messages the bot
30
- anything; it answers with the line to add, `TELEGRAM_OWNER_ID=<digits>`, and where the
31
- file is. The user adds it. Until then the bot makes no topic.
32
- 5. **Start again.** Read the config (secrets hidden) in one step, then
33
- `herdrchat start P` in the next. One topic per tab appears; Telegram takes about one
30
+ 4. **Start, then pair the owner.** Read the config (secrets hidden) in one step, then
31
+ `herdrchat start P` in the next. Then run `herdrchat pair P` in the background: it
32
+ prints a 6-digit code and waits. Tell the user the code; they open the bot (tap
33
+ Start), send it, and the bot writes `TELEGRAM_OWNER_ID` itself and starts again as
34
+ theirs. Until then the bot makes no topic. No id to copy.
35
+ 5. **The topics.** One topic per tab appears; Telegram takes about one
34
36
  message a second in a chat, so twenty tabs take twenty seconds. A new topic gets the
35
37
  conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it again.
36
38
  6. **Make it a service.** Once it works: `herdrchat install P`. It then starts at login
@@ -68,7 +70,7 @@ without muting each topic by hand.
68
70
  | Seen | Cause | Do |
69
71
  |---|---|---|
70
72
  | log: `topic mode is off for @<bot>` | step 3 not done | turn it on in BotFather's mini app, check `has_topics_enabled`, start again |
71
- | the bot answers "To make you this bot's owner…" | `TELEGRAM_OWNER_ID` empty | step 4 |
73
+ | the bot answers "This bot has no owner yet" | `TELEGRAM_OWNER_ID` empty | step 4: `herdrchat pair P` |
72
74
  | `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 3) |
73
75
  | log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop P`, find the other one (another machine?), start once |
74
76
  | log: `too fast, waiting N s` | Telegram's flood limit; the bot waits as told | nothing; it catches up |