jeo-code 0.7.47 → 0.7.49
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/CHANGELOG.md +34 -0
- package/README.ja.md +2 -2
- package/README.ko.md +5 -4
- package/README.md +5 -4
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +170 -10
- package/src/agent/notify/telegram-daemon.ts +490 -21
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/launch.ts +141 -4
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity
|
|
3
|
-
* scoped down to jeo's subagent-remote-control surface — no forum topics, no
|
|
4
|
-
* inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo
|
|
5
|
-
* intentionally does not replicate from gjc's full notification stack).
|
|
6
|
-
*
|
|
2
|
+
* The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity).
|
|
7
3
|
* Runs as ONE long-lived background process (singleton, enforced by
|
|
8
4
|
* `daemon-control.ts`'s lock file — Telegram allows only one `getUpdates`
|
|
9
5
|
* long-poll owner per bot token). It:
|
|
@@ -12,19 +8,42 @@
|
|
|
12
8
|
* discovery files and connects a loopback WebSocket to each
|
|
13
9
|
* (`SessionNotifyEndpoint` on the other end);
|
|
14
10
|
* - sends a Telegram message on each subagent state EDGE (started → a
|
|
15
|
-
* terminal state), not on every poll tick
|
|
11
|
+
* terminal state), not on every poll tick, attaching an inline "Cancel"
|
|
12
|
+
* keyboard button for the running subagent;
|
|
16
13
|
* - long-polls Telegram `getUpdates` and dispatches `/subagents`,
|
|
17
14
|
* `/steer <sessionId> <subagentId> <message>`, `/cancel <sessionId>
|
|
18
|
-
* <subagentId>`, `/help`
|
|
15
|
+
* <subagentId>`, `/help` — plus inline-keyboard button taps
|
|
16
|
+
* (`callback_query`) — back into the matching session's WebSocket.
|
|
17
|
+
*
|
|
18
|
+
* gjc-parity surface added on top of the plain-text core: forum topics (all
|
|
19
|
+
* pushes carry the configured `message_thread_id`, inbound is topic-filtered),
|
|
20
|
+
* inline keyboards (cancel buttons + `callback_query` handling), image
|
|
21
|
+
* attachments (a session may push a `{type:"photo"}` frame relayed via
|
|
22
|
+
* `sendPhoto`), a shared `RateLimitPool` (burst/steady-rate protection across
|
|
23
|
+
* every session sharing this one bot token), and — when
|
|
24
|
+
* `notifications.telegram.perSessionTopics` is enabled — a dynamic,
|
|
25
|
+
* PER-SESSION forum topic (via `TopicRegistry`) that mirrors that session's
|
|
26
|
+
* OWN activity (`identity_header`/`context_update`/`turn_stream` frames) and
|
|
27
|
+
* accepts free-text replies (routed back as `user_message`) and
|
|
28
|
+
* `/verbose`/`/lean`/`/redact` config commands, ALL scoped to that one
|
|
29
|
+
* session's topic. The flat/global `topicId` behavior below is UNCHANGED when
|
|
30
|
+
* `perSessionTopics` is off (or unset) — zero regression risk for existing
|
|
31
|
+
* setups.
|
|
19
32
|
*/
|
|
20
33
|
import * as fs from "node:fs/promises";
|
|
34
|
+
import * as os from "node:os";
|
|
21
35
|
import * as path from "node:path";
|
|
22
36
|
import { readGlobalConfig } from "../state";
|
|
23
|
-
import { notifySessionsDir } from "./paths";
|
|
37
|
+
import { notifySessionsDir, notifyTopicsPath } from "./paths";
|
|
24
38
|
import { acquireDaemonLock, isPidAlive } from "./daemon-control";
|
|
25
|
-
import { TelegramApi, type TelegramUpdate } from "./telegram-api";
|
|
39
|
+
import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
|
|
40
|
+
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
41
|
+
import { RateLimitPool } from "./rate-limit-pool";
|
|
42
|
+
import { parseInThreadConfigCommand } from "./config-commands";
|
|
43
|
+
import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
|
|
26
44
|
import type { SubagentRecord } from "../subagent-registry";
|
|
27
45
|
|
|
46
|
+
|
|
28
47
|
function sleep(ms: number): Promise<void> {
|
|
29
48
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
30
49
|
setTimeout(resolve, ms);
|
|
@@ -109,7 +128,50 @@ export const HELP_TEXT =
|
|
|
109
128
|
"/subagents — list running/recent subagents across every connected session\n" +
|
|
110
129
|
"/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
|
|
111
130
|
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
112
|
-
"/
|
|
131
|
+
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
132
|
+
"/help — show this message\n" +
|
|
133
|
+
"(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)\n\n" +
|
|
134
|
+
"Inside a session's OWN topic (when per-session topics are enabled): reply with any " +
|
|
135
|
+
"text to steer that session directly, or use /verbose, /lean, /verbosity lean|verbose, " +
|
|
136
|
+
"/redact on|off to adjust how that session mirrors its activity here.";
|
|
137
|
+
|
|
138
|
+
// ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** Encode an inline-button cancel target. Kept short to stay under Telegram's
|
|
141
|
+
* 64-byte `callback_data` cap. */
|
|
142
|
+
export function cancelCallbackData(shortId: string, subagentId: string): string {
|
|
143
|
+
return `cancel:${shortId}:${subagentId}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parse a `callback_data` payload produced by `cancelCallbackData`. */
|
|
147
|
+
export function parseCallbackData(
|
|
148
|
+
data: string,
|
|
149
|
+
): { action: "cancel"; shortId: string; subagentId: string } | undefined {
|
|
150
|
+
const m = /^cancel:([^:]+):([\s\S]+)$/.exec(data);
|
|
151
|
+
if (!m) return undefined;
|
|
152
|
+
return { action: "cancel", shortId: m[1]!, subagentId: m[2]! };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One ⏹ Cancel button (its own row) targeting a single running subagent. */
|
|
156
|
+
function cancelButton(shortId: string, rec: SubagentRecord): InlineKeyboardButton {
|
|
157
|
+
return { text: `⏹ Cancel ${rec.role} '${rec.id}'`, callback_data: cancelCallbackData(shortId, rec.id) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Inline keyboard with one ⏹ Cancel button per RUNNING subagent across every
|
|
161
|
+
* connected session; returns undefined when nothing is running (no keyboard to
|
|
162
|
+
* attach). */
|
|
163
|
+
export function buildSubagentsKeyboard(
|
|
164
|
+
sessions: Array<{ sessionId: string; records: SubagentRecord[] }>,
|
|
165
|
+
): InlineKeyboardMarkup | undefined {
|
|
166
|
+
const rows: InlineKeyboardButton[][] = [];
|
|
167
|
+
for (const s of sessions) {
|
|
168
|
+
for (const r of s.records) {
|
|
169
|
+
if (r.status === "running") rows.push([cancelButton(shortSessionId(s.sessionId), r)]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return rows.length ? { inline_keyboard: rows } : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
113
175
|
|
|
114
176
|
// ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
|
|
115
177
|
|
|
@@ -119,6 +181,14 @@ interface SessionConnection {
|
|
|
119
181
|
pid: number;
|
|
120
182
|
ws: WebSocket;
|
|
121
183
|
lastRecords: SubagentRecord[];
|
|
184
|
+
/** Latest identity, once an `identity_header` frame arrives — used to name/
|
|
185
|
+
* rename the session's forum topic. `undefined` until then (topic still
|
|
186
|
+
* gets created eagerly with a provisional short-id name, gjc parity). */
|
|
187
|
+
identity?: { repo: string; branch?: string; cwd: string };
|
|
188
|
+
/** True once this session's per-session topic creation has been attempted
|
|
189
|
+
* and failed (e.g. Threaded Mode off) — stops a retry storm; falls back to
|
|
190
|
+
* no per-session mirroring for the rest of this connection's lifetime. */
|
|
191
|
+
topicFailed?: boolean;
|
|
122
192
|
}
|
|
123
193
|
|
|
124
194
|
export interface TelegramDaemonOptions {
|
|
@@ -131,8 +201,31 @@ export interface TelegramDaemonOptions {
|
|
|
131
201
|
isPidAlive?: (pid: number) => boolean;
|
|
132
202
|
scanIntervalMs?: number;
|
|
133
203
|
ackTimeoutMs?: number;
|
|
204
|
+
/** Forum-topic thread id (message_thread_id). When set, every push is sent
|
|
205
|
+
* into this topic and inbound messages/taps from other topics are ignored.
|
|
206
|
+
* Ignored for a session with its OWN per-session topic when
|
|
207
|
+
* `perSessionTopics` is true. */
|
|
208
|
+
topicId?: number;
|
|
209
|
+
/** Auto-create/manage one forum topic PER SESSION instead of the single flat
|
|
210
|
+
* `topicId` above (see the file header doc). Off by default. */
|
|
211
|
+
perSessionTopics?: boolean;
|
|
212
|
+
/** Injectable persistence for the per-session topic map — defaults to
|
|
213
|
+
* reading/writing `notifyTopicsPath()` via `fs`. */
|
|
214
|
+
loadTopicState?: () => Promise<TopicRegistryState>;
|
|
215
|
+
saveTopicState?: (state: TopicRegistryState) => Promise<void>;
|
|
216
|
+
/** Injectable clock for `RateLimitPool` determinism in tests. */
|
|
217
|
+
now?: () => number;
|
|
218
|
+
/** Injectable temp-file writer for downloaded inbound attachments — defaults
|
|
219
|
+
* to a real `os.tmpdir()` write. Returns the written file's absolute path. */
|
|
220
|
+
writeTempFile?: (bytes: Uint8Array, suggestedName: string) => Promise<string>;
|
|
134
221
|
}
|
|
135
222
|
|
|
223
|
+
/** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
|
|
224
|
+
* variant onto the matching Bot API call. */
|
|
225
|
+
type OutboundPayload =
|
|
226
|
+
| { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean }
|
|
227
|
+
| { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup };
|
|
228
|
+
|
|
136
229
|
export class TelegramDaemon {
|
|
137
230
|
private readonly WS: typeof WebSocket;
|
|
138
231
|
readonly sessions = new Map<string, SessionConnection>();
|
|
@@ -140,16 +233,83 @@ export class TelegramDaemon {
|
|
|
140
233
|
private updateOffset: number | undefined;
|
|
141
234
|
private stopped = false;
|
|
142
235
|
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
236
|
+
private poolTimer: ReturnType<typeof setInterval> | undefined;
|
|
237
|
+
/** `undefined` when `perSessionTopics` is off — the flat/global `topicId`
|
|
238
|
+
* path never touches this. */
|
|
239
|
+
private readonly topics: TopicRegistry | undefined;
|
|
240
|
+
private readonly pool: RateLimitPool<OutboundPayload>;
|
|
143
241
|
|
|
144
242
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
145
243
|
this.WS = opts.WebSocketImpl ?? WebSocket;
|
|
244
|
+
this.topics = opts.perSessionTopics ? new TopicRegistry() : undefined;
|
|
245
|
+
this.pool = new RateLimitPool<OutboundPayload>({ now: opts.now });
|
|
146
246
|
}
|
|
147
247
|
|
|
148
|
-
private async notify(
|
|
248
|
+
private async notify(
|
|
249
|
+
text: string,
|
|
250
|
+
replyMarkup?: InlineKeyboardMarkup,
|
|
251
|
+
opts?: { topicId?: number; sessionId?: string; html?: boolean },
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
this.pool.submit({
|
|
254
|
+
sessionId: opts?.sessionId ?? "__global__",
|
|
255
|
+
lane: "finalized",
|
|
256
|
+
payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html },
|
|
257
|
+
});
|
|
258
|
+
await this.flushPool();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async notifyPhoto(
|
|
262
|
+
photo: string,
|
|
263
|
+
caption?: string,
|
|
264
|
+
replyMarkup?: InlineKeyboardMarkup,
|
|
265
|
+
opts?: { topicId?: number; sessionId?: string },
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
this.pool.submit({
|
|
268
|
+
sessionId: opts?.sessionId ?? "__global__",
|
|
269
|
+
lane: "finalized",
|
|
270
|
+
payload: { kind: "photo", chatId: this.opts.chatId, photo, topicId: opts?.topicId ?? this.opts.topicId, caption, replyMarkup },
|
|
271
|
+
});
|
|
272
|
+
await this.flushPool();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async answerCallback(id: string, text?: string): Promise<void> {
|
|
149
276
|
try {
|
|
150
|
-
await this.opts.telegram.
|
|
277
|
+
await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
|
|
151
278
|
} catch {
|
|
152
|
-
// best-effort —
|
|
279
|
+
// best-effort — acknowledging the tap is nice-to-have, not load-bearing.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Grant queued pool items tokens and actually dispatch them via the Bot API.
|
|
284
|
+
* Called after every `submit()` (drains whatever tokens are free RIGHT NOW —
|
|
285
|
+
* the common case, near-synchronous like the pre-pool behavior) AND by a
|
|
286
|
+
* 250ms timer (catches anything that queued because the burst capacity was
|
|
287
|
+
* briefly exhausted — e.g. a large fan-out's subagents all finishing near-
|
|
288
|
+
* simultaneously). `splitTelegramHtml` is applied to EVERY text send (not
|
|
289
|
+
* just HTML ones — it degrades to plain char-boundary splitting when no
|
|
290
|
+
* tags are present), so nothing this daemon sends can exceed Telegram's
|
|
291
|
+
* 4096-char hard limit regardless of source. */
|
|
292
|
+
private async flushPool(): Promise<void> {
|
|
293
|
+
const granted = this.pool.drain();
|
|
294
|
+
for (const item of granted) {
|
|
295
|
+
const p = item.payload;
|
|
296
|
+
try {
|
|
297
|
+
if (p.kind === "text") {
|
|
298
|
+
const chunks = splitTelegramHtml(p.text);
|
|
299
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
300
|
+
await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
|
|
301
|
+
messageThreadId: p.topicId,
|
|
302
|
+
// A reply keyboard belongs on the LAST fragment a split reply ends on.
|
|
303
|
+
replyMarkup: i === chunks.length - 1 ? p.replyMarkup : undefined,
|
|
304
|
+
parseMode: p.html ? "HTML" : undefined,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// best-effort — a failed push must never crash the daemon loop.
|
|
312
|
+
}
|
|
153
313
|
}
|
|
154
314
|
}
|
|
155
315
|
|
|
@@ -194,6 +354,97 @@ export class TelegramDaemon {
|
|
|
194
354
|
});
|
|
195
355
|
}
|
|
196
356
|
|
|
357
|
+
/** Resolve the topic to push INTO for `conn`: its own per-session topic
|
|
358
|
+
* (created on first use, cached thereafter) when `perSessionTopics` is on
|
|
359
|
+
* and creation hasn't already failed for this connection; the flat/global
|
|
360
|
+
* `topicId` otherwise (unchanged pre-Tier-2 behavior). Never throws — a
|
|
361
|
+
* creation failure (e.g. Threaded Mode off in @BotFather) is remembered on
|
|
362
|
+
* `conn.topicFailed` so it is not retried every single frame. */
|
|
363
|
+
private async resolveTopicId(conn: SessionConnection): Promise<number | undefined> {
|
|
364
|
+
if (!this.opts.perSessionTopics || !this.topics || conn.topicFailed) return this.opts.topicId;
|
|
365
|
+
// Fail-closed privacy gate (contract requirement): per-session topics carry
|
|
366
|
+
// that session's OWN activity mirroring + accept free-text steering — both
|
|
367
|
+
// are only safe in a confirmed 1:1 private DM. A group/channel `chatId`
|
|
368
|
+
// (e.g. misconfigured `jeo notify setup`) must NEVER get per-session
|
|
369
|
+
// topics/routing; it silently falls back to the flat/global `topicId`
|
|
370
|
+
// instead (still fully functional for subagent-status pushes, just without
|
|
371
|
+
// the per-session surface). Checked once per daemon lifetime and cached —
|
|
372
|
+
// chat type cannot change without re-running `jeo notify setup` anyway.
|
|
373
|
+
if (!(await this.pairedChatIsPrivate())) return this.opts.topicId;
|
|
374
|
+
const provisional = conn.identity
|
|
375
|
+
? `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`
|
|
376
|
+
: `session ${shortSessionId(conn.sessionId)}`;
|
|
377
|
+
try {
|
|
378
|
+
const record = await this.topics.getOrCreateTopic(
|
|
379
|
+
conn.sessionId,
|
|
380
|
+
async () => {
|
|
381
|
+
const res = await this.opts.telegram.createForumTopic(this.opts.chatId, provisional);
|
|
382
|
+
const tid = res.result?.message_thread_id;
|
|
383
|
+
if (tid === undefined) throw new Error("createForumTopic: no message_thread_id");
|
|
384
|
+
return tid;
|
|
385
|
+
},
|
|
386
|
+
undefined,
|
|
387
|
+
provisional,
|
|
388
|
+
);
|
|
389
|
+
void this.persistTopics();
|
|
390
|
+
return record.topicId;
|
|
391
|
+
} catch {
|
|
392
|
+
conn.topicFailed = true;
|
|
393
|
+
return this.opts.topicId;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Cached (undefined = not yet checked) result of `getChat`'s `type ===
|
|
398
|
+
* "private"` — a `getChat` failure fails CLOSED (treated as not-private,
|
|
399
|
+
* never retried, matching `resolveTopicId`'s own no-retry-storm posture). */
|
|
400
|
+
private pairedChatPrivate: boolean | undefined;
|
|
401
|
+
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
402
|
+
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
403
|
+
try {
|
|
404
|
+
const res = await this.opts.telegram.getChat(this.opts.chatId);
|
|
405
|
+
this.pairedChatPrivate = res.result?.type === "private";
|
|
406
|
+
} catch {
|
|
407
|
+
this.pairedChatPrivate = false;
|
|
408
|
+
}
|
|
409
|
+
return this.pairedChatPrivate;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async persistTopics(): Promise<void> {
|
|
413
|
+
if (!this.topics) return;
|
|
414
|
+
const save = this.opts.saveTopicState ?? defaultSaveTopicState;
|
|
415
|
+
try {
|
|
416
|
+
await save(this.topics.serialize());
|
|
417
|
+
} catch {
|
|
418
|
+
// best-effort — a failed persist just re-creates topics after a restart.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Download a Telegram-hosted file into a local temp file for relay into a
|
|
423
|
+
* session's `[image #N]` attachment pipeline (`attachImagePaths`, see
|
|
424
|
+
* `src/util/file-attachment.ts`). Returns `undefined` on any failure — the
|
|
425
|
+
* message still delivers as text-only rather than being dropped entirely. */
|
|
426
|
+
private async downloadAttachment(fileId: string, suggestedName: string): Promise<string | undefined> {
|
|
427
|
+
try {
|
|
428
|
+
const got = await this.opts.telegram.getFile(fileId);
|
|
429
|
+
const filePath = got.result?.file_path;
|
|
430
|
+
if (!filePath) return undefined;
|
|
431
|
+
const bytes = await this.opts.telegram.downloadFile(filePath);
|
|
432
|
+
if (!bytes) return undefined;
|
|
433
|
+
const write = this.opts.writeTempFile ?? defaultWriteTempFile;
|
|
434
|
+
return await write(bytes, suggestedName);
|
|
435
|
+
} catch {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async reactToMessage(messageId: number, emoji: string): Promise<void> {
|
|
441
|
+
try {
|
|
442
|
+
await this.opts.telegram.setMessageReaction(this.opts.chatId, messageId, emoji);
|
|
443
|
+
} catch {
|
|
444
|
+
// best-effort — a visible delivery confirmation, not load-bearing.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
197
448
|
async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
|
|
198
449
|
let msg: Record<string, unknown>;
|
|
199
450
|
try {
|
|
@@ -205,17 +456,82 @@ export class TelegramDaemon {
|
|
|
205
456
|
const next = msg.subagents as SubagentRecord[];
|
|
206
457
|
const events = diffSubagentTransitions(conn.lastRecords, next);
|
|
207
458
|
conn.lastRecords = next;
|
|
208
|
-
|
|
209
|
-
|
|
459
|
+
const shortId = shortSessionId(conn.sessionId);
|
|
460
|
+
if (events.length > 0) {
|
|
461
|
+
const topicId = await this.resolveTopicId(conn);
|
|
462
|
+
for (const ev of events) {
|
|
463
|
+
const keyboard =
|
|
464
|
+
ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
|
|
465
|
+
await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard, { topicId, sessionId: conn.sessionId });
|
|
466
|
+
}
|
|
210
467
|
}
|
|
211
468
|
return;
|
|
212
469
|
}
|
|
470
|
+
if (msg.type === "photo" && typeof msg.url === "string") {
|
|
471
|
+
const replyMarkup =
|
|
472
|
+
msg.replyMarkup &&
|
|
473
|
+
typeof msg.replyMarkup === "object" &&
|
|
474
|
+
"inline_keyboard" in (msg.replyMarkup as object)
|
|
475
|
+
? (msg.replyMarkup as InlineKeyboardMarkup)
|
|
476
|
+
: undefined;
|
|
477
|
+
const topicId = await this.resolveTopicId(conn);
|
|
478
|
+
await this.notifyPhoto(
|
|
479
|
+
msg.url,
|
|
480
|
+
typeof msg.caption === "string" ? msg.caption : undefined,
|
|
481
|
+
replyMarkup,
|
|
482
|
+
{ topicId, sessionId: conn.sessionId },
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
213
486
|
if (msg.type === "ack" && typeof msg.reqId === "string") {
|
|
214
487
|
const resolve = this.pendingAcks.get(msg.reqId);
|
|
215
488
|
if (resolve) {
|
|
216
489
|
resolve(Boolean(msg.ok));
|
|
217
490
|
this.pendingAcks.delete(msg.reqId);
|
|
218
491
|
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// ── Tier 2: main-session mirroring frames (only meaningful/acted-on when
|
|
495
|
+
// `perSessionTopics` is enabled — `resolveTopicId` degrades to the
|
|
496
|
+
// flat/global topicId otherwise, so these still deliver, just without
|
|
497
|
+
// per-session grouping). ──────────────────────────────────────────────
|
|
498
|
+
if (msg.type === "identity_header") {
|
|
499
|
+
conn.identity = {
|
|
500
|
+
repo: typeof msg.repo === "string" && msg.repo ? msg.repo : projectName(conn.cwd),
|
|
501
|
+
branch: typeof msg.branch === "string" ? msg.branch : undefined,
|
|
502
|
+
cwd: typeof msg.cwd === "string" ? msg.cwd : conn.cwd,
|
|
503
|
+
};
|
|
504
|
+
const topicId = await this.resolveTopicId(conn);
|
|
505
|
+
if (topicId !== undefined && this.topics) {
|
|
506
|
+
const name = `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`;
|
|
507
|
+
// `getOrCreateTopic` only names a topic on FIRST creation — a later,
|
|
508
|
+
// more-informative identity (the provisional short-id name gets
|
|
509
|
+
// superseded once the real repo/branch is known) needs an explicit
|
|
510
|
+
// rename, applied only when the name actually changed (dedup).
|
|
511
|
+
if (this.topics.applyName(conn.sessionId, name)) {
|
|
512
|
+
try {
|
|
513
|
+
await this.opts.telegram.editForumTopic(this.opts.chatId, topicId, name);
|
|
514
|
+
void this.persistTopics();
|
|
515
|
+
} catch {
|
|
516
|
+
// best-effort — a failed rename just leaves the provisional name.
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (msg.type === "context_update" && typeof msg.phase === "string" && typeof msg.summary === "string") {
|
|
523
|
+
const topicId = await this.resolveTopicId(conn);
|
|
524
|
+
const icon = msg.phase === "turn_start" ? "▶" : "■";
|
|
525
|
+
const label = msg.phase === "turn_start" ? "Turn started" : "Turn finished";
|
|
526
|
+
const modelSuffix = typeof msg.model === "string" && msg.model ? ` (${msg.model})` : "";
|
|
527
|
+
const usageLine = typeof msg.usage === "string" && msg.usage ? `\n${msg.usage}` : "";
|
|
528
|
+
await this.notify(`${icon} ${label}${modelSuffix}\n${msg.summary}${usageLine}`, undefined, { topicId, sessionId: conn.sessionId });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (msg.type === "turn_stream" && typeof msg.text === "string") {
|
|
532
|
+
const topicId = await this.resolveTopicId(conn);
|
|
533
|
+
await this.notify(markdownToTelegramHtml(msg.text), undefined, { topicId, sessionId: conn.sessionId, html: true });
|
|
534
|
+
return;
|
|
219
535
|
}
|
|
220
536
|
}
|
|
221
537
|
|
|
@@ -250,7 +566,7 @@ export class TelegramDaemon {
|
|
|
250
566
|
const trimmed = text.trim();
|
|
251
567
|
if (trimmed === "/subagents") {
|
|
252
568
|
const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
|
|
253
|
-
await this.notify(formatSubagentsList(sessions));
|
|
569
|
+
await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
|
|
254
570
|
return;
|
|
255
571
|
}
|
|
256
572
|
if (trimmed === "/help" || trimmed === "/start") {
|
|
@@ -282,17 +598,118 @@ export class TelegramDaemon {
|
|
|
282
598
|
if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
|
|
283
599
|
}
|
|
284
600
|
|
|
601
|
+
/** Inbound routed to a KNOWN session's OWN forum topic (per-session-topics
|
|
602
|
+
* mode only — `handleUpdate` is the only caller, and only once
|
|
603
|
+
* `this.topics.sessionForTopic(...)` has already confirmed ownership).
|
|
604
|
+
* A config command (`/verbose`, `/lean`, `/redact on|off`) is applied to
|
|
605
|
+
* THAT session only; anything else (including a media-only message with no
|
|
606
|
+
* caption) is forwarded as free-text steering, with any attached photo/
|
|
607
|
+
* image-document downloaded first. A 👀 reaction confirms the daemon
|
|
608
|
+
* actually routed the message (simplified single-stage delivery
|
|
609
|
+
* confirmation — gjc's own two-stage queued→consumed reaction protocol
|
|
610
|
+
* requires a round-trip ack frame this version does not add). */
|
|
611
|
+
private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
|
|
612
|
+
const conn = this.sessions.get(sessionId);
|
|
613
|
+
if (!conn) return; // the owning session has disconnected; the topic record just lingers
|
|
614
|
+
const text = msg.text ?? msg.caption ?? "";
|
|
615
|
+
const hasMedia = !!(msg.photo?.length || msg.document);
|
|
616
|
+
const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
|
|
617
|
+
if (cfg) {
|
|
618
|
+
conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const imagePaths: string[] = [];
|
|
622
|
+
if (msg.photo?.length) {
|
|
623
|
+
const largest = msg.photo[msg.photo.length - 1]!;
|
|
624
|
+
const p = await this.downloadAttachment(largest.file_id, `photo-${largest.file_id}.jpg`);
|
|
625
|
+
if (p) imagePaths.push(p);
|
|
626
|
+
} else if (msg.document && (msg.document.mime_type ?? "").startsWith("image/")) {
|
|
627
|
+
const p = await this.downloadAttachment(msg.document.file_id, msg.document.file_name ?? `document-${msg.document.file_id}`);
|
|
628
|
+
if (p) imagePaths.push(p);
|
|
629
|
+
}
|
|
630
|
+
if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
|
|
631
|
+
conn.ws.send(JSON.stringify({ type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined }));
|
|
632
|
+
await this.reactToMessage(msg.message_id, "👀");
|
|
633
|
+
}
|
|
634
|
+
|
|
285
635
|
/** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
|
|
286
636
|
* user can message it. Only the paired chat (the one that ran
|
|
287
|
-
* `jeo notify setup`) may steer/cancel subagents
|
|
288
|
-
* without a reply (replying would leak that the
|
|
637
|
+
* `jeo notify setup`) may steer/cancel subagents or steer a session;
|
|
638
|
+
* everything else is dropped without a reply (replying would leak that the
|
|
639
|
+
* bot is live). A message in a KNOWN session's own topic (per-session-
|
|
640
|
+
* topics mode) routes directly to that session — bypassing the flat
|
|
641
|
+
* `topicId` gate, which still governs everything else (global commands in
|
|
642
|
+
* the General topic or the configured flat topic; other topics ignored). */
|
|
289
643
|
async handleUpdate(update: TelegramUpdate): Promise<void> {
|
|
644
|
+
if (update.callback_query) {
|
|
645
|
+
await this.handleCallbackQuery(update.callback_query);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
290
648
|
const msg = update.message;
|
|
291
|
-
if (!msg
|
|
649
|
+
if (!msg) return;
|
|
292
650
|
if (String(msg.chat.id) !== this.opts.chatId) return;
|
|
651
|
+
|
|
652
|
+
const ownerSessionId = this.topics && msg.message_thread_id !== undefined
|
|
653
|
+
? this.topics.sessionForTopic(msg.message_thread_id)
|
|
654
|
+
: undefined;
|
|
655
|
+
if (ownerSessionId) {
|
|
656
|
+
await this.handleSessionTopicInbound(ownerSessionId, msg);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!msg.text) return;
|
|
661
|
+
if (
|
|
662
|
+
this.opts.topicId !== undefined &&
|
|
663
|
+
msg.message_thread_id !== undefined &&
|
|
664
|
+
msg.message_thread_id !== this.opts.topicId
|
|
665
|
+
) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
293
668
|
await this.handleInboundText(msg.text);
|
|
294
669
|
}
|
|
295
670
|
|
|
671
|
+
/** Handle an inline-button tap. Same chat trust boundary as text; the topic
|
|
672
|
+
* gate additionally allows a tap from within any KNOWN session's own topic
|
|
673
|
+
* (its cancel buttons target that session's own subagents — legitimate
|
|
674
|
+
* regardless of the flat `topicId`), not just the configured flat topic.
|
|
675
|
+
* The tap is always acknowledged (Telegram shows a spinner until
|
|
676
|
+
* `answerCallbackQuery`). */
|
|
677
|
+
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
|
|
678
|
+
const chatId = cb.message?.chat.id;
|
|
679
|
+
if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
|
|
680
|
+
await this.answerCallback(cb.id);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const threadId = cb.message?.message_thread_id;
|
|
684
|
+
const inKnownSessionTopic = !!this.topics && threadId !== undefined && this.topics.sessionForTopic(threadId) !== undefined;
|
|
685
|
+
if (
|
|
686
|
+
this.opts.topicId !== undefined &&
|
|
687
|
+
threadId !== undefined &&
|
|
688
|
+
threadId !== this.opts.topicId &&
|
|
689
|
+
!inKnownSessionTopic
|
|
690
|
+
) {
|
|
691
|
+
await this.answerCallback(cb.id);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
|
|
695
|
+
if (!parsed) {
|
|
696
|
+
await this.answerCallback(cb.id);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const conn = this.findSession(parsed.shortId);
|
|
700
|
+
if (!conn) {
|
|
701
|
+
await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
|
|
705
|
+
await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
|
|
706
|
+
await this.notify(
|
|
707
|
+
ok
|
|
708
|
+
? `⏹ Cancelled '${parsed.subagentId}' via button.`
|
|
709
|
+
: `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
296
713
|
private async pollTelegramLoop(): Promise<void> {
|
|
297
714
|
while (!this.stopped) {
|
|
298
715
|
let res;
|
|
@@ -317,14 +734,29 @@ export class TelegramDaemon {
|
|
|
317
734
|
|
|
318
735
|
/** Blocks (long-poll loop) until `stop()` is called. */
|
|
319
736
|
async start(): Promise<void> {
|
|
737
|
+
if (this.topics) {
|
|
738
|
+
const load = this.opts.loadTopicState ?? defaultLoadTopicState;
|
|
739
|
+
try {
|
|
740
|
+
this.topics.load(await load());
|
|
741
|
+
} catch {
|
|
742
|
+
// best-effort — a missing/corrupt topic-state file just starts empty
|
|
743
|
+
// (every session's first frame re-creates its topic from scratch).
|
|
744
|
+
}
|
|
745
|
+
}
|
|
320
746
|
await this.scanSessions();
|
|
321
747
|
this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
|
|
748
|
+
// Catches anything left queued after a submit-time drain because the
|
|
749
|
+
// burst capacity was briefly exhausted (rare at jeo's realistic message
|
|
750
|
+
// volume, but a large simultaneous fan-out can hit it) — without this,
|
|
751
|
+
// a queued-but-never-retried item would silently never send.
|
|
752
|
+
this.poolTimer = setInterval(() => void this.flushPool(), 250);
|
|
322
753
|
await this.pollTelegramLoop();
|
|
323
754
|
}
|
|
324
755
|
|
|
325
756
|
stop(): void {
|
|
326
757
|
this.stopped = true;
|
|
327
|
-
|
|
758
|
+
clearInterval(this.scanTimer);
|
|
759
|
+
clearInterval(this.poolTimer);
|
|
328
760
|
for (const conn of this.sessions.values()) {
|
|
329
761
|
try {
|
|
330
762
|
conn.ws.close();
|
|
@@ -334,6 +766,41 @@ export class TelegramDaemon {
|
|
|
334
766
|
}
|
|
335
767
|
}
|
|
336
768
|
|
|
769
|
+
/** Default inbound-attachment temp-file writer: a private (0700-parent,
|
|
770
|
+
* default-mode) file under the OS temp dir, named to survive filesystem
|
|
771
|
+
* weirdness (sanitized, length-capped) while staying traceable to its
|
|
772
|
+
* Telegram origin for debugging. */
|
|
773
|
+
async function defaultWriteTempFile(bytes: Uint8Array, suggestedName: string): Promise<string> {
|
|
774
|
+
const safe = (suggestedName.replace(/[^\w.-]+/g, "_").slice(-128) || "file");
|
|
775
|
+
const target = path.join(os.tmpdir(), `jeo-telegram-${Date.now()}-${crypto.randomUUID().slice(0, 8)}-${safe}`);
|
|
776
|
+
await fs.writeFile(target, bytes);
|
|
777
|
+
return target;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function defaultLoadTopicState(): Promise<TopicRegistryState> {
|
|
781
|
+
try {
|
|
782
|
+
const raw = await fs.readFile(notifyTopicsPath(), "utf-8");
|
|
783
|
+
return JSON.parse(raw) as TopicRegistryState;
|
|
784
|
+
} catch {
|
|
785
|
+
return { topics: {} };
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Atomic temp+rename write (same convention as `state.ts`'s config/workflow
|
|
790
|
+
* persistence) — a torn write must never corrupt the topic map. */
|
|
791
|
+
async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
|
|
792
|
+
const target = notifyTopicsPath();
|
|
793
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
794
|
+
const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
795
|
+
try {
|
|
796
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
797
|
+
await fs.rename(tmp, target);
|
|
798
|
+
} catch (err) {
|
|
799
|
+
await fs.unlink(tmp).catch(() => {});
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
337
804
|
// ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
|
|
338
805
|
|
|
339
806
|
export async function runNotifyDaemonForeground(): Promise<void> {
|
|
@@ -346,11 +813,13 @@ export async function runNotifyDaemonForeground(): Promise<void> {
|
|
|
346
813
|
const config = await readGlobalConfig();
|
|
347
814
|
const botToken = config.notifications?.telegram?.botToken;
|
|
348
815
|
const chatId = config.notifications?.telegram?.chatId;
|
|
816
|
+
const topicId = config.notifications?.telegram?.topicId;
|
|
817
|
+
const perSessionTopics = config.notifications?.telegram?.perSessionTopics;
|
|
349
818
|
if (!config.notifications?.enabled || !botToken || !chatId) {
|
|
350
819
|
process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
|
|
351
820
|
return;
|
|
352
821
|
}
|
|
353
|
-
const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
|
|
822
|
+
const daemon = new TelegramDaemon({ chatId, topicId, perSessionTopics, telegram: new TelegramApi(botToken) });
|
|
354
823
|
let shuttingDown = false;
|
|
355
824
|
const shutdown = () => {
|
|
356
825
|
if (shuttingDown) return;
|