jeo-code 0.7.48 → 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.
@@ -17,16 +17,30 @@
17
17
  *
18
18
  * gjc-parity surface added on top of the plain-text core: forum topics (all
19
19
  * pushes carry the configured `message_thread_id`, inbound is topic-filtered),
20
- * inline keyboards (cancel buttons + `callback_query` handling), and image
20
+ * inline keyboards (cancel buttons + `callback_query` handling), image
21
21
  * attachments (a session may push a `{type:"photo"}` frame relayed via
22
- * `sendPhoto`).
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.
23
32
  */
24
33
  import * as fs from "node:fs/promises";
34
+ import * as os from "node:os";
25
35
  import * as path from "node:path";
26
36
  import { readGlobalConfig } from "../state";
27
- import { notifySessionsDir } from "./paths";
37
+ import { notifySessionsDir, notifyTopicsPath } from "./paths";
28
38
  import { acquireDaemonLock, isPidAlive } from "./daemon-control";
29
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";
30
44
  import type { SubagentRecord } from "../subagent-registry";
31
45
 
32
46
 
@@ -116,7 +130,10 @@ export const HELP_TEXT =
116
130
  "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
117
131
  "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
118
132
  "/help — show this message\n" +
119
- "(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)";
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.";
120
137
 
121
138
  // ── Inline keyboards / callback data (gjc parity) ───────────────────────────────
122
139
 
@@ -164,6 +181,14 @@ interface SessionConnection {
164
181
  pid: number;
165
182
  ws: WebSocket;
166
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;
167
192
  }
168
193
 
169
194
  export interface TelegramDaemonOptions {
@@ -177,10 +202,30 @@ export interface TelegramDaemonOptions {
177
202
  scanIntervalMs?: number;
178
203
  ackTimeoutMs?: number;
179
204
  /** Forum-topic thread id (message_thread_id). When set, every push is sent
180
- * into this topic and inbound messages/taps from other topics are ignored. */
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. */
181
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>;
182
221
  }
183
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
+
184
229
  export class TelegramDaemon {
185
230
  private readonly WS: typeof WebSocket;
186
231
  readonly sessions = new Map<string, SessionConnection>();
@@ -188,32 +233,43 @@ export class TelegramDaemon {
188
233
  private updateOffset: number | undefined;
189
234
  private stopped = false;
190
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>;
191
241
 
192
242
  constructor(private readonly opts: TelegramDaemonOptions) {
193
243
  this.WS = opts.WebSocketImpl ?? WebSocket;
244
+ this.topics = opts.perSessionTopics ? new TopicRegistry() : undefined;
245
+ this.pool = new RateLimitPool<OutboundPayload>({ now: opts.now });
194
246
  }
195
247
 
196
- private async notify(text: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
197
- try {
198
- await this.opts.telegram.sendMessage(this.opts.chatId, text, {
199
- messageThreadId: this.opts.topicId,
200
- replyMarkup,
201
- });
202
- } catch {
203
- // best-effort — a failed push must never crash the daemon loop.
204
- }
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();
205
259
  }
206
260
 
207
- private async notifyPhoto(photo: string, caption?: string, replyMarkup?: InlineKeyboardMarkup): Promise<void> {
208
- try {
209
- await this.opts.telegram.sendPhoto(this.opts.chatId, photo, {
210
- messageThreadId: this.opts.topicId,
211
- caption,
212
- replyMarkup,
213
- });
214
- } catch {
215
- // best-effort — a failed photo push must never crash the daemon loop.
216
- }
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();
217
273
  }
218
274
 
219
275
  private async answerCallback(id: string, text?: string): Promise<void> {
@@ -224,6 +280,39 @@ export class TelegramDaemon {
224
280
  }
225
281
  }
226
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
+ }
313
+ }
314
+ }
315
+
227
316
  async scanSessions(): Promise<void> {
228
317
  const readdir = this.opts.readdir ?? (d => fs.readdir(d));
229
318
  let files: string[];
@@ -265,6 +354,97 @@ export class TelegramDaemon {
265
354
  });
266
355
  }
267
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
+
268
448
  async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
269
449
  let msg: Record<string, unknown>;
270
450
  try {
@@ -277,10 +457,13 @@ export class TelegramDaemon {
277
457
  const events = diffSubagentTransitions(conn.lastRecords, next);
278
458
  conn.lastRecords = next;
279
459
  const shortId = shortSessionId(conn.sessionId);
280
- for (const ev of events) {
281
- const keyboard =
282
- ev.kind === "started" ? { inline_keyboard: [[cancelButton(shortId, ev.record)]] } : undefined;
283
- await this.notify(formatNotifyEvent(shortId, conn.cwd, ev), keyboard);
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
+ }
284
467
  }
285
468
  return;
286
469
  }
