herdrchat 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/README.zh.md +1 -0
- 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 +1041 -0
- package/lib/{hc.mjs → core/hc.mjs} +50 -2
- package/lib/{log.mjs → core/log.mjs} +6 -3
- 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 +6 -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
- /package/lib/{menu.mjs → core/menu.mjs} +0 -0
package/bin/herdrchat.mjs
CHANGED
|
@@ -1,1260 +1,77 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// herdr-app,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the direct chat with the bot -> herdr itself: /ls, /new, /kill
|
|
6
|
-
// hc is the backbone: its events say which terminals exist, their status and when a
|
|
7
|
-
// turn ends (a program left `working`). What the program said comes from its reader
|
|
8
|
-
// (lib/readers/: the agent's own records, so your prompts and its answers, posted live
|
|
9
|
-
// while it works); a program with no reader gets its screen at each turn's end.
|
|
10
|
-
// Only what happens after the bot starts is posted, never older history.
|
|
2
|
+
// herdrchat: every herdr-app tab, mirrored into a chat app. This file only wires the
|
|
3
|
+
// parts together: the config, the log, the chat app (lib/chats/, picked by
|
|
4
|
+
// HERDRCHAT_CHAT) and the core (lib/core/engine.mjs), which does the work.
|
|
11
5
|
//
|
|
12
|
-
// Usage: node bin/herdrchat.mjs
|
|
13
|
-
import crypto from 'node:crypto';
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
6
|
+
// Usage: node bin/herdrchat.mjs (`herdrchat start` keeps it running)
|
|
16
7
|
import path from 'node:path';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import { menuOf } from '../lib/menu.mjs';
|
|
25
|
-
import { createLog } from '../lib/log.mjs';
|
|
26
|
-
import { readerFor } from '../lib/readers/index.mjs';
|
|
27
|
-
import { createUserAuth } from '../lib/userauth.mjs';
|
|
28
|
-
|
|
29
|
-
const SHELLS = new Set(['zsh', 'bash', 'sh', 'fish', 'nu', 'dash']);
|
|
30
|
-
const SHELL_ECHO_MS = 1500;
|
|
31
|
-
const SCREEN_LINES = 40;
|
|
32
|
-
const TURN_SCREEN_LINES = 150;
|
|
33
|
-
const POST_TRIES = 5;
|
|
34
|
-
const SWEEP_MS = 60 * 1000;
|
|
35
|
-
const SWEEP_DONE_MS = 30 * 1000;
|
|
36
|
-
const LIVE_MS = 5000;
|
|
37
|
-
const END_TRIES = 20;
|
|
38
|
-
const END_WAIT_MS = 750;
|
|
39
|
-
const KEY_SETTLE_MS = 700; // a dialog redraws this soon after a key
|
|
40
|
-
const CLICK_BUDGET_MS = 2000; // a card click must be answered within 3 s
|
|
41
|
-
const STARTED = new Date().toISOString();
|
|
8
|
+
import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, writeState } from '../lib/core/config.mjs';
|
|
9
|
+
import { createEngine } from '../lib/core/engine.mjs';
|
|
10
|
+
import { followEvents } from '../lib/core/events.mjs';
|
|
11
|
+
import { cores, getTerminal, hc, keys, machines, put, run, screenTail, send } from '../lib/core/hc.mjs';
|
|
12
|
+
import { createLog } from '../lib/core/log.mjs';
|
|
13
|
+
import * as readers from '../lib/core/readers/index.mjs';
|
|
14
|
+
import { chatModule, createChat } from '../lib/chats/index.mjs';
|
|
42
15
|
|
|
43
16
|
makeDirs();
|
|
44
|
-
const cfg = loadConfig();
|
|
45
|
-
const client = createClient(cfg);
|
|
46
|
-
const userAuth = createUserAuth(cfg, STATE_DIR);
|
|
47
17
|
const logger = createLog(path.join(STATE_DIR, 'logs'));
|
|
48
|
-
logger.capture();
|
|
49
18
|
const log = logger.info;
|
|
50
19
|
const logWarn = logger.warn;
|
|
51
20
|
const logError = logger.error;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const firstSnapshot = new Map(cfg.sessions.map((s) => {
|
|
78
|
-
let done;
|
|
79
|
-
const promise = new Promise((r) => { done = r; });
|
|
80
|
-
return [s, { promise, done }];
|
|
81
|
-
}));
|
|
82
|
-
const ready = Promise.all([...firstSnapshot.values()].map((x) => x.promise));
|
|
83
|
-
const lanes = new Map(); // terminal key -> its last queued work
|
|
84
|
-
function later(session, address, label, fn) {
|
|
85
|
-
const k = key(session, address);
|
|
86
|
-
const next = (lanes.get(k) || ready).then(fn).catch((err) => logError(`${label} ${address}:`, err));
|
|
87
|
-
lanes.set(k, next);
|
|
88
|
-
next.then(() => { if (lanes.get(k) === next) lanes.delete(k); });
|
|
89
|
-
return next;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ---------- terminals and their groups ----------
|
|
93
|
-
|
|
94
|
-
function shownWhere(record) {
|
|
95
|
-
return record && record.state === 'live' ? splitWhere(record.labels?.where) : null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// A group is named after where herdr-app shows the terminal; the session (or, for a
|
|
99
|
-
// terminal on another machine, the machine) is the group's tag. A tab herdr-app only
|
|
100
|
-
// numbered ("2") says nothing, so the terminal's title stands in for it (what any
|
|
101
|
-
// program sets with the standard title sequence; Claude Code sets its session name or
|
|
102
|
-
// topic), then its hc name; leading status glyphs are dropped, so a spinner in a title
|
|
103
|
-
// never renames a group.
|
|
104
|
-
const TITLE_MAX = 40;
|
|
105
|
-
|
|
106
|
-
function tabName(where, record) {
|
|
107
|
-
if (!/^\d+$/.test(where.tab)) return where.tab;
|
|
108
|
-
const title = String(record.title || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
|
|
109
|
-
const name = title || record.name || '';
|
|
110
|
-
if (!name) return where.tab;
|
|
111
|
-
return name.length > TITLE_MAX ? `${name.slice(0, TITLE_MAX - 1)}…` : name;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function groupName(where, record) {
|
|
115
|
-
return `${where.space} / ${tabName(where, record)}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
|
|
119
|
-
// by the first lane that needs it; the others wait for that.
|
|
120
|
-
const making = new Map();
|
|
121
|
-
function once(id, make) {
|
|
122
|
-
if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
|
|
123
|
-
return making.get(id);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// One tenant tag per session or machine, made once, and one "ended" tag; their ids are
|
|
127
|
-
// kept in state.json (tags).
|
|
128
|
-
const ENDED_TAG = '#ended';
|
|
129
|
-
|
|
130
|
-
async function tagOf(id, name = id) {
|
|
131
|
-
return readState().tags?.[id] || once(`tag ${id}`, async () => {
|
|
132
|
-
const tag = await createTag(client, name);
|
|
133
|
-
const state = readState();
|
|
134
|
-
state.tags = { ...(state.tags || {}), [id]: tag };
|
|
135
|
-
writeState(state);
|
|
136
|
-
log(`created tag ${name} (${tag})`);
|
|
137
|
-
return tag;
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Each space gets an avatar: its first letter on a color of its own (the first color no
|
|
142
|
-
// other space has), kept in state.json (spaces), so all of its groups look alike, as in
|
|
143
|
-
// herdr-app. An avatar is a nicety: a failure is logged and tried again at the next check.
|
|
144
|
-
// The color is picked and saved before the first await, so spaces made side by side
|
|
145
|
-
// never pick the same one.
|
|
146
|
-
function colorOf(space) {
|
|
147
|
-
const state = readState();
|
|
148
|
-
const spaces = state.spaces || {};
|
|
149
|
-
if (spaces[space]?.color) return spaces[space].color;
|
|
150
|
-
const used = Object.values(spaces).map((x) => x.color);
|
|
151
|
-
const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
|
|
152
|
-
state.spaces = { ...spaces, [space]: { color } };
|
|
153
|
-
writeState(state);
|
|
154
|
-
return color;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function avatarOf(space) {
|
|
158
|
-
return readState().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
|
|
159
|
-
const color = colorOf(space);
|
|
160
|
-
const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
|
|
161
|
-
const state = readState();
|
|
162
|
-
state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
|
|
163
|
-
writeState(state);
|
|
164
|
-
log(`made avatar for space ${space} (${color})`);
|
|
165
|
-
return image;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function avatarChat(k, chat, space) {
|
|
170
|
-
try {
|
|
171
|
-
const image = await avatarOf(space);
|
|
172
|
-
if (readState().chats[k]?.avatar === image) return;
|
|
173
|
-
await setChatAvatar(client, chat, image);
|
|
174
|
-
const state = readState();
|
|
175
|
-
if (state.chats[k]) state.chats[k] = { ...state.chats[k], avatar: image };
|
|
176
|
-
writeState(state);
|
|
177
|
-
} catch (err) {
|
|
178
|
-
logError(`avatar ${space} on ${chat}: ${err.message}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
|
|
183
|
-
// its terminal is gone, "ended". They are tenant tags, so they can be turned off; off takes
|
|
184
|
-
// off the ones already on. A tag is a nicety: a failure is logged and tried again at the
|
|
185
|
-
// next check, and never holds back a post.
|
|
186
|
-
async function syncBotTags(k) {
|
|
187
|
-
const c = readState().chats[k];
|
|
188
|
-
if (!c) return;
|
|
189
|
-
try {
|
|
190
|
-
if (cfg.botTags) {
|
|
191
|
-
const tag = await tagOf(scopeOfKey(k));
|
|
192
|
-
if (c.tag !== tag) {
|
|
193
|
-
await bindTag(client, tag, c.chat);
|
|
194
|
-
setChat(k, { tag });
|
|
195
|
-
}
|
|
196
|
-
if (c.ended !== Boolean(c.endedTag)) {
|
|
197
|
-
const ended = await tagOf(ENDED_TAG, 'ended');
|
|
198
|
-
await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
|
|
199
|
-
setChat(k, { endedTag: Boolean(c.ended) });
|
|
200
|
-
}
|
|
201
|
-
} else {
|
|
202
|
-
const tags = readState().tags || {};
|
|
203
|
-
if (c.tag) {
|
|
204
|
-
await unbindTag(client, c.tag, c.chat).catch(() => {});
|
|
205
|
-
setChat(k, { tag: null });
|
|
206
|
-
}
|
|
207
|
-
if (c.endedTag && tags[ENDED_TAG]) {
|
|
208
|
-
await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
|
|
209
|
-
setChat(k, { endedTag: false });
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
} catch (err) {
|
|
213
|
-
logError(`bot tags on ${c.name}: ${err.message}`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ---------- acting as you (optional, after /auth) ----------
|
|
218
|
-
|
|
219
|
-
// With your token (lib/userauth.mjs) the bot does what only you can: mutes its groups,
|
|
220
|
-
// puts each in one of your own labels (the session or machine; "ended" once the terminal
|
|
221
|
-
// is gone) besides its bot tag, pins the group that needs you, and @-mentions you in a
|
|
222
|
-
// one-line reply under a card that needs you and under the turn's last answer, since in a
|
|
223
|
-
// muted group only a mention rings. Bot tags stay either way: they show next to a
|
|
224
|
-
// group's name, your labels only in the sidebar. Without the token none of this happens
|
|
225
|
-
// and the bot works as before. Whether it may act as you is checked every YOU_CHECK_MS;
|
|
226
|
-
// when that turns on, every group is brought in line; when it turns off, you are told once.
|
|
227
|
-
const YOU_CHECK_MS = 60 * 1000;
|
|
228
|
-
const ENDED_LABEL = 'ended';
|
|
229
|
-
let you = false;
|
|
230
|
-
let youCheckedAt = 0;
|
|
231
|
-
|
|
232
|
-
function setChat(k, fields) {
|
|
233
|
-
const state = readState();
|
|
234
|
-
if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
|
|
235
|
-
writeState(state);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
async function asYou() {
|
|
239
|
-
if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
|
|
240
|
-
youCheckedAt = Date.now();
|
|
241
|
-
const was = you;
|
|
242
|
-
you = await userAuth.allowed();
|
|
243
|
-
if (you && !was) {
|
|
244
|
-
log('acting as you: on');
|
|
245
|
-
setTimeout(() => alignAll(), 0);
|
|
246
|
-
} else if (!you && was) {
|
|
247
|
-
logWarn('acting as you: off');
|
|
248
|
-
await sendCard(client, cfg.ownerOpenId, textCard('The bot can no longer act as you (mute, labels, pin). Send `/auth` to allow it again.', '⚠️ Needs you', 'red'), 'open_id').catch(() => {});
|
|
249
|
-
}
|
|
250
|
-
return you;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function labelOf(name) {
|
|
254
|
-
return readState().labels?.[name] || once(`label ${name}`, async () => {
|
|
255
|
-
const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
|
|
256
|
-
const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
|
|
257
|
-
if (!id) throw new Error(`no label ${name}`);
|
|
258
|
-
const state = readState();
|
|
259
|
-
state.labels = { ...(state.labels || {}), [name]: id };
|
|
260
|
-
writeState(state);
|
|
261
|
-
log(`label ${name} (${id})`);
|
|
262
|
-
return id;
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// A label you deleted is made again at the next try.
|
|
267
|
-
async function intoLabel(name, chat) {
|
|
268
|
-
try {
|
|
269
|
-
await userAuth.addToFeedGroup(await labelOf(name), [chat]);
|
|
270
|
-
} catch (err) {
|
|
271
|
-
const state = readState();
|
|
272
|
-
if (state.labels) delete state.labels[name];
|
|
273
|
-
writeState(state);
|
|
274
|
-
throw err;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once the
|
|
279
|
-
// terminal is gone; off takes the group out of it (the label itself stays: it is yours).
|
|
280
|
-
async function syncUserLabel(k) {
|
|
281
|
-
if (!(await asYou())) return;
|
|
282
|
-
const c = readState().chats[k];
|
|
283
|
-
if (!c) return;
|
|
284
|
-
const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scopeOfKey(k))) : null;
|
|
285
|
-
if ((c.label || null) === want) return;
|
|
286
|
-
try {
|
|
287
|
-
if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
|
|
288
|
-
if (want) await intoLabel(want, c.chat);
|
|
289
|
-
setChat(k, { label: want });
|
|
290
|
-
} catch (err) {
|
|
291
|
-
logError(`as you, label of ${c.name}: ${err.message}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change in
|
|
296
|
-
// config.env is applied both ways at the next start.
|
|
297
|
-
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
298
|
-
|
|
299
|
-
async function muteChat(k, chat, space) {
|
|
300
|
-
const want = inSpaces(cfg.muted, space);
|
|
301
|
-
if (Boolean(readState().chats[k]?.muted) === want) return;
|
|
302
|
-
await userAuth.setMuted([chat], want);
|
|
303
|
-
setChat(k, { muted: want });
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Puts a live group where it belongs: its bot tag and, acting as you, muted and in your
|
|
307
|
-
// label. Cheap when nothing is left to do.
|
|
308
|
-
async function placeChat(k, chat, space) {
|
|
309
|
-
await syncBotTags(k);
|
|
310
|
-
if (!(await asYou())) return;
|
|
311
|
-
await muteChat(k, chat, space).catch((err) => logError(`as you, mute ${chat}: ${err.message}`));
|
|
312
|
-
await syncUserLabel(k);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Everything at once, when acting as you turns on: live groups to their labels, ended
|
|
316
|
-
// ones to "ended", bot tags off.
|
|
317
|
-
function alignAll() {
|
|
318
|
-
for (const [k, c] of Object.entries(readState().chats)) {
|
|
319
|
-
if (!cfg.sessions.includes(c.session)) continue;
|
|
320
|
-
later(c.session, addressOfKey(k), 'as you', () => (c.ended ? endChat(k, { again: true }) : placeChat(k, c.chat, c.name.split(' / ')[0])));
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// The group that needs you is pinned while it waits, and unpinned when it no longer does.
|
|
325
|
-
async function pinChat(k, chat) {
|
|
326
|
-
if (!(await asYou())) return;
|
|
327
|
-
try {
|
|
328
|
-
await userAuth.pin(chat);
|
|
329
|
-
const state = readState();
|
|
330
|
-
state.pinned = { ...(state.pinned || {}), [k]: chat };
|
|
331
|
-
writeState(state);
|
|
332
|
-
} catch (err) {
|
|
333
|
-
logError(`pin ${chat}: ${err.message}`);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function unpinChat(k) {
|
|
338
|
-
const chat = readState().pinned?.[k];
|
|
339
|
-
if (!chat) return;
|
|
340
|
-
try {
|
|
341
|
-
await userAuth.unpin(chat);
|
|
342
|
-
} catch (err) {
|
|
343
|
-
logError(`unpin ${chat}: ${err.message}`);
|
|
344
|
-
}
|
|
345
|
-
const state = readState();
|
|
346
|
-
delete state.pinned[k];
|
|
347
|
-
writeState(state);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// In a muted group only a mention rings; a group that is not muted rings anyway. A space
|
|
351
|
-
// in HERDR_SILENT_SPACES gets no mention at all.
|
|
352
|
-
async function mentionYou(k, messageId, text) {
|
|
353
|
-
const c = readState().chats[k];
|
|
354
|
-
if (!messageId || !c?.muted || inSpaces(cfg.silent, c.name.split(' / ')[0]) || !(await asYou())) return;
|
|
355
|
-
await replyMention(client, messageId, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function chatOf(session, address) {
|
|
359
|
-
return readState().chats[key(session, address)];
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function terminalOfChat(chatId) {
|
|
363
|
-
for (const [k, v] of Object.entries(readState().chats)) {
|
|
364
|
-
if (v.chat === chatId) return { session: v.session, address: addressOfKey(k) };
|
|
365
|
-
}
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async function ensureChat(session, record) {
|
|
370
|
-
const where = shownWhere(record);
|
|
371
|
-
if (!where || !cfg.ownerOpenId || !handles(session, record.address)) return null;
|
|
372
|
-
const k = key(session, record.address);
|
|
373
|
-
const name = groupName(where, record);
|
|
374
|
-
// state.json is read again after each await: other terminals' lanes write it meanwhile
|
|
375
|
-
const known = readState().chats[k];
|
|
376
|
-
if (known) {
|
|
377
|
-
if (known.ended) await reviveChat(k, known);
|
|
378
|
-
if (known.name !== name || known.session !== session) {
|
|
379
|
-
if (known.name !== name) await renameChat(client, known.chat, name);
|
|
380
|
-
const state = readState();
|
|
381
|
-
state.chats[k] = { ...state.chats[k], name, session };
|
|
382
|
-
writeState(state);
|
|
383
|
-
log(`renamed group ${known.chat} -> ${name}`);
|
|
384
|
-
}
|
|
385
|
-
await placeChat(k, known.chat, where.space);
|
|
386
|
-
if (!known.avatar) await avatarChat(k, known.chat, where.space);
|
|
387
|
-
return known.chat;
|
|
388
|
-
}
|
|
389
|
-
const chat = await createChat(client, cfg.ownerOpenId, name, `hc terminal ${record.address} (hc session "${session}"). Type here to talk to it.`);
|
|
390
|
-
const state = readState();
|
|
391
|
-
state.chats[k] = { chat, name, session };
|
|
392
|
-
writeState(state);
|
|
393
|
-
log(`created group ${name} (${chat})`);
|
|
394
|
-
await placeChat(k, chat, where.space);
|
|
395
|
-
await avatarChat(k, chat, where.space);
|
|
396
|
-
return chat;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// A terminal that is gone keeps its group, with its history, tagged "ended", so a new
|
|
400
|
-
// terminal in the same place gets a group that is not mistaken for the old one. The tag
|
|
401
|
-
// goes when the terminal comes back.
|
|
402
|
-
async function endChat(k, { again = false } = {}) {
|
|
403
|
-
const known = readState().chats[k];
|
|
404
|
-
if (!known || (known.ended && !again)) return;
|
|
405
|
-
setChat(k, { ended: true });
|
|
406
|
-
if (!again) log(`ended group ${known.name} (${known.chat})`);
|
|
407
|
-
await syncBotTags(k);
|
|
408
|
-
if (!known.avatar) await avatarChat(k, known.chat, known.name.split(' / ')[0]);
|
|
409
|
-
if (!(await asYou())) return;
|
|
410
|
-
await muteChat(k, known.chat, known.name.split(' / ')[0]).catch((err) => logError(`as you, mute ${known.chat}: ${err.message}`));
|
|
411
|
-
await syncUserLabel(k);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function reviveChat(k, known) {
|
|
415
|
-
setChat(k, { ended: false });
|
|
416
|
-
await syncBotTags(k);
|
|
417
|
-
await syncUserLabel(k);
|
|
418
|
-
log(`group ${known.name} (${known.chat}) is live again`);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Groups of terminals that ended while the bot was down get no end event: once every
|
|
422
|
-
// session's first snapshot is in, a group whose terminal is live in no session is ended.
|
|
423
|
-
// A terminal on another machine counts as gone only while that machine is connected,
|
|
424
|
-
// so a dropped connection never ends a group.
|
|
425
|
-
async function endGone() {
|
|
426
|
-
const connected = new Set();
|
|
427
|
-
for (const session of cfg.sessions) {
|
|
428
|
-
for (const m of await machines(session).catch(() => [])) if (m.state === 'connected') connected.add(m.name);
|
|
429
|
-
}
|
|
430
|
-
for (const [k, c] of Object.entries(readState().chats)) {
|
|
431
|
-
const address = addressOfKey(k);
|
|
432
|
-
const machine = machineOf(address);
|
|
433
|
-
if (c.ended && !c.avatar) later(c.session, address, 'avatar', () => avatarChat(k, c.chat, c.name.split(' / ')[0]));
|
|
434
|
-
if (c.ended || !cfg.sessions.includes(c.session) || (machine && !connected.has(machine))) continue;
|
|
435
|
-
if (cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.state === 'live')) continue;
|
|
436
|
-
later(c.session, address, 'end', () => endChat(k));
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// A post is retried; when it still fails, the caller keeps its cursor, so the same
|
|
441
|
-
// content goes out with the next post: a repeat is acceptable, a loss is not.
|
|
442
|
-
function postTo(session, record, text) {
|
|
443
|
-
return deliver(session, record, (chat) => sendMarkdown(client, chat, text));
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
async function deliver(session, record, send) {
|
|
447
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
448
|
-
try {
|
|
449
|
-
const chat = await ensureChat(session, record);
|
|
450
|
-
return chat ? await send(chat) : null;
|
|
451
|
-
} catch (err) {
|
|
452
|
-
if (attempt >= POST_TRIES) throw err;
|
|
453
|
-
logWarn(`post to ${session}/${record.address} failed (${err.message}); retry ${attempt}/${POST_TRIES - 1}`);
|
|
454
|
-
await sleep(1000 * 2 ** attempt);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function screen(session, address, lines = SCREEN_LINES) {
|
|
460
|
-
return `\`\`\`\n${await screenTail(session, address, lines)}\n\`\`\``;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Per terminal, in state.json: the cursor of what its reader posted, which reader and
|
|
464
|
-
// which log that was (kept after the program exits, to post what it left).
|
|
465
|
-
function cursorOf(k) {
|
|
466
|
-
return readState().cursors?.[k] || { ...startCursor(STARTED), reader: null, file: null };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function saveCursor(k, cursor) {
|
|
470
|
-
const state = readState();
|
|
471
|
-
state.cursors = { ...(state.cursors || {}), [k]: cursor };
|
|
472
|
-
writeState(state);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// What you sent from Feishu; its group shows it already, so it is not posted again.
|
|
476
|
-
const fromFeishu = new Map();
|
|
477
|
-
|
|
478
|
-
function formatItems(k, items) {
|
|
479
|
-
const sent = fromFeishu.get(k) || [];
|
|
480
|
-
const parts = [];
|
|
481
|
-
for (const it of items) {
|
|
482
|
-
if (it.role === 'tool') {
|
|
483
|
-
const last = parts[parts.length - 1];
|
|
484
|
-
if (last?.startsWith('▸ ')) parts[parts.length - 1] = `${last}\n${it.text}`;
|
|
485
|
-
else parts.push(it.text);
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
if (it.role === 'image') {
|
|
489
|
-
// uploaded by uploadImages. One you send from the group reaches the terminal as a
|
|
490
|
-
// file path, so it comes back as your text, not as an image.
|
|
491
|
-
const shown = it.key ? `` : `_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'} was not sent: ${it.error})_`;
|
|
492
|
-
parts.push(`${it.from === 'you' ? '**You:**\n' : ''}${shown}`);
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
if (it.role === 'you') {
|
|
496
|
-
const i = sent.indexOf(it.text);
|
|
497
|
-
if (i >= 0) {
|
|
498
|
-
sent.splice(i, 1);
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
parts.push(`**You:**\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
|
|
502
|
-
} else {
|
|
503
|
-
parts.push(it.text);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return parts.join('\n\n');
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// While it works, a turn has one live card: a single message, edited in place as the
|
|
510
|
-
// agent goes on, so the group shows it is alive without a new message (and a ringing
|
|
511
|
-
// phone) every few seconds. The turn's last answer then comes as a new message: you are
|
|
512
|
-
// notified when it starts and when it is done. The card holds everything, not a status
|
|
513
|
-
// line: a full card is closed and the next one goes on, and a card that cannot be edited
|
|
514
|
-
// is sent again as a new one (a repeat, never a loss). It is kept in the cursor
|
|
515
|
-
// (`live: {message, text}`), so a restart goes on editing it.
|
|
516
|
-
const LIVE_CARD_BYTES = 20000; // a card may hold 30 KB; leave room for JSON escaping
|
|
517
|
-
const LIVE_TITLE = '⌛️ Working…';
|
|
518
|
-
const FULL_TITLE = '⌛️ Working… (continued below)'; // a full card while the turn goes on
|
|
519
|
-
const CLOSED_TITLE = '✅ Worked'; // the turn is over
|
|
520
|
-
|
|
521
|
-
async function newLive(session, record, text) {
|
|
522
|
-
const parts = chunk(text);
|
|
523
|
-
let res = null;
|
|
524
|
-
for (const [i, part] of parts.entries()) {
|
|
525
|
-
const last = i === parts.length - 1;
|
|
526
|
-
res = await deliver(session, record, (chat) => sendCard(client, chat, textCard(part, last ? LIVE_TITLE : FULL_TITLE, last ? 'blue' : 'grey')));
|
|
527
|
-
}
|
|
528
|
-
return res?.data?.message_id ? { message: res.data.message_id, text: parts[parts.length - 1] } : null;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
async function addLive(session, record, live, add) {
|
|
532
|
-
if (!live) return newLive(session, record, add);
|
|
533
|
-
const text = `${live.text}\n\n${add}`;
|
|
534
|
-
if (Buffer.byteLength(text) > LIVE_CARD_BYTES) {
|
|
535
|
-
await closeLive(live, FULL_TITLE);
|
|
536
|
-
return newLive(session, record, add);
|
|
537
|
-
}
|
|
538
|
-
try {
|
|
539
|
-
await updateCard(client, live.message, textCard(text, LIVE_TITLE, 'blue'));
|
|
540
|
-
return { message: live.message, text };
|
|
541
|
-
} catch (err) {
|
|
542
|
-
logError(`live card ${live.message} not editable (${err.message}); sending it again`);
|
|
543
|
-
return newLive(session, record, text);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
async function closeLive(live, title = CLOSED_TITLE) {
|
|
548
|
-
try {
|
|
549
|
-
await updateCard(client, live.message, textCard(live.text, title, 'grey'));
|
|
550
|
-
} catch (err) {
|
|
551
|
-
logError(`live card ${live.message} not closed (${err.message})`); // its text is all there
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Images go to Feishu first: each once (by item id), so a post retried after a failure
|
|
556
|
-
// does not upload it again. A failed upload fails the post and the cursor stays; after
|
|
557
|
-
// UPLOAD_TRIES posts that failed on the same image (too big, a format Feishu refuses),
|
|
558
|
-
// the group gets a line saying it was not sent and why, so one image cannot hold back
|
|
559
|
-
// everything after it.
|
|
560
|
-
const UPLOAD_TRIES = 3;
|
|
561
|
-
const uploaded = new Map();
|
|
562
|
-
const uploadFailures = new Map();
|
|
563
|
-
async function uploadImages(items) {
|
|
564
|
-
for (const it of items) {
|
|
565
|
-
if (it.role !== 'image' || it.key || it.error) continue;
|
|
566
|
-
if (!uploaded.has(it.id)) {
|
|
567
|
-
try {
|
|
568
|
-
uploaded.set(it.id, await uploadImage(client, Buffer.from(it.data, 'base64')));
|
|
569
|
-
} catch (err) {
|
|
570
|
-
const failures = (uploadFailures.get(it.id) || 0) + 1;
|
|
571
|
-
uploadFailures.set(it.id, failures);
|
|
572
|
-
if (failures < UPLOAD_TRIES) throw err;
|
|
573
|
-
it.error = err.message;
|
|
574
|
-
logError(`image ${it.id} not sent after ${failures} tries: ${err.message}`);
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
if (uploaded.size > 500) uploaded.delete(uploaded.keys().next().value);
|
|
578
|
-
}
|
|
579
|
-
it.key = uploaded.get(it.id);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// `live`: a post while it works. Everything goes into the live card, except the turn's
|
|
584
|
-
// last answer, which is left for the turn's end (it is not consumed, so nothing is lost).
|
|
585
|
-
// Otherwise (turn end, sweep, a dialog, the end): what came before the last answer goes
|
|
586
|
-
// into the live card, which is closed, and the answer onward is a new message; with no
|
|
587
|
-
// live card, everything is one new message.
|
|
588
|
-
async function postItems(session, record, k, cursor, items, reader, file, { live = false } = {}) {
|
|
589
|
-
const answerAt = items.findIndex((it) => it.end);
|
|
590
|
-
const take = live && answerAt >= 0 ? items.slice(0, answerAt) : items;
|
|
591
|
-
if (!take.length && !(cursor.live && !live)) return;
|
|
592
|
-
await uploadImages(take);
|
|
593
|
-
let open = cursor.live || null;
|
|
594
|
-
const head = live ? take : answerAt >= 0 ? take.slice(0, answerAt) : open ? take : [];
|
|
595
|
-
const tail = live ? [] : take.slice(head.length);
|
|
596
|
-
const headText = formatItems(k, head);
|
|
597
|
-
if (headText) open = await addLive(session, record, open, headText);
|
|
598
|
-
if (!live && open) {
|
|
599
|
-
await closeLive(open);
|
|
600
|
-
open = null;
|
|
601
|
-
}
|
|
602
|
-
const tailText = formatItems(k, tail);
|
|
603
|
-
if (tailText) await mentionYou(k, await postTo(session, record, tailText), '✅ done');
|
|
604
|
-
const last = take[take.length - 1];
|
|
605
|
-
saveCursor(k, { ...advance(cursor, take), reader: reader.app, file, ended: last ? last.role === 'ai' && Boolean(last.end) : cursor.ended, live: open });
|
|
606
|
-
if (take.length) log(`<- ${session}/${record.address}: ${take.length} items (${reader.app}${live ? ', live card' : ''})`);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Posts what a terminal said since the last post, through its reader. With
|
|
610
|
-
// `waitForEnd`, it first waits for the turn's last answer (hc reports the end before
|
|
611
|
-
// the agent writes it). Returns false when the terminal is not readable.
|
|
612
|
-
async function postRead(session, address, record, reader, { waitForEnd, live = false }) {
|
|
613
|
-
const k = key(session, address);
|
|
614
|
-
const cursor = cursorOf(k);
|
|
615
|
-
let read = null;
|
|
616
|
-
for (let attempt = 0; attempt < (waitForEnd ? END_TRIES : 1); attempt += 1) {
|
|
617
|
-
read = await reader.after(session, address, cursor);
|
|
618
|
-
if (!read || read.items.some((it) => it.end)) break;
|
|
619
|
-
if (!read.items.length && cursor.ended) break; // the live posts already reached the turn's end
|
|
620
|
-
await sleep(END_WAIT_MS);
|
|
621
|
-
}
|
|
622
|
-
if (!read) return false;
|
|
623
|
-
await postItems(session, record, k, cursor, read.items, reader, read.file, { live });
|
|
624
|
-
return true;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
async function postTurn(session, address, record) {
|
|
628
|
-
const reader = readerFor(record.status?.app);
|
|
629
|
-
if (reader) {
|
|
630
|
-
try {
|
|
631
|
-
if (await postRead(session, address, record, reader, { waitForEnd: true })) return;
|
|
632
|
-
} catch (err) {
|
|
633
|
-
logError(`${session}/${address}: ${reader.app} not readable (${err.message}); posting the screen`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
await mentionYou(key(session, address), await postTo(session, record, await screen(session, address, TURN_SCREEN_LINES)), '✅ done');
|
|
637
|
-
log(`<- ${session}/${address}: screen`);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// The program is gone: post what its log still holds, then say it ended.
|
|
641
|
-
async function postEnd(session, address, record, reason) {
|
|
642
|
-
const k = key(session, address);
|
|
643
|
-
const cursor = cursorOf(k);
|
|
644
|
-
const reader = readerFor(cursor.reader);
|
|
645
|
-
if (reader && cursor.file) {
|
|
646
|
-
try {
|
|
647
|
-
await postItems(session, record, k, cursor, await reader.rest(session, address, cursor), reader, cursor.file);
|
|
648
|
-
} catch (err) {
|
|
649
|
-
logError(`${session}/${address}: last ${reader.app} entries not readable (${err.message})`);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
await postTo(session, record, `🔚 **Terminal ended**${reason ? ` (${reason})` : ''}`);
|
|
653
|
-
await markDone(k);
|
|
654
|
-
await endChat(k);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ---------- dialogs: it waits for you ----------
|
|
658
|
-
|
|
659
|
-
// What each dialog card showed, by message id, so an answered card keeps it.
|
|
660
|
-
const dialogCards = new Map();
|
|
661
|
-
|
|
662
|
-
function remember(messageId, shown) {
|
|
663
|
-
dialogCards.set(messageId, shown);
|
|
664
|
-
if (dialogCards.size > 200) dialogCards.delete(dialogCards.keys().next().value);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// With a menu, the card shows only the dialog, as plain text that wraps on a phone (the
|
|
668
|
-
// screen above it was posted already, and the options are the buttons); without one,
|
|
669
|
-
// the screen is all there is to go by.
|
|
670
|
-
const escapeMarkdown = (text) => text.replace(/[\\`*_[\]#>~|]/g, '\\$&');
|
|
671
|
-
|
|
672
|
-
// A card that needs you says so first thing, so the chat list shows it at a glance; once
|
|
673
|
-
// it no longer waits, it says that instead (the card's last line says why).
|
|
674
|
-
const NEEDS_TITLE = '⚠️ Needs you';
|
|
675
|
-
const DONE_TITLE = '✅ Done';
|
|
676
|
-
|
|
677
|
-
function dialogOf(status, screenText) {
|
|
678
|
-
const menu = menuOf(screenText);
|
|
679
|
-
const title = `${NEEDS_TITLE}${status?.kind ? ` (${status.kind})` : ''}`;
|
|
680
|
-
const markdown = menu ? escapeMarkdown(menu.text) : `\`\`\`\n${screenText}\n\`\`\``;
|
|
681
|
-
return { menu, title, markdown };
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function dialogCard(session, address, { menu, title, markdown }) {
|
|
685
|
-
return menuCard({ title, markdown, menu, value: { s: session, a: address, k: menu.key } });
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// It waits for you (permission, question, auth). When its screen holds a numbered menu,
|
|
689
|
-
// the card has a button per option; otherwise it shows the screen and how to answer by
|
|
690
|
-
// typing.
|
|
691
|
-
// One dialog gets one card. A dialog is named by its menu key (or, with no menu, by its
|
|
692
|
-
// status words); the name is claimed before anything is sent, so the turn event, a
|
|
693
|
-
// status event and a card click that all see the same dialog post it once. Leaving
|
|
694
|
-
// `blocked` forgets it, so the same dialog asked again later gets a card again.
|
|
695
|
-
const postedDialog = new Map(); // terminal key -> name of the dialog last posted
|
|
696
|
-
|
|
697
|
-
const dialogName = (dialog, status) => (dialog.menu ? dialog.menu.key : `${status?.kind || ''}|${status?.msg || ''}`);
|
|
698
|
-
|
|
699
|
-
function claimDialog(k, dialog, status) {
|
|
700
|
-
const name = dialogName(dialog, status);
|
|
701
|
-
if (postedDialog.get(k) === name) return false;
|
|
702
|
-
postedDialog.set(k, name);
|
|
703
|
-
return true;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// The dialog card still waiting, per terminal. When its dialog goes away (answered in the
|
|
707
|
-
// terminal, or the program moved on to the next one), the card drops its buttons and
|
|
708
|
-
// says so, so the group never shows a question nobody waits on. A card being clicked
|
|
709
|
-
// is not open: its click answers it.
|
|
710
|
-
const openCard = new Map(); // terminal key -> {message, dialog}
|
|
711
|
-
|
|
712
|
-
async function closeCard(k, done) {
|
|
713
|
-
const open = openCard.get(k);
|
|
714
|
-
if (!open) return;
|
|
715
|
-
openCard.delete(k);
|
|
716
|
-
try {
|
|
717
|
-
await updateCard(client, open.message, doneCard(DONE_TITLE, open.dialog.markdown, done));
|
|
718
|
-
} catch (err) {
|
|
719
|
-
logError(`dialog card ${open.message} not closed (${err.message})`);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// `screen`: the screen read when the event came (a promise), so a dialog answered in
|
|
724
|
-
// the terminal while earlier work was still being posted still gets its card. `again`:
|
|
725
|
-
// post it even if it was posted (you typed in the group while it waits).
|
|
726
|
-
// A screen read the moment `blocked` arrives may come before the dialog is drawn: with no
|
|
727
|
-
// menu on it, the screen is read again a few times.
|
|
728
|
-
const DRAW_TRIES = 3;
|
|
729
|
-
const DRAW_WAIT_MS = 400;
|
|
730
|
-
|
|
731
|
-
async function postBlocked(session, address, record, { screen = null, again = false } = {}) {
|
|
732
|
-
const first = await screen;
|
|
733
|
-
let dialog = first ? dialogOf(record.status, first) : null;
|
|
734
|
-
for (let i = 0; i < DRAW_TRIES && !dialog?.menu; i += 1) {
|
|
735
|
-
if (i) await sleep(DRAW_WAIT_MS);
|
|
736
|
-
dialog = dialogOf(record.status, await screenTail(session, address, SCREEN_LINES));
|
|
737
|
-
}
|
|
738
|
-
const k = key(session, address);
|
|
739
|
-
if (!claimDialog(k, dialog, record.status) && !again) return;
|
|
740
|
-
await closeCard(k, 'It moved on; see the next card.');
|
|
741
|
-
if (!dialog.menu) {
|
|
742
|
-
const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
|
|
743
|
-
const chat = readState().chats[k]?.chat;
|
|
744
|
-
if (chat) await pinChat(k, chat);
|
|
745
|
-
await mentionYou(k, sent, '⚠️ needs you');
|
|
746
|
-
} else {
|
|
747
|
-
const res = await deliver(session, record, (chat) => sendCard(client, chat, dialogCard(session, address, dialog)));
|
|
748
|
-
if (res?.data?.message_id) {
|
|
749
|
-
remember(res.data.message_id, dialog);
|
|
750
|
-
openCard.set(k, { message: res.data.message_id, dialog });
|
|
751
|
-
await pinChat(k, res.data.chat_id);
|
|
752
|
-
await mentionYou(k, res.data.message_id, '⚠️ needs you');
|
|
753
|
-
// answered while the card was on its way: nothing will close it later, so now
|
|
754
|
-
const live = views.get(session)?.terminals.get(address);
|
|
755
|
-
if (live?.status?.state !== 'blocked' || postedDialog.get(k) !== dialogName(dialog, record.status)) await closeCard(k, '✔ Answered.');
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// "Type something": pick that option, then type the words.
|
|
762
|
-
async function answerFree(session, address, menu, text) {
|
|
763
|
-
await keys(session, address, [String(menu.free.n)]);
|
|
764
|
-
await sleep(300);
|
|
765
|
-
await send(session, address, text, { force: true });
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
const toast = (type, content) => ({ toast: { type, content } });
|
|
769
|
-
const answered = (shown, done) => ({ ...toast('info', done), card: { type: 'raw', data: doneCard(DONE_TITLE, shown.markdown, done) } });
|
|
770
|
-
|
|
771
|
-
// A click on a dialog card. The screen is read again first, and nothing is pressed
|
|
772
|
-
// unless it still shows the dialog the card was made from (same key) with the same
|
|
773
|
-
// option. After the key, the card turns into the dialog's next step (the next question,
|
|
774
|
-
// the review, a toggled box) or says what was picked.
|
|
775
|
-
async function onCardAction(data) {
|
|
776
|
-
const started = Date.now();
|
|
777
|
-
if (data.operator?.open_id !== cfg.ownerOpenId) return toast('error', 'Only the owner can answer.');
|
|
778
|
-
const v = data.action?.value || {};
|
|
779
|
-
const messageId = data.context?.open_message_id;
|
|
780
|
-
const shown = dialogCards.get(messageId) || { title: NEEDS_TITLE, markdown: '' };
|
|
781
|
-
const record = views.get(v.s)?.terminals.get(v.a);
|
|
782
|
-
if (!record || record.state !== 'live') return answered(shown, 'The terminal has ended.');
|
|
783
|
-
const status = (await getTerminal(v.s, v.a)).status;
|
|
784
|
-
const menu = status?.state === 'blocked' ? menuOf(await screenTail(v.s, v.a, SCREEN_LINES)) : null;
|
|
785
|
-
if (!menu || menu.key !== v.k) {
|
|
786
|
-
if (menu) later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status }));
|
|
787
|
-
return answered(shown, menu ? 'It now asks something else; see the new card.' : 'It no longer waits on this.');
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const k = key(v.s, v.a);
|
|
791
|
-
if (openCard.get(k)?.message === messageId) openCard.delete(k);
|
|
792
|
-
let picked;
|
|
793
|
-
if (v.pick === 'esc' || v.pick === 'tab') {
|
|
794
|
-
await keys(v.s, v.a, [v.pick]);
|
|
795
|
-
picked = v.pick === 'esc' ? 'Esc pressed' : 'Next';
|
|
796
|
-
} else {
|
|
797
|
-
const option = menu.options.find((o) => o.n === v.pick);
|
|
798
|
-
if (!option || option.label !== v.label) return answered(shown, 'The options changed; nothing was pressed.');
|
|
799
|
-
if (option === menu.free) {
|
|
800
|
-
const text = String(data.action?.form_value?.answer || '').trim();
|
|
801
|
-
if (!text) return toast('warning', 'Type your answer first.');
|
|
802
|
-
await answerFree(v.s, v.a, menu, text);
|
|
803
|
-
picked = text;
|
|
804
|
-
} else {
|
|
805
|
-
await keys(v.s, v.a, [String(option.n)]);
|
|
806
|
-
picked = `${option.n}. ${option.label}`;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
log(`-> ${v.s}/${v.a}: picked ${picked}`);
|
|
810
|
-
|
|
811
|
-
// A slow machine: answer the click now, show the next step as a new card after.
|
|
812
|
-
if (Date.now() - started > CLICK_BUDGET_MS) {
|
|
813
|
-
later(v.s, v.a, 'dialog', async () => {
|
|
814
|
-
await sleep(KEY_SETTLE_MS);
|
|
815
|
-
const next = (await getTerminal(v.s, v.a)).status;
|
|
816
|
-
if (next?.state === 'blocked') await postBlocked(v.s, v.a, { ...record, status: next });
|
|
817
|
-
});
|
|
818
|
-
return answered(shown, `✔ ${picked}`);
|
|
819
|
-
}
|
|
820
|
-
await sleep(KEY_SETTLE_MS);
|
|
821
|
-
const nextStatus = (await getTerminal(v.s, v.a)).status;
|
|
822
|
-
if (nextStatus?.state === 'blocked') {
|
|
823
|
-
const next = dialogOf(nextStatus, await screenTail(v.s, v.a, SCREEN_LINES));
|
|
824
|
-
// the same dialog (a toggled box) is redrawn here; a new one only if no card has it yet
|
|
825
|
-
if (next.menu && (next.menu.key === v.k || claimDialog(k, next, nextStatus))) {
|
|
826
|
-
remember(messageId, next);
|
|
827
|
-
openCard.set(k, { message: messageId, dialog: next });
|
|
828
|
-
return { ...toast('info', `✔ ${picked}`), card: { type: 'raw', data: dialogCard(v.s, v.a, next) } };
|
|
829
|
-
}
|
|
830
|
-
if (next.menu) return answered(shown, `✔ ${picked}`); // its card is out already
|
|
831
|
-
// still waiting, on something with no menu: hc sends no new turn for it, so post it
|
|
832
|
-
later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status: nextStatus }));
|
|
833
|
-
}
|
|
834
|
-
return answered(shown, `✔ ${picked}`);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Shown terminals this session runs whose program has a reader, with that reader.
|
|
838
|
-
function readable(session) {
|
|
839
|
-
return [...(views.get(session)?.terminals.values() || [])]
|
|
840
|
-
.filter((record) => shownWhere(record) && handles(session, record.address))
|
|
841
|
-
.map((record) => ({ record, reader: readerFor(record.status?.app) }))
|
|
842
|
-
.filter((x) => x.reader);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// A missed turn event (bot down, event stream reconnected) or a turn posted before its
|
|
846
|
-
// last answer was written would leave content behind; idle readable terminals are
|
|
847
|
-
// checked again at start, after every snapshot and every SWEEP_MS.
|
|
848
|
-
function sweep(session) {
|
|
849
|
-
for (const { record, reader } of readable(session)) {
|
|
850
|
-
if (['working', 'blocked'].includes(record.status?.state)) continue;
|
|
851
|
-
later(session, record.address, 'sweep', async () => {
|
|
852
|
-
await postRead(session, record.address, record, reader, { waitForEnd: false });
|
|
853
|
-
await markDone(key(session, record.address), { olderThan: SWEEP_DONE_MS }); // a turn end that was missed
|
|
854
|
-
});
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// While a readable terminal works, what it says and does is posted every LIVE_MS, so
|
|
859
|
-
// the group shows it is alive; the turn's end then posts the rest. One check per
|
|
860
|
-
// terminal at a time.
|
|
861
|
-
const liveBusy = new Set();
|
|
862
|
-
function live(session) {
|
|
863
|
-
for (const { record, reader } of readable(session)) {
|
|
864
|
-
if (record.status?.state !== 'working') continue;
|
|
865
|
-
const k = key(session, record.address);
|
|
866
|
-
if (liveBusy.has(k)) continue;
|
|
867
|
-
liveBusy.add(k);
|
|
868
|
-
later(session, record.address, 'live', () => postRead(session, record.address, record, reader, { waitForEnd: false, live: true })).finally(() => liveBusy.delete(k));
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// hc sends a turn event only when a program leaves `working`. A program that goes from
|
|
873
|
-
// one dialog straight to the next (a permission, then a question) never works in
|
|
874
|
-
// between, so the status events catch that: blocked, and not coming from working
|
|
875
|
-
// (that case is the turn event's, which posts what it said first).
|
|
876
|
-
const lastState = new Map(); // terminal key -> status state at the last status event
|
|
877
|
-
|
|
878
|
-
function onStatus(session, address, record) {
|
|
879
|
-
const k = key(session, address);
|
|
880
|
-
const was = lastState.get(k);
|
|
881
|
-
const now = record.status?.state;
|
|
882
|
-
lastState.set(k, now);
|
|
883
|
-
if (now !== was) since.set(k, Date.now());
|
|
884
|
-
if (now !== 'blocked') {
|
|
885
|
-
postedDialog.delete(k);
|
|
886
|
-
if (readState().pinned?.[k]) later(session, address, 'unpin', () => unpinChat(k));
|
|
887
|
-
if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
if (was === 'working' || !shownWhere(record) || !handles(session, address)) return;
|
|
891
|
-
// the screen and the status as they are now: by the time the lane gets to it, the
|
|
892
|
-
// dialog may be answered and the status moved on
|
|
893
|
-
const screen = screenTail(session, address, SCREEN_LINES).catch(() => null);
|
|
894
|
-
const status = { ...record.status };
|
|
895
|
-
later(session, address, 'dialog', () => postBlocked(session, address, { ...record, status }, { screen }));
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function watch(session) {
|
|
899
|
-
const view = followEvents(session, {
|
|
900
|
-
log,
|
|
901
|
-
onSnapshot: (list) => {
|
|
902
|
-
firstSnapshot.get(session).done();
|
|
903
|
-
scheduleOverview();
|
|
904
|
-
for (const record of list) if (shownWhere(record)) later(session, record.address, 'group', () => ensureChat(session, record));
|
|
905
|
-
setTimeout(() => sweep(session), 0); // after views has this session's records
|
|
906
|
-
},
|
|
907
|
-
onChange: ({ type, address, record, data }) => {
|
|
908
|
-
scheduleOverview();
|
|
909
|
-
if (type === 'status' && record) onStatus(session, address, record);
|
|
910
|
-
if (['created', 'restarted', 'labels', 'status', 'title'].includes(type) && shownWhere(record)) {
|
|
911
|
-
later(session, address, 'group', () => ensureChat(session, record));
|
|
912
|
-
} else if ((type === 'finished' || type === 'removed') && chatOf(session, address)?.session === session) {
|
|
913
|
-
later(session, address, 'end', () => postEnd(session, address, record || { address }, data?.reason));
|
|
914
|
-
}
|
|
915
|
-
},
|
|
916
|
-
onTurn: ({ address, state, record }) => {
|
|
917
|
-
scheduleOverview();
|
|
918
|
-
log(`turn ${session}/${address}: ${state}${shownWhere(record) ? '' : ' (not shown in herdr-app)'}`);
|
|
919
|
-
if (!shownWhere(record) || !handles(session, address)) return;
|
|
920
|
-
const screen = state === 'blocked' ? screenTail(session, address, SCREEN_LINES).catch(() => null) : null;
|
|
921
|
-
const status = { ...record.status };
|
|
922
|
-
later(session, address, 'turn', async () => {
|
|
923
|
-
if (state === 'blocked') {
|
|
924
|
-
// what it said before stopping to ask comes first
|
|
925
|
-
const reader = readerFor(record.status?.app);
|
|
926
|
-
if (reader) await postRead(session, address, record, reader, { waitForEnd: false }).catch(() => {});
|
|
927
|
-
await postBlocked(session, address, { ...record, status }, { screen });
|
|
928
|
-
return;
|
|
929
|
-
}
|
|
930
|
-
await postTurn(session, address, record);
|
|
931
|
-
await markDone(key(session, address));
|
|
932
|
-
});
|
|
933
|
-
},
|
|
934
|
-
});
|
|
935
|
-
views.set(session, view);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// ---------- Feishu in ----------
|
|
939
|
-
|
|
940
|
-
const seen = new Set();
|
|
941
|
-
|
|
942
|
-
async function reply(chatId, text) {
|
|
943
|
-
await sendMarkdown(client, chatId, text);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// What you sent from a group is marked with a reaction: THINKING once it is typed into
|
|
947
|
-
// the terminal, swapped for OK when the turn that took it is over (the turn ends, a
|
|
948
|
-
// shell shows what the command did, or the terminal ends). A dialog in between is not
|
|
949
|
-
// the end. Which messages wait for their OK is kept in state.json (reacts), so a
|
|
950
|
-
// restart still marks them. Reactions are a nicety: a failure is only logged.
|
|
951
|
-
const WORKING_EMOJI = 'THINKING';
|
|
952
|
-
const DONE_EMOJI = 'OK';
|
|
953
|
-
|
|
954
|
-
async function addReaction(messageId, emoji) {
|
|
955
|
-
const res = await client.im.v1.messageReaction.create({ path: { message_id: messageId }, data: { reaction_type: { emoji_type: emoji } } });
|
|
956
|
-
return res?.data?.reaction_id;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
async function markWorking(k, messageId) {
|
|
960
|
-
try {
|
|
961
|
-
const reaction = await addReaction(messageId, WORKING_EMOJI);
|
|
962
|
-
const state = readState();
|
|
963
|
-
state.reacts = { ...(state.reacts || {}), [k]: [...(state.reacts?.[k] || []), { message: messageId, reaction, at: Date.now() }] };
|
|
964
|
-
writeState(state);
|
|
965
|
-
} catch (err) {
|
|
966
|
-
logError(`reaction on ${messageId}: ${err.message}`);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// `olderThan`: only messages typed in at least this long ago (the sweep: a message just
|
|
971
|
-
// typed in may not have started a turn yet).
|
|
972
|
-
async function markDone(k, { olderThan = 0 } = {}) {
|
|
973
|
-
const all = readState().reacts?.[k] || [];
|
|
974
|
-
const waiting = all.filter((x) => Date.now() - (x.at || 0) >= olderThan);
|
|
975
|
-
if (!waiting.length) return;
|
|
21
|
+
const store = { read: readState, write: writeState };
|
|
22
|
+
|
|
23
|
+
const cfg = loadConfig({ requires: chatModule(chatOf()).requires || [] });
|
|
24
|
+
|
|
25
|
+
// The core is made after the chat app, which needs the core's lanes and keys: these
|
|
26
|
+
// forward to it.
|
|
27
|
+
let engine = null;
|
|
28
|
+
const chat = createChat(cfg.chat, {
|
|
29
|
+
cfg,
|
|
30
|
+
store,
|
|
31
|
+
log,
|
|
32
|
+
logWarn,
|
|
33
|
+
logError,
|
|
34
|
+
stateDir: STATE_DIR,
|
|
35
|
+
lane: (k, label, fn) => engine.laneOf(k, label, fn),
|
|
36
|
+
scopeOf: (k) => engine.scopeOfKey(k),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A bot that is down is a phone that hears nothing, so down time is said in the direct
|
|
40
|
+
// chat: a crash says why (at most once per CRASH_NOTICE_MS, as a restart loop repeats
|
|
41
|
+
// it), and the next connection says how long it was gone.
|
|
42
|
+
const CRASH_NOTICE_MS = 10 * 60 * 1000;
|
|
43
|
+
async function sayCrash(err) {
|
|
44
|
+
if (!chat.ready()) return;
|
|
45
|
+
const now = Date.now();
|
|
976
46
|
const state = readState();
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
47
|
+
const down = { since: now, said: 0, ...state.down, why: String(err?.message || err).slice(0, 300) };
|
|
48
|
+
const say = now - down.said >= CRASH_NOTICE_MS;
|
|
49
|
+
if (say) down.said = now;
|
|
50
|
+
state.down = down;
|
|
980
51
|
writeState(state);
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (reaction) await client.im.v1.messageReaction.delete({ path: { message_id: message, reaction_id: reaction } });
|
|
984
|
-
await addReaction(message, DONE_EMOJI);
|
|
985
|
-
} catch (err) {
|
|
986
|
-
logError(`reaction on ${message}: ${err.message}`);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
async function typeInto(chatId, messageId, session, address, text) {
|
|
992
|
-
const record = views.get(session)?.terminals.get(address);
|
|
993
|
-
if (!record || record.state !== 'live') {
|
|
994
|
-
await reply(chatId, 'This terminal has ended.');
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
if (text === '/screen' || text === '/esc') {
|
|
998
|
-
if (text === '/esc') await keys(session, address, ['esc']);
|
|
999
|
-
await reply(chatId, `${text === '/esc' ? 'Esc pressed.\n\n' : ''}${await screen(session, address)}`);
|
|
1000
|
-
return;
|
|
1001
|
-
}
|
|
1002
|
-
const answer = text.match(/^\/answer\s+([\s\S]+)$/);
|
|
1003
|
-
if (!answer && record.status?.state === 'blocked') {
|
|
1004
|
-
// It waits on a dialog: words go into its "Type something" box when it has one;
|
|
1005
|
-
// otherwise the dialog card comes again, to pick there.
|
|
1006
|
-
const menu = menuOf(await screenTail(session, address, SCREEN_LINES));
|
|
1007
|
-
if (menu?.free) {
|
|
1008
|
-
await answerFree(session, address, menu, text);
|
|
1009
|
-
log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
|
|
1010
|
-
await markWorking(key(session, address), messageId);
|
|
1011
|
-
} else {
|
|
1012
|
-
await postBlocked(session, address, record, { again: true });
|
|
1013
|
-
}
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
try {
|
|
1017
|
-
const typed = answer ? answer[1] : text;
|
|
1018
|
-
await send(session, address, typed, { force: Boolean(answer) });
|
|
1019
|
-
const k = key(session, address);
|
|
1020
|
-
fromFeishu.set(k, [...(fromFeishu.get(k) || []).slice(-20), typed.trim()]);
|
|
1021
|
-
log(`-> ${session}/${address}: ${text.slice(0, 80)}`);
|
|
1022
|
-
await markWorking(key(session, address), messageId);
|
|
1023
|
-
} catch (err) {
|
|
1024
|
-
const hint = /blocked/i.test(err.message) ? '\n\nIt waits on a dialog. Reply `/answer <text>` to answer it, or `/esc` to cancel it.' : '';
|
|
1025
|
-
await reply(chatId, `Not sent: ${err.message}\n\n${await screen(session, address)}${hint}`);
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
// A shell reports no turns, so show what the command did.
|
|
1029
|
-
const foreground = (await getTerminal(session, address)).foreground?.name;
|
|
1030
|
-
if (!record.status && SHELLS.has(foreground)) {
|
|
1031
|
-
await sleep(SHELL_ECHO_MS);
|
|
1032
|
-
await reply(chatId, await screen(session, address));
|
|
1033
|
-
await markDone(key(session, address));
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// Images you send to a group: saved here, sent beside the terminal (on its machine) with
|
|
1038
|
-
// `hc put`, and typed in as their paths, which an agent like Claude Code opens.
|
|
1039
|
-
const INBOX = path.join(os.tmpdir(), 'herdrchat-inbox');
|
|
1040
|
-
|
|
1041
|
-
function extOf(head) {
|
|
1042
|
-
if (head[0] === 0x89 && head.toString('latin1', 1, 4) === 'PNG') return 'png';
|
|
1043
|
-
if (head[0] === 0xff && head[1] === 0xd8) return 'jpg';
|
|
1044
|
-
if (head.toString('latin1', 0, 4) === 'GIF8') return 'gif';
|
|
1045
|
-
if (head.toString('latin1', 0, 4) === 'RIFF' && head.toString('latin1', 8, 12) === 'WEBP') return 'webp';
|
|
1046
|
-
return 'png';
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
async function imagesInto(messageId, session, address, imageKeys) {
|
|
1050
|
-
fs.mkdirSync(INBOX, { recursive: true });
|
|
1051
|
-
const landed = [];
|
|
1052
|
-
for (const [i, imageKey] of imageKeys.entries()) {
|
|
1053
|
-
const raw = path.join(INBOX, `${messageId}-${i}`);
|
|
1054
|
-
await downloadImage(client, messageId, imageKey, raw);
|
|
1055
|
-
const file = `${raw}.${extOf(fs.readFileSync(raw).subarray(0, 12))}`;
|
|
1056
|
-
fs.renameSync(raw, file);
|
|
1057
|
-
landed.push(await put(session, address, file));
|
|
1058
|
-
fs.unlinkSync(file); // the terminal has its own copy now
|
|
1059
|
-
}
|
|
1060
|
-
return landed;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
// ---------- the overview, in the direct chat ----------
|
|
1064
|
-
|
|
1065
|
-
// The overview card (lib/overview.mjs) is edited whenever a terminal changes; changes
|
|
1066
|
-
// within OVERVIEW_WAIT_MS make one edit, and an edit that would change nothing is
|
|
1067
|
-
// skipped. Which message it is lives in state.json (overview). `/ls` sends a new one at
|
|
1068
|
-
// the bottom of the chat and the old one points to it.
|
|
1069
|
-
const OVERVIEW_WAIT_MS = 2000;
|
|
1070
|
-
const since = new Map(); // terminal key -> when its status state last changed
|
|
1071
|
-
let overviewTimer = null;
|
|
1072
|
-
let overviewShown = null;
|
|
1073
|
-
|
|
1074
|
-
function overviewRows() {
|
|
1075
|
-
const rows = [];
|
|
1076
|
-
for (const session of cfg.sessions) {
|
|
1077
|
-
for (const record of views.get(session)?.terminals.values() || []) {
|
|
1078
|
-
const where = shownWhere(record);
|
|
1079
|
-
if (!where || !handles(session, record.address)) continue;
|
|
1080
|
-
const k = key(session, record.address);
|
|
1081
|
-
rows.push({
|
|
1082
|
-
name: groupName(where, record),
|
|
1083
|
-
address: session === cfg.sessions[0] || machineOf(record.address) ? record.address : `${session} ${record.address}`,
|
|
1084
|
-
state: record.status?.state,
|
|
1085
|
-
kind: record.status?.kind,
|
|
1086
|
-
program: record.foreground?.name || record.agent,
|
|
1087
|
-
since: since.get(k),
|
|
1088
|
-
chat: readState().chats[k]?.chat,
|
|
1089
|
-
});
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
return rows;
|
|
52
|
+
if (!say) return;
|
|
53
|
+
await chat.tellOwner({ title: '🔴 herdrchat is down', tone: 'bad', markdown: `herdrchat stopped: ${down.why}\n\nIt is being started again. Until it is back, nothing you send here reaches your terminals.` });
|
|
1093
54
|
}
|
|
1094
|
-
|
|
1095
|
-
async function sendOverview(card) {
|
|
1096
|
-
const res = await sendCard(client, cfg.ownerOpenId, card, 'open_id');
|
|
55
|
+
async function sayBack() {
|
|
1097
56
|
const state = readState();
|
|
1098
|
-
|
|
57
|
+
const { down } = state;
|
|
58
|
+
if (!down) return;
|
|
59
|
+
delete state.down;
|
|
1099
60
|
writeState(state);
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
await updateCard(client, old, card);
|
|
1118
|
-
overviewShown = text;
|
|
1119
|
-
return;
|
|
1120
|
-
} catch (err) {
|
|
1121
|
-
logWarn(`overview ${old} not edited (${err.message}); ${attempt < OVERVIEW_TRIES ? 'trying again' : 'sending a new one'}`);
|
|
1122
|
-
if (attempt < OVERVIEW_TRIES) await sleep(1000 * 2 ** attempt);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
await sendOverview(card);
|
|
1127
|
-
overviewShown = text;
|
|
1128
|
-
if (old) await recallMessage(client, old).catch((err) => logError(`old overview ${old} not recalled (${err.message})`));
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
function scheduleOverview() {
|
|
1132
|
-
if (overviewTimer) return;
|
|
1133
|
-
overviewTimer = setTimeout(() => {
|
|
1134
|
-
overviewTimer = null;
|
|
1135
|
-
refreshOverview().catch((err) => logError(`overview: ${err.message}`));
|
|
1136
|
-
}, OVERVIEW_WAIT_MS);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
const HELP = [
|
|
1140
|
-
'`/ls` a new overview of every terminal (the overview also keeps itself up to date)',
|
|
1141
|
-
'`/new <session> <space>/<tab> [command]` start a terminal (default `claude`); `--cwd <dir>` first to pick its folder',
|
|
1142
|
-
'`/kill <session> <address>` end a terminal',
|
|
1143
|
-
'`/auth` let the bot act as you for chat-list settings (mute, labels, pin)',
|
|
1144
|
-
'In a terminal\'s group: type to talk to it; `/screen`, `/answer <text>`, `/esc`.',
|
|
1145
|
-
].join('\n');
|
|
1146
|
-
|
|
1147
|
-
async function newTerminal(args) {
|
|
1148
|
-
let cwd = null;
|
|
1149
|
-
if (args[0] === '--cwd') {
|
|
1150
|
-
cwd = args[1];
|
|
1151
|
-
args = args.slice(2);
|
|
1152
|
-
}
|
|
1153
|
-
const [session, place, ...command] = args;
|
|
1154
|
-
if (!cfg.sessions.includes(session)) throw new Error(`unknown session "${session}"; known: ${cfg.sessions.join(', ')}`);
|
|
1155
|
-
const slash = (place || '').indexOf('/');
|
|
1156
|
-
if (slash < 1 || slash === place.length - 1) throw new Error('give the place as <space>/<tab>');
|
|
1157
|
-
const space = place.slice(0, slash);
|
|
1158
|
-
const tab = place.slice(slash + 1);
|
|
1159
|
-
const sibling = [...views.get(session).terminals.values()].find((t) => shownWhere(t)?.space === space && t.labels?.['herdr.space']);
|
|
1160
|
-
const spaceId = sibling?.labels['herdr.space'] || `sp${crypto.randomBytes(16).toString('hex')}`;
|
|
1161
|
-
const folder = cwd || sibling?.foreground?.cwd || sibling?.cwd || os.homedir();
|
|
1162
|
-
const line = command.length ? command.join(' ') : 'claude';
|
|
1163
|
-
const out = await run(session, {
|
|
1164
|
-
cwd: folder,
|
|
1165
|
-
labels: { 'herdr.space': spaceId, 'herdr.tab': `tb${crypto.randomBytes(16).toString('hex')}`, where: `${space} / ${tab}` },
|
|
1166
|
-
argv: ['/bin/zsh', '-lc', `exec ${line}`], // login shell for PATH; exec so no bare shell is left behind
|
|
1167
|
-
});
|
|
1168
|
-
const address = JSON.parse(out).address;
|
|
1169
|
-
return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its group appears in a moment.`;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// `/auth`: the device flow, in the direct chat. The link opens Feishu's own consent page;
|
|
1173
|
-
// the bot waits for your yes in the background.
|
|
1174
|
-
async function authorize(chatId) {
|
|
1175
|
-
const device = await userAuth.startDeviceFlow();
|
|
1176
|
-
await reply(chatId, `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.`);
|
|
1177
|
-
userAuth.waitForToken(device)
|
|
1178
|
-
.then(async (t) => {
|
|
1179
|
-
await reply(chatId, `✅ Allowed${t.refresh ? '' : ' (no refresh token: it lasts about two hours)'}. Muting the groups and putting them in your labels now.`);
|
|
1180
|
-
youCheckedAt = 0;
|
|
1181
|
-
await asYou();
|
|
1182
|
-
})
|
|
1183
|
-
.catch((err) => reply(chatId, `Not allowed: ${err.message}`));
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
async function admin(chatId, text) {
|
|
1187
|
-
const [cmd, ...args] = text.split(/\s+/);
|
|
1188
|
-
if (cmd === '/ls') return refreshOverview({ fresh: true });
|
|
1189
|
-
if (cmd === '/auth') return authorize(chatId);
|
|
1190
|
-
if (cmd === '/new') return reply(chatId, await newTerminal(args));
|
|
1191
|
-
if (cmd === '/kill') {
|
|
1192
|
-
const [session, address] = args;
|
|
1193
|
-
if (!cfg.sessions.includes(session) || !address) throw new Error('give `/kill <session> <address>`');
|
|
1194
|
-
await hc(session, ['kill', address]);
|
|
1195
|
-
return reply(chatId, `Ended \`${address}\` in ${session}.`);
|
|
1196
|
-
}
|
|
1197
|
-
return reply(chatId, HELP);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
async function onMessage(data) {
|
|
1201
|
-
const msg = data.message;
|
|
1202
|
-
if (seen.has(msg.message_id)) return;
|
|
1203
|
-
seen.add(msg.message_id);
|
|
1204
|
-
if (seen.size > 1000) seen.delete(seen.values().next().value);
|
|
1205
|
-
|
|
1206
|
-
const openId = data.sender?.sender_id?.open_id;
|
|
1207
|
-
if (!cfg.ownerOpenId) {
|
|
1208
|
-
log(`owner not set; message from open_id ${openId}`);
|
|
1209
|
-
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Owner is not set. Put this in ${CONFIG_FILE}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen restart the bot.`);
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
if (openId !== cfg.ownerOpenId) {
|
|
1213
|
-
logWarn(`ignored message from ${openId}`);
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
const parts = messageParts(msg.message_type, msg.content);
|
|
1217
|
-
if (!parts) {
|
|
1218
|
-
await reply(msg.chat_id, 'Only text and images are passed on.');
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
const text = parts.text.replace(/@_user_\d+/g, '').trim();
|
|
1222
|
-
if (!text && !parts.images.length) return;
|
|
1223
|
-
|
|
1224
|
-
try {
|
|
1225
|
-
if (msg.chat_type === 'p2p') {
|
|
1226
|
-
if (parts.images.length) await reply(msg.chat_id, 'Send images in a terminal\'s group.');
|
|
1227
|
-
else await admin(msg.chat_id, text);
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
const target = terminalOfChat(msg.chat_id);
|
|
1231
|
-
if (!target) {
|
|
1232
|
-
log(`message in a group this bot did not make: ${msg.chat_id}${msg.thread_id ? ` (thread ${msg.thread_id})` : ''}`);
|
|
1233
|
-
return;
|
|
1234
|
-
}
|
|
1235
|
-
const files = await imagesInto(msg.message_id, target.session, target.address, parts.images);
|
|
1236
|
-
await typeInto(msg.chat_id, msg.message_id, target.session, target.address, [text, ...files].filter(Boolean).join(' '));
|
|
1237
|
-
} catch (err) {
|
|
1238
|
-
await reply(msg.chat_id, err.message);
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// ---------- start ----------
|
|
1243
|
-
|
|
1244
|
-
log(`herdrchat: sessions ${cfg.sessions.join(', ')}`);
|
|
1245
|
-
if (!cfg.ownerOpenId) log('owner not set: no groups are made until it is. Send the bot any direct message.');
|
|
1246
|
-
startEvents(cfg, onMessage, onCardAction);
|
|
1247
|
-
for (const session of cfg.sessions) watch(session);
|
|
1248
|
-
ready.then(() => setTimeout(async () => {
|
|
1249
|
-
await endGone().catch((err) => logError(`end gone groups: ${err.message}`));
|
|
1250
|
-
if (await asYou()) {
|
|
1251
|
-
// pins left from before a restart, for terminals that no longer wait
|
|
1252
|
-
for (const k of Object.keys(readState().pinned || {})) {
|
|
1253
|
-
const address = addressOfKey(k);
|
|
1254
|
-
if (!cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.status?.state === 'blocked')) await unpinChat(k);
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
}, 0));
|
|
1258
|
-
setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
|
|
1259
|
-
setInterval(() => cfg.sessions.forEach(sweep), SWEEP_MS);
|
|
1260
|
-
setInterval(() => cfg.sessions.forEach(live), LIVE_MS);
|
|
61
|
+
if (!chat.ready()) return;
|
|
62
|
+
const minutes = Math.max(1, Math.round((Date.now() - down.since) / 60000));
|
|
63
|
+
await chat.tellOwner({ title: '✅ herdrchat is back', tone: 'good', markdown: `herdrchat is back after about ${minutes} min. What stopped it: ${down.why}` });
|
|
64
|
+
}
|
|
65
|
+
logger.capture({ onCrash: sayCrash });
|
|
66
|
+
|
|
67
|
+
engine = createEngine({
|
|
68
|
+
cfg,
|
|
69
|
+
chat,
|
|
70
|
+
hc: { followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill: (session, address) => hc(session, ['kill', address]) },
|
|
71
|
+
readers,
|
|
72
|
+
store,
|
|
73
|
+
log,
|
|
74
|
+
logWarn,
|
|
75
|
+
logError,
|
|
76
|
+
});
|
|
77
|
+
engine.start({ onReady: () => sayBack().catch((err) => logError(`back notice: ${err.message}`)) });
|