@@ -291,10 +474,12 @@ export class TelegramDaemon {
291
474
  "inline_keyboard" in (msg.replyMarkup as object)
292
475
  ? (msg.replyMarkup as InlineKeyboardMarkup)
293
476
  : undefined;
477
+ const topicId = await this.resolveTopicId(conn);
294
478
  await this.notifyPhoto(
295
479
  msg.url,
296
480
  typeof msg.caption === "string" ? msg.caption : undefined,
297
481
  replyMarkup,
482
+ { topicId, sessionId: conn.sessionId },
298
483
  );
299
484
  return;
300
485
  }
@@ -304,6 +489,49 @@ export class TelegramDaemon {
304
489
  resolve(Boolean(msg.ok));
305
490
  this.pendingAcks.delete(msg.reqId);
306
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;
307
535
  }
308
536
  }
309
537
 
@@ -370,19 +598,66 @@ export class TelegramDaemon {
370
598
  if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
371
599
  }
372
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
+
373
635
  /** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
374
636
  * user can message it. Only the paired chat (the one that ran
375
- * `jeo notify setup`) may steer/cancel subagents; everything else is dropped
376
- * without a reply (replying would leak that the bot is live). When a forum
377
- * topic is configured, messages/taps from other topics are also ignored. */
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). */
378
643
  async handleUpdate(update: TelegramUpdate): Promise<void> {
379
644
  if (update.callback_query) {
380
645
  await this.handleCallbackQuery(update.callback_query);
381
646
  return;
382
647
  }
383
648
  const msg = update.message;
384
- if (!msg?.text) return;
649
+ if (!msg) return;
385
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;
386
661
  if (
387
662
  this.opts.topicId !== undefined &&
388
663
  msg.message_thread_id !== undefined &&
@@ -393,18 +668,25 @@ export class TelegramDaemon {
393
668
  await this.handleInboundText(msg.text);
394
669
  }
395
670
 
396
- /** Handle an inline-button tap. Same chat/topic trust boundary as text, and the
397
- * tap is always acknowledged (Telegram shows a spinner until `answerCallbackQuery`). */
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`). */
398
677
  async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
399
678
  const chatId = cb.message?.chat.id;
400
679
  if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
401
680
  await this.answerCallback(cb.id);
402
681
  return;
403
682
  }
683
+ const threadId = cb.message?.message_thread_id;
684
+ const inKnownSessionTopic = !!this.topics && threadId !== undefined && this.topics.sessionForTopic(threadId) !== undefined;
404
685
  if (
405
686
  this.opts.topicId !== undefined &&
406
- cb.message?.message_thread_id !== undefined &&
407
- cb.message.message_thread_id !== this.opts.topicId
687
+ threadId !== undefined &&
688
+ threadId !== this.opts.topicId &&
689
+ !inKnownSessionTopic
408
690
  ) {
409
691
  await this.answerCallback(cb.id);
410
692
  return;
@@ -452,14 +734,29 @@ export class TelegramDaemon {
452
734
 
453
735
  /** Blocks (long-poll loop) until `stop()` is called. */
454
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
+ }
455
746
  await this.scanSessions();
456
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);
457
753
  await this.pollTelegramLoop();
458
754
  }
459
755
 
460
756
  stop(): void {
461
757
  this.stopped = true;
462
- if (this.scanTimer) clearInterval(this.scanTimer);
758
+ clearInterval(this.scanTimer);
759
+ clearInterval(this.poolTimer);
463
760
  for (const conn of this.sessions.values()) {
464
761
  try {
465
762
  conn.ws.close();
@@ -469,6 +766,41 @@ export class TelegramDaemon {
469
766
  }
470
767
  }
471
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
+
472
804
  // ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
473
805
 
474
806
  export async function runNotifyDaemonForeground(): Promise<void> {
@@ -482,11 +814,12 @@ export async function runNotifyDaemonForeground(): Promise<void> {
482
814
  const botToken = config.notifications?.telegram?.botToken;
483
815
  const chatId = config.notifications?.telegram?.chatId;
484
816
  const topicId = config.notifications?.telegram?.topicId;
817
+ const perSessionTopics = config.notifications?.telegram?.perSessionTopics;
485
818
  if (!config.notifications?.enabled || !botToken || !chatId) {
486
819
  process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
487
820
  return;
488
821
  }
489
- const daemon = new TelegramDaemon({ chatId, topicId, telegram: new TelegramApi(botToken) });
822
+ const daemon = new TelegramDaemon({ chatId, topicId, perSessionTopics, telegram: new TelegramApi(botToken) });
490
823
  let shuttingDown = false;
491
824
  const shutdown = () => {
492
825
  if (shuttingDown) return;