@gajae-code/coding-agent 0.7.1 → 0.7.3

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -0
  4. package/dist/types/cli.d.ts +6 -0
  5. package/dist/types/commands/mcp.d.ts +70 -0
  6. package/dist/types/config/keybindings.d.ts +2 -2
  7. package/dist/types/config/settings-schema.d.ts +39 -2
  8. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -0
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
  11. package/dist/types/lsp/types.d.ts +2 -0
  12. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  13. package/dist/types/modes/components/model-selector.d.ts +2 -0
  14. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  15. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  16. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  17. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  18. package/dist/types/notifications/config.d.ts +9 -1
  19. package/dist/types/notifications/engine.d.ts +59 -0
  20. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  21. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  22. package/dist/types/notifications/telegram-daemon.d.ts +73 -16
  23. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  24. package/dist/types/notifications/threaded-render.d.ts +6 -1
  25. package/dist/types/notifications/topic-registry.d.ts +2 -0
  26. package/dist/types/session/agent-session.d.ts +2 -0
  27. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  28. package/dist/types/tools/fetch.d.ts +23 -0
  29. package/dist/types/tools/index.d.ts +1 -0
  30. package/dist/types/tools/telegram-send.d.ts +32 -0
  31. package/dist/types/web/insane/bridge.d.ts +103 -0
  32. package/dist/types/web/insane/url-guard.d.ts +25 -0
  33. package/dist/types/web/scrapers/types.d.ts +5 -0
  34. package/dist/types/web/scrapers/utils.d.ts +7 -1
  35. package/dist/types/web/search/provider.d.ts +18 -1
  36. package/dist/types/web/search/providers/insane.d.ts +53 -0
  37. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  38. package/dist/types/web/search/types.d.ts +12 -4
  39. package/package.json +10 -8
  40. package/scripts/verify-insane-vendor.ts +132 -0
  41. package/src/cli/args.ts +1 -1
  42. package/src/cli/fast-help.ts +1 -1
  43. package/src/cli/mcp-cli.ts +272 -0
  44. package/src/cli/notify-cli.ts +152 -5
  45. package/src/cli.ts +6 -2
  46. package/src/commands/mcp.ts +117 -0
  47. package/src/commands/team.ts +1 -1
  48. package/src/config/keybindings.ts +2 -2
  49. package/src/config/settings-schema.ts +30 -1
  50. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  51. package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
  52. package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
  53. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  54. package/src/extensibility/extensions/runner.ts +1 -0
  55. package/src/extensibility/shared-events.ts +1 -0
  56. package/src/gjc-runtime/launch-tmux.ts +17 -3
  57. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  58. package/src/gjc-runtime/ralplan-runtime.ts +2 -2
  59. package/src/gjc-runtime/tmux-common.ts +3 -1
  60. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  61. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  62. package/src/gjc-runtime/workflow-manifest.ts +7 -2
  63. package/src/hooks/skill-state.ts +57 -0
  64. package/src/internal-urls/docs-index.generated.ts +14 -11
  65. package/src/lsp/config.ts +16 -3
  66. package/src/lsp/defaults.json +7 -0
  67. package/src/lsp/types.ts +2 -0
  68. package/src/modes/bridge/bridge-mode.ts +11 -0
  69. package/src/modes/components/custom-editor.ts +2 -0
  70. package/src/modes/components/footer.ts +2 -3
  71. package/src/modes/components/model-selector.ts +12 -0
  72. package/src/modes/components/status-line/git-utils.ts +25 -0
  73. package/src/modes/components/status-line.ts +10 -11
  74. package/src/modes/components/welcome.ts +2 -3
  75. package/src/modes/controllers/event-controller.ts +15 -0
  76. package/src/modes/controllers/selector-controller.ts +3 -0
  77. package/src/modes/interactive-mode.ts +48 -3
  78. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  79. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  80. package/src/modes/theme/defaults/index.ts +2 -0
  81. package/src/modes/utils/context-usage.ts +2 -2
  82. package/src/notifications/attachment-registry.ts +23 -0
  83. package/src/notifications/chat-adapters.ts +147 -0
  84. package/src/notifications/config.ts +23 -2
  85. package/src/notifications/engine.ts +100 -0
  86. package/src/notifications/index.ts +180 -38
  87. package/src/notifications/managed-daemon.ts +163 -0
  88. package/src/notifications/operator-runtime.ts +171 -0
  89. package/src/notifications/telegram-daemon.ts +553 -236
  90. package/src/notifications/threaded-inbound.ts +60 -4
  91. package/src/notifications/threaded-render.ts +20 -2
  92. package/src/notifications/topic-registry.ts +5 -0
  93. package/src/session/agent-session.ts +82 -51
  94. package/src/slash-commands/helpers/parse.ts +2 -1
  95. package/src/tools/bash.ts +9 -0
  96. package/src/tools/composer-bash-policy.ts +96 -0
  97. package/src/tools/fetch.ts +94 -1
  98. package/src/tools/index.ts +3 -0
  99. package/src/tools/telegram-send.ts +137 -0
  100. package/src/web/insane/bridge.ts +350 -0
  101. package/src/web/insane/url-guard.ts +159 -0
  102. package/src/web/scrapers/types.ts +143 -45
  103. package/src/web/scrapers/utils.ts +70 -19
  104. package/src/web/search/provider.ts +77 -18
  105. package/src/web/search/providers/anthropic.ts +70 -3
  106. package/src/web/search/providers/codex.ts +1 -119
  107. package/src/web/search/providers/gemini.ts +99 -0
  108. package/src/web/search/providers/insane.ts +551 -0
  109. package/src/web/search/providers/openai-compatible.ts +66 -32
  110. package/src/web/search/providers/text-citations.ts +111 -0
  111. package/src/web/search/types.ts +13 -2
  112. package/vendor/insane-search/LICENSE +21 -0
  113. package/vendor/insane-search/MANIFEST.json +24 -0
  114. package/vendor/insane-search/engine/__init__.py +23 -0
  115. package/vendor/insane-search/engine/__main__.py +128 -0
  116. package/vendor/insane-search/engine/bias_check.py +183 -0
  117. package/vendor/insane-search/engine/executor.py +254 -0
  118. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  119. package/vendor/insane-search/engine/learning.py +175 -0
  120. package/vendor/insane-search/engine/phase0.py +214 -0
  121. package/vendor/insane-search/engine/safety.py +91 -0
  122. package/vendor/insane-search/engine/templates/package.json +11 -0
  123. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  124. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  125. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  126. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  127. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  128. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  129. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  130. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  131. package/vendor/insane-search/engine/transport.py +211 -0
  132. package/vendor/insane-search/engine/url_transforms.py +98 -0
  133. package/vendor/insane-search/engine/validators.py +331 -0
  134. package/vendor/insane-search/engine/waf_detector.py +214 -0
  135. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -1,5 +1,7 @@
1
1
  import { spawn as childProcessSpawn } from "node:child_process";
2
+ import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
3
5
  import * as path from "node:path";
4
6
  import { logger } from "@gajae-code/utils";
5
7
  import { withFileLock } from "../config/file-lock";
@@ -9,6 +11,7 @@ import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
9
11
  import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
10
12
  import { parseInThreadConfigCommand } from "./config-commands";
11
13
  import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
14
+ import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
12
15
  import { RateLimitPool } from "./rate-limit-pool";
13
16
  import {
14
17
  type AliasTable,
@@ -19,7 +22,7 @@ import {
19
22
  readEndpoint,
20
23
  routeInboundUpdate,
21
24
  } from "./telegram-reference";
22
- import { decideThreadedInbound } from "./threaded-inbound";
25
+ import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
23
26
  import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
24
27
  import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
25
28
 
@@ -102,6 +105,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
102
105
  // Native reactions used as a two-stage delivery double-check on inbound thread
103
106
  // messages: queued on receipt, consumed once a turn picks the message up.
104
107
  const QUEUED_REACTION = "👀";
108
+ const PENDING_TOPIC_FRAME_LIMIT = 20;
105
109
  const CONSUMED_REACTION = "✅";
106
110
 
107
111
  /**
@@ -525,6 +529,154 @@ export interface BotApi {
525
529
  call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
526
530
  }
527
531
 
532
+ export interface TelegramTransportOptions {
533
+ botToken: string;
534
+ apiBase?: string;
535
+ fetchImpl?: typeof fetch;
536
+ setTimeoutImpl?: typeof setTimeout;
537
+ }
538
+
539
+ /** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */
540
+ export class TelegramBotTransport implements BotApi {
541
+ #opts: TelegramTransportOptions;
542
+
543
+ constructor(opts: TelegramTransportOptions) {
544
+ this.#opts = opts;
545
+ }
546
+
547
+ async call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown> {
548
+ const apiBase = this.#opts.apiBase ?? "https://api.telegram.org";
549
+ const url = `${apiBase}/bot${this.#opts.botToken}/${method}`;
550
+ const fetchImpl = this.#opts.fetchImpl ?? fetch;
551
+ const setTimeoutImpl = this.#opts.setTimeoutImpl ?? setTimeout;
552
+ const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
553
+ // sendPhoto with base64 bytes must be a multipart upload (Telegram does
554
+ // not accept base64 in JSON). Other methods stay JSON.
555
+ const photoBody = body as { photo?: unknown; mime?: unknown } | null;
556
+ if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
557
+ const b = body as {
558
+ chat_id: unknown;
559
+ message_thread_id?: unknown;
560
+ photo: string;
561
+ mime?: string;
562
+ caption?: string;
563
+ parse_mode?: string;
564
+ };
565
+ const form = new FormData();
566
+ form.set("chat_id", String(b.chat_id));
567
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
568
+ if (b.caption) form.set("caption", b.caption);
569
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
570
+ form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
571
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
572
+ return res.json();
573
+ }
574
+ const docBody = body as { document?: unknown } | null;
575
+ if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
576
+ const b = body as {
577
+ chat_id: unknown;
578
+ message_thread_id?: unknown;
579
+ document: string;
580
+ mime?: string;
581
+ fileName?: string;
582
+ caption?: string;
583
+ parse_mode?: string;
584
+ };
585
+ const form = new FormData();
586
+ form.set("chat_id", String(b.chat_id));
587
+ if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
588
+ if (b.caption) form.set("caption", b.caption);
589
+ if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
590
+ form.set(
591
+ "document",
592
+ new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
593
+ b.fileName ?? "file",
594
+ );
595
+ const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
596
+ return res.json();
597
+ }
598
+ const res = await fetchWithRetry(
599
+ fetchImpl,
600
+ url,
601
+ {
602
+ method: "POST",
603
+ headers: { "content-type": "application/json" },
604
+ body: JSON.stringify(body),
605
+ signal: opts?.signal,
606
+ },
607
+ sleep,
608
+ );
609
+ return res.json();
610
+ }
611
+ }
612
+
613
+ export interface TelegramUpdatePollerOptions {
614
+ botApi: BotApi;
615
+ runtime: NotificationOperatorRuntime;
616
+ backoff: OperatorBackoffPolicy;
617
+ processUpdate: (update: unknown) => Promise<void>;
618
+ }
619
+
620
+ /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
621
+ export class TelegramUpdatePoller {
622
+ #offset = 0;
623
+ #opts: TelegramUpdatePollerOptions;
624
+
625
+ constructor(opts: TelegramUpdatePollerOptions) {
626
+ this.#opts = opts;
627
+ }
628
+
629
+ async pollOnce(signal?: AbortSignal): Promise<number> {
630
+ let body: {
631
+ ok?: boolean;
632
+ error_code?: number;
633
+ description?: string;
634
+ result?: Array<{ update_id: number } & Record<string, unknown>>;
635
+ };
636
+ try {
637
+ body = (await this.#opts.botApi.call(
638
+ "getUpdates",
639
+ { offset: this.#offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
640
+ { signal },
641
+ )) as typeof body;
642
+ } catch (err) {
643
+ // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
644
+ if (isAbortError(err)) return 0;
645
+ // A transient Telegram API failure must never crash the daemon.
646
+ logger.error("notifications daemon: getUpdates failed", { error: String(err) });
647
+ await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
648
+ return 0;
649
+ }
650
+ // Telegram allows only one active getUpdates poller per bot. A 409 means
651
+ // another poller is live; back off boundedly instead of hot-looping.
652
+ if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
653
+ const backoffMs = this.#opts.backoff.next();
654
+ logger.error(
655
+ `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
656
+ );
657
+ await this.#opts.runtime.sleep(backoffMs, signal);
658
+ return 0;
659
+ }
660
+ this.#opts.backoff.reset();
661
+ for (const update of body.result ?? []) {
662
+ this.#offset = update.update_id + 1;
663
+ try {
664
+ await this.#opts.processUpdate(update);
665
+ } catch (err) {
666
+ logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
667
+ }
668
+ }
669
+ return body.result?.length ?? 0;
670
+ }
671
+ }
672
+
673
+ /** Mutable dispatch state shared by session frames and inbound Telegram updates. */
674
+ export class TelegramEventDispatchState {
675
+ readonly busy = new Set<string>();
676
+ readonly inboundReactions = new Map<number, { messageId: number }>();
677
+ readonly seenUpdateIds = new Set<number>();
678
+ }
679
+
528
680
  /**
529
681
  * Cooperative control seam for the daemon run loop. Implemented by the
530
682
  * daemon-internal CLI / controller against the owner-scoped control-request
@@ -573,31 +725,44 @@ interface SessionSocket {
573
725
  pingTimer: ReturnType<typeof setInterval> | undefined;
574
726
  }
575
727
 
728
+ interface PendingThreadedFrame {
729
+ send: ThreadedSend;
730
+ msg: Record<string, unknown>;
731
+ }
732
+
576
733
  export class TelegramNotificationDaemon {
577
734
  readonly aliasTable: AliasTable;
578
735
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
579
736
  readonly sessions = new Map<string, SessionSocket>();
737
+ private readonly runtime: NotificationOperatorRuntime;
738
+ private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
739
+ private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
740
+ private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
580
741
  private running = false;
581
- private offset = 0;
582
742
  private readonly fsImpl: TelegramDaemonFs;
583
743
  private readonly botApi: BotApi;
584
744
  private readonly topics = new TopicRegistry();
585
- private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId: string }>;
586
- private readonly seenUpdateIds = new Set<number>();
587
- private flushTimer: ReturnType<typeof setInterval> | undefined;
588
- private scanTimer: ReturnType<typeof setInterval> | undefined;
589
- private scanning = false;
590
- private typingTimer: ReturnType<typeof setInterval> | undefined;
745
+ private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
746
+ private readonly poller: TelegramUpdatePoller;
747
+ private readonly dispatchState = new TelegramEventDispatchState();
748
+ /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
749
+ private readonly topicOwnerByIdentity = new Map<string, string>();
750
+ /** Non-identity frames held until identity creates the correct thread. */
751
+ private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
752
+ /** True once the daemon has nudged the user to enable Threaded Mode. */
753
+ private threadedFallbackNoticeSent = false;
754
+ /** Sessions whose identity header was already sent flat (Threaded Mode off). */
755
+ private readonly flatIdentitySent = new Set<string>();
756
+ /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
757
+ private pairedChatPrivate: boolean | undefined;
591
758
  /** Sessions whose agent loop is currently busy (drives the typing indicator). */
592
- private readonly busy = new Set<string>();
759
+ private get busy(): Set<string> {
760
+ return this.dispatchState.busy;
761
+ }
593
762
  /** Inbound update id → originating Telegram message, for delivery reactions. */
594
- private readonly inboundReactions = new Map<number, { messageId: number }>();
595
- /** AbortController for the in-flight long poll; aborted by requestStop() to wake the loop. */
596
- private activePoll: AbortController | undefined;
597
- /** Set when a cooperative stop has been requested (signal or control request). */
598
- private stopRequested = false;
599
- /** Current bounded backoff after a Telegram getUpdates 409 conflict (0 when healthy). */
600
- private pollConflictBackoffMs = 0;
763
+ private get inboundReactions(): Map<number, { messageId: number }> {
764
+ return this.dispatchState.inboundReactions;
765
+ }
601
766
 
602
767
  /**
603
768
  * Cooperatively stop the daemon: set the stop flag and abort the in-flight
@@ -605,62 +770,84 @@ export class TelegramNotificationDaemon {
605
770
  * ~25s getUpdates timeout. Safe to call from a signal handler.
606
771
  */
607
772
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
608
- this.stopRequested = true;
773
+ this.runtime.requestStop();
609
774
  this.running = false;
610
- this.activePoll?.abort();
611
775
  }
612
776
 
613
777
  constructor(private readonly opts: TelegramDaemonOptions) {
614
778
  this.fsImpl = opts.fs ?? nodeFs;
615
779
  this.aliasTable = createAliasTable();
616
- this.botApi = opts.botApi ?? {
617
- call: async (method, body, callOpts) => {
618
- const apiBase = opts.apiBase ?? "https://api.telegram.org";
619
- const url = `${apiBase}/bot${opts.botToken}/${method}`;
620
- const fetchImpl = opts.fetchImpl ?? fetch;
621
- const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
622
- const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
623
- // sendPhoto with base64 bytes must be a multipart upload (Telegram does
624
- // not accept base64 in JSON). Other methods stay JSON.
625
- const photoBody = body as { photo?: unknown; mime?: unknown } | null;
626
- if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
627
- const b = body as {
628
- chat_id: unknown;
629
- message_thread_id?: unknown;
630
- photo: string;
631
- mime?: string;
632
- caption?: string;
633
- parse_mode?: string;
634
- };
635
- const form = new FormData();
636
- form.set("chat_id", String(b.chat_id));
637
- if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
638
- if (b.caption) form.set("caption", b.caption);
639
- if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
640
- form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
641
- const res = await fetchWithRetry(
642
- fetchImpl,
643
- url,
644
- { method: "POST", body: form, signal: callOpts?.signal },
645
- sleep,
646
- );
647
- return res.json();
648
- }
649
- const res = await fetchWithRetry(
650
- fetchImpl,
651
- url,
652
- {
653
- method: "POST",
654
- headers: { "content-type": "application/json" },
655
- body: JSON.stringify(body),
656
- signal: callOpts?.signal,
657
- },
658
- sleep,
659
- );
660
- return res.json();
661
- },
662
- };
663
- this.pool = new RateLimitPool<{ send: ThreadedSend; topicId: string }>({ now: opts.now });
780
+ this.botApi =
781
+ opts.botApi ??
782
+ new TelegramBotTransport({
783
+ botToken: opts.botToken,
784
+ apiBase: opts.apiBase,
785
+ fetchImpl: opts.fetchImpl,
786
+ setTimeoutImpl: opts.setTimeoutImpl,
787
+ });
788
+ this.runtime = new NotificationOperatorRuntime({
789
+ now: opts.now,
790
+ setTimeoutImpl: opts.setTimeoutImpl,
791
+ clearTimeoutImpl: opts.clearTimeoutImpl,
792
+ setIntervalImpl: opts.setIntervalImpl,
793
+ clearIntervalImpl: opts.clearIntervalImpl,
794
+ });
795
+ this.sessionRouter = this.createSessionRouter();
796
+ this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
797
+ this.poller = new TelegramUpdatePoller({
798
+ botApi: this.botApi,
799
+ runtime: this.runtime,
800
+ backoff: this.pollConflictBackoff,
801
+ processUpdate: update => this.handleTelegramUpdate(update),
802
+ });
803
+ }
804
+
805
+ private createSessionRouter(): OperatorEventRouter<SessionSocket> {
806
+ return new OperatorEventRouter<SessionSocket>()
807
+ .add({
808
+ name: "hello",
809
+ matches: msg => msg.type === "hello",
810
+ handle: (session, msg) => {
811
+ const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
812
+ if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
813
+ session.capable = true;
814
+ this.startLiveness(session);
815
+ }
816
+ },
817
+ })
818
+ .add({
819
+ name: "pong",
820
+ matches: msg => msg.type === "pong",
821
+ handle: (session, msg) => {
822
+ if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
823
+ session.awaitingNonce = undefined;
824
+ session.lastPongAt = this.runtime.now();
825
+ }
826
+ },
827
+ })
828
+ .add({
829
+ name: "activity",
830
+ matches: msg => msg.type === "activity",
831
+ handle: async (session, msg) => {
832
+ if (msg.state === "busy") {
833
+ this.busy.add(session.sessionId);
834
+ await this.sendTyping(session.sessionId);
835
+ } else {
836
+ this.busy.delete(session.sessionId);
837
+ }
838
+ },
839
+ })
840
+ .add({
841
+ name: "inbound_ack",
842
+ matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
843
+ handle: async (_session, msg) => {
844
+ const target = this.inboundReactions.get(msg.updateId as number);
845
+ if (target && msg.state === "consumed") {
846
+ this.inboundReactions.delete(msg.updateId as number);
847
+ await this.setReaction(target.messageId, CONSUMED_REACTION);
848
+ }
849
+ },
850
+ });
664
851
  }
665
852
 
666
853
  async loadAliases(): Promise<void> {
@@ -733,7 +920,7 @@ export class TelegramNotificationDaemon {
733
920
  void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
734
921
  // Surface frame-handling failures (e.g. a rejected ask sendMessage) to
735
922
  // the daemon log instead of an invisible unhandled rejection.
736
- console.error("notifications daemon: handleSessionMessage failed:", err);
923
+ logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
737
924
  });
738
925
  });
739
926
  ws.addEventListener("close", () => {
@@ -751,7 +938,7 @@ export class TelegramNotificationDaemon {
751
938
  private startLiveness(session: SessionSocket): void {
752
939
  if (session.pingTimer) return;
753
940
  const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
754
- const now = () => (this.opts.now ?? Date.now)();
941
+ const now = () => this.runtime.now();
755
942
  session.lastPongAt = now();
756
943
  session.pingTimer = setIntervalImpl(() => {
757
944
  if (this.sessions.get(session.sessionId) !== session) return;
@@ -797,6 +984,7 @@ export class TelegramNotificationDaemon {
797
984
  "context_update",
798
985
  "turn_stream",
799
986
  "image_attachment",
987
+ "file_attachment",
800
988
  "config_update",
801
989
  ]);
802
990
 
@@ -813,10 +1001,65 @@ export class TelegramNotificationDaemon {
813
1001
  return `GJC ${sessionId.slice(-6)}`;
814
1002
  }
815
1003
 
1004
+ private topicIdentityKey(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1005
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1006
+ if (!repo) return undefined;
1007
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : "";
1008
+ return `${repo}\0${branch}`;
1009
+ }
1010
+
1011
+ private topicIdentityBase(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1012
+ const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
1013
+ if (!repo) return undefined;
1014
+ const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : undefined;
1015
+ return branch ? `${repo}/${branch}` : repo;
1016
+ }
1017
+
1018
+ private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
1019
+ const identityKey = this.topicIdentityKey(msg);
1020
+ const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
1021
+ if (remembered && this.topics.get(remembered)) return remembered;
1022
+ const base = this.topicIdentityBase(msg);
1023
+ if (!identityKey || !base) return undefined;
1024
+ for (const sessionId of this.topics.sessionIds()) {
1025
+ const name = this.topics.get(sessionId)?.name;
1026
+ if (name === base || name?.startsWith(`${base} - `)) {
1027
+ this.topicOwnerByIdentity.set(identityKey, sessionId);
1028
+ return sessionId;
1029
+ }
1030
+ }
1031
+ return undefined;
1032
+ }
1033
+
1034
+ private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
1035
+ this.pool.submit({
1036
+ sessionId,
1037
+ lane: send.lane,
1038
+ coalesceKey: send.coalesceKey,
1039
+ payload: { send, topicId },
1040
+ });
1041
+ await this.flushPool();
1042
+ }
1043
+
1044
+ private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
1045
+ const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
1046
+ frames.push({ send, msg });
1047
+ if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
1048
+ this.pendingThreadedFrames.set(sessionId, frames);
1049
+ }
1050
+
1051
+ private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
1052
+ const frames = this.pendingThreadedFrames.get(sessionId);
1053
+ if (!frames || frames.length === 0) return;
1054
+ this.pendingThreadedFrames.delete(sessionId);
1055
+ for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
1056
+ }
1057
+
816
1058
  /**
817
1059
  * Resolve (creating once via `createForumTopic`) the forum topic for a
818
- * session. Threaded mode is required: on capability failure this returns
819
- * `undefined` and the caller drops the send (no flat fallback).
1060
+ * session. On capability failure (e.g. Threaded Mode off) this returns
1061
+ * `undefined`; callers then flat-deliver to a private paired chat (with a
1062
+ * one-time nudge) or drop fail-closed for a non-private chat.
820
1063
  */
821
1064
  private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
822
1065
  const existing = this.topics.get(sessionId);
@@ -857,26 +1100,140 @@ export class TelegramNotificationDaemon {
857
1100
  if (raw && typeof raw === "object") this.topics.load(raw);
858
1101
  }
859
1102
 
1103
+ /** Download a Telegram file by its file_path (from getFile) into memory. */
1104
+ private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
1105
+ const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
1106
+ const fetchImpl = this.opts.fetchImpl ?? fetch;
1107
+ // `filePath` is remote metadata from getFile; reject suspicious segments
1108
+ // (traversal/absolute/backslash) and percent-encode each component before
1109
+ // composing the download URL.
1110
+ if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
1111
+ logger.warn("notifications: rejecting suspicious Telegram file_path");
1112
+ return undefined;
1113
+ }
1114
+ const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
1115
+ const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
1116
+ try {
1117
+ const res = await fetchImpl(url);
1118
+ if (!res.ok) return undefined;
1119
+ return Buffer.from(await res.arrayBuffer());
1120
+ } catch (e) {
1121
+ logger.warn(`notifications: file download failed: ${String(e)}`);
1122
+ return undefined;
1123
+ }
1124
+ }
1125
+
1126
+ /**
1127
+ * Per-session private temp directories (mode 0700) holding inbound non-image
1128
+ * attachments. Keyed by session id and reused across transient reconnects;
1129
+ * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
1130
+ */
1131
+ private readonly attachmentDirs = new Map<string, string>();
1132
+
1133
+ /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
1134
+ private async ensureAttachmentDir(sessionId: string): Promise<string> {
1135
+ const existing = this.attachmentDirs.get(sessionId);
1136
+ if (existing) return existing;
1137
+ // mkdtemp creates a directory with an unguessable suffix and 0700 perms;
1138
+ // chmod defensively in case of an unusual platform/umask.
1139
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-telegram-"));
1140
+ await fs.promises.chmod(dir, 0o700).catch(() => undefined);
1141
+ this.attachmentDirs.set(sessionId, dir);
1142
+ return dir;
1143
+ }
1144
+
1145
+ /** Remove all per-session attachment directories. Called on daemon shutdown. */
1146
+ private async cleanupAllAttachmentDirs(): Promise<void> {
1147
+ const dirs = [...this.attachmentDirs.values()];
1148
+ this.attachmentDirs.clear();
1149
+ await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
1150
+ }
1151
+
1152
+ /**
1153
+ * Resolve an inbound attachment to inline image bytes (forwarded as images) or
1154
+ * a securely-saved file path note (non-images). Non-image bytes are written
1155
+ * into a private per-session temp dir (0700) under an unguessable name via an
1156
+ * exclusive 0600 create (`wx`), so the files are not world-readable and the
1157
+ * write never follows a pre-existing symlink. The directory is removed when the
1158
+ * daemon stops. Returns base64 images to inline plus human-readable file notes
1159
+ * to append to the injected text.
1160
+ */
1161
+ private async resolveInboundAttachment(
1162
+ att: InboundAttachment,
1163
+ sessionId: string,
1164
+ ): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
1165
+ const images: { data: string; mime?: string }[] = [];
1166
+ const fileNotes: string[] = [];
1167
+ const label = att.fileName ?? att.kind;
1168
+ try {
1169
+ const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
1170
+ result?: { file_path?: unknown };
1171
+ };
1172
+ const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
1173
+ if (!filePath) {
1174
+ fileNotes.push(`[attachment unavailable: ${label}]`);
1175
+ return { images, fileNotes };
1176
+ }
1177
+ const bytes = await this.downloadTelegramFile(filePath);
1178
+ if (!bytes) {
1179
+ fileNotes.push(`[attachment download failed: ${label}]`);
1180
+ return { images, fileNotes };
1181
+ }
1182
+ const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
1183
+ if (isImage) {
1184
+ images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
1185
+ } else {
1186
+ const safeBase =
1187
+ (att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
1188
+ .replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
1189
+ .replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
1190
+ .replace(/^[.-]+/, "_") // no leading dot/hyphen
1191
+ .slice(-128) || "file";
1192
+ const dir = await this.ensureAttachmentDir(sessionId);
1193
+ // Unguessable, non-colliding name inside the private 0700 dir; the
1194
+ // exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
1195
+ const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
1196
+ await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
1197
+ fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
1198
+ }
1199
+ } catch (e) {
1200
+ logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
1201
+ fileNotes.push(`[attachment error: ${label}]`);
1202
+ }
1203
+ return { images, fileNotes };
1204
+ }
1205
+
860
1206
  /** Drain the shared rate-limit pool and deliver each granted send to its topic. */
861
1207
  private async flushPool(): Promise<void> {
862
1208
  for (const item of this.pool.drain()) {
863
1209
  const { send, topicId } = item.payload;
864
- const thread = Number(topicId);
1210
+ // Threaded topic when available; otherwise deliver flat to the paired chat.
1211
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
865
1212
  try {
866
1213
  if (send.method === "sendPhoto" && send.photoBase64) {
867
1214
  // Real photo upload (the default botApi multiparts base64 -> file).
868
1215
  await this.botApi.call("sendPhoto", {
869
1216
  chat_id: this.opts.chatId,
870
- message_thread_id: thread,
1217
+ ...threadField,
871
1218
  photo: send.photoBase64,
872
1219
  mime: send.mime,
873
1220
  caption: send.text,
874
1221
  parse_mode: TELEGRAM_PARSE_MODE,
875
1222
  });
1223
+ } else if (send.method === "sendDocument" && send.documentBase64) {
1224
+ await this.botApi.call("sendDocument", {
1225
+ chat_id: this.opts.chatId,
1226
+ ...threadField,
1227
+ document: send.documentBase64,
1228
+ mime: send.mime,
1229
+ fileName: send.fileName,
1230
+ caption: send.text,
1231
+ parse_mode: TELEGRAM_PARSE_MODE,
1232
+ });
876
1233
  } else if (send.text) {
877
1234
  await this.botApi.call("sendMessage", {
878
1235
  chat_id: this.opts.chatId,
879
- message_thread_id: thread,
1236
+ ...threadField,
880
1237
  text: send.text,
881
1238
  parse_mode: TELEGRAM_PARSE_MODE,
882
1239
  });
@@ -887,47 +1244,84 @@ export class TelegramNotificationDaemon {
887
1244
  }
888
1245
  }
889
1246
 
1247
+ /**
1248
+ * Threaded Mode is unavailable (the bot owner has not enabled forum topics in
1249
+ * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
1250
+ * the paired chat instead of dropping it, and nudge the user once. Flat delivery
1251
+ * is gated on the paired chat being a private chat: for a group/supergroup/channel
1252
+ * (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
1253
+ * content never lands in a shared chat. Identity headers are sent at most once per
1254
+ * session in flat mode.
1255
+ */
1256
+ private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
1257
+ if (!(await this.pairedChatIsPrivate())) return;
1258
+ await this.notifyThreadedFallback();
1259
+ if (send.identity && this.flatIdentitySent.has(sessionId)) return;
1260
+ this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
1261
+ await this.flushPool();
1262
+ if (send.identity) this.flatIdentitySent.add(sessionId);
1263
+ }
1264
+
1265
+ /**
1266
+ * Resolve once (cached) whether the paired `chatId` is a private chat. Flat
1267
+ * fallback is only safe in a private DM; any non-private chat or an unresolvable
1268
+ * `getChat` is treated as not-private so delivery fails closed.
1269
+ */
1270
+ private async pairedChatIsPrivate(): Promise<boolean> {
1271
+ if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
1272
+ try {
1273
+ const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
1274
+ result?: { type?: string };
1275
+ };
1276
+ this.pairedChatPrivate = res.result?.type === "private";
1277
+ } catch {
1278
+ this.pairedChatPrivate = false;
1279
+ }
1280
+ return this.pairedChatPrivate;
1281
+ }
1282
+
1283
+ /** Tell the user once (per daemon run) how to enable Threaded Mode. */
1284
+ private async notifyThreadedFallback(): Promise<void> {
1285
+ if (this.threadedFallbackNoticeSent) return;
1286
+ this.threadedFallbackNoticeSent = true;
1287
+ try {
1288
+ await this.botApi.call("sendMessage", {
1289
+ chat_id: this.opts.chatId,
1290
+ text: "turn on threaded mode from botfather miniapp to receive gjc notification!",
1291
+ parse_mode: TELEGRAM_PARSE_MODE,
1292
+ });
1293
+ } catch {
1294
+ // Best-effort nudge; never block delivery.
1295
+ }
1296
+ }
1297
+
890
1298
  private startFlushTimer(): void {
891
- if (this.flushTimer) return;
892
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
893
- this.flushTimer = setIntervalImpl(() => {
1299
+ this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
894
1300
  if (!this.running || this.pool.pending === 0) return;
895
1301
  void this.flushPool();
896
- }, RATE_LIMIT_FLUSH_INTERVAL_MS);
1302
+ });
897
1303
  }
898
1304
 
899
1305
  private stopFlushTimer(): void {
900
- if (!this.flushTimer) return;
901
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
902
- clearIntervalImpl(this.flushTimer);
903
- this.flushTimer = undefined;
1306
+ this.runtime.stopInterval("telegram-flush");
904
1307
  }
905
1308
 
906
1309
  /** Run a root scan, guarding against overlapping scans from the timer + loop. */
907
1310
  private async runScan(): Promise<void> {
908
- if (this.scanning) return;
909
- this.scanning = true;
910
- try {
1311
+ await this.runtime.runExclusive("telegram-scan", async () => {
911
1312
  await this.scanRoots();
912
- } finally {
913
- this.scanning = false;
914
- }
1313
+ });
915
1314
  }
916
1315
 
917
1316
  private startScanTimer(): void {
918
- if (this.scanTimer) return;
919
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
920
- this.scanTimer = setIntervalImpl(() => {
1317
+ this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
921
1318
  if (!this.running) return;
922
1319
  void this.runScan();
923
- }, this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS);
1320
+ });
924
1321
  }
925
1322
 
926
1323
  private stopScanTimer(): void {
927
- if (!this.scanTimer) return;
928
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
929
- clearIntervalImpl(this.scanTimer);
930
- this.scanTimer = undefined;
1324
+ this.runtime.stopInterval("telegram-scan");
931
1325
  }
932
1326
 
933
1327
  /** Send a single `typing` chat action into a busy session's topic (best-effort). */
@@ -959,64 +1353,43 @@ export class TelegramNotificationDaemon {
959
1353
  }
960
1354
 
961
1355
  private startTypingTimer(): void {
962
- if (this.typingTimer) return;
963
- const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
964
- this.typingTimer = setIntervalImpl(() => {
1356
+ this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
965
1357
  if (!this.running || this.busy.size === 0) return;
966
1358
  for (const sessionId of this.busy) void this.sendTyping(sessionId);
967
- }, TYPING_REFRESH_INTERVAL_MS);
1359
+ });
968
1360
  }
969
1361
 
970
1362
  private stopTypingTimer(): void {
971
- if (!this.typingTimer) return;
972
- const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
973
- clearIntervalImpl(this.typingTimer);
974
- this.typingTimer = undefined;
1363
+ this.runtime.stopInterval("telegram-typing");
975
1364
  }
976
1365
 
977
1366
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
978
- if (msg?.type === "hello") {
979
- const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
980
- if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
981
- session.capable = true;
982
- this.startLiveness(session);
983
- }
984
- return;
985
- }
986
- if (msg?.type === "pong") {
987
- if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
988
- session.awaitingNonce = undefined;
989
- session.lastPongAt = (this.opts.now ?? Date.now)();
990
- }
991
- return;
992
- }
993
- // Live typing indicator: track busy/idle per session and push an immediate
994
- // chat action so "typing…" appears without waiting for the refresh tick.
995
- if (msg?.type === "activity") {
996
- if (msg.state === "busy") {
997
- this.busy.add(session.sessionId);
998
- await this.sendTyping(session.sessionId);
999
- } else {
1000
- this.busy.delete(session.sessionId);
1001
- }
1002
- return;
1003
- }
1004
- // Inbound delivery double-check: flip the queued reaction to the consumed
1005
- // reaction once the session reports a turn picked the message up.
1006
- if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
1007
- const target = this.inboundReactions.get(msg.updateId);
1008
- if (target && msg.state === "consumed") {
1009
- this.inboundReactions.delete(msg.updateId);
1010
- await this.setReaction(target.messageId, CONSUMED_REACTION);
1011
- }
1012
- return;
1013
- }
1367
+ if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
1014
1368
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
1015
1369
  const send = renderThreadedFrame(msg);
1016
1370
  if (!send) return;
1017
- const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1018
- if (!topicId) return;
1371
+ const existingTopic = this.topics.get(session.sessionId)?.topicId;
1372
+ if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
1373
+ this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
1374
+ return;
1375
+ }
1019
1376
  if (send.identity) {
1377
+ const ownerId = this.topicOwnerForIdentity(msg);
1378
+ const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
1379
+ if (ownerId && ownerId !== session.sessionId && ownerTopic) {
1380
+ await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
1381
+ return;
1382
+ }
1383
+ }
1384
+ const topicId =
1385
+ existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
1386
+ if (!topicId) {
1387
+ await this.deliverFlatFallback(session.sessionId, send);
1388
+ return;
1389
+ }
1390
+ if (send.identity) {
1391
+ const identityKey = this.topicIdentityKey(msg);
1392
+ if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
1020
1393
  // Rename the topic if the title changed (e.g. the session title was
1021
1394
  // auto-generated after the topic was first created). This runs on
1022
1395
  // every identity frame, but does NOT re-send the bulleted message.
@@ -1034,31 +1407,25 @@ export class TelegramNotificationDaemon {
1034
1407
  }
1035
1408
  // Send the full bulleted identity header EXACTLY ONCE per topic.
1036
1409
  if (this.topics.needsIdentity(session.sessionId)) {
1037
- this.pool.submit({
1038
- sessionId: session.sessionId,
1039
- lane: send.lane,
1040
- coalesceKey: send.coalesceKey,
1041
- payload: { send, topicId },
1042
- });
1043
- await this.flushPool();
1410
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1044
1411
  this.topics.markIdentitySent(session.sessionId);
1045
1412
  }
1413
+ await this.flushPendingThreadedFrames(session.sessionId, topicId);
1046
1414
  await this.persistTopics();
1047
1415
  return;
1048
1416
  }
1049
- this.pool.submit({
1050
- sessionId: session.sessionId,
1051
- lane: send.lane,
1052
- coalesceKey: send.coalesceKey,
1053
- payload: { send, topicId },
1054
- });
1055
- await this.flushPool();
1417
+ await this.submitThreadedFrame(session.sessionId, send, topicId);
1056
1418
  return;
1057
1419
  }
1058
1420
  if (msg.type === "action_needed" && msg.id) {
1059
1421
  if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
1060
1422
  const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
1061
- if (!topicId) return;
1423
+ if (!topicId) {
1424
+ // Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
1425
+ if (!(await this.pairedChatIsPrivate())) return;
1426
+ await this.notifyThreadedFallback();
1427
+ }
1428
+ const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
1062
1429
  const rendered = buildActionMessage({
1063
1430
  kind: msg.kind ?? "ask",
1064
1431
  id: msg.id,
@@ -1074,7 +1441,7 @@ export class TelegramNotificationDaemon {
1074
1441
  );
1075
1442
  const result = (await this.botApi.call("sendMessage", {
1076
1443
  chat_id: this.opts.chatId,
1077
- message_thread_id: Number(topicId),
1444
+ ...threadField,
1078
1445
  text: rendered.text,
1079
1446
  parse_mode: TELEGRAM_PARSE_MODE,
1080
1447
  ...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
@@ -1131,19 +1498,26 @@ export class TelegramNotificationDaemon {
1131
1498
  const inbound = decideThreadedInbound(update as never, {
1132
1499
  pairedChatId: this.opts.chatId,
1133
1500
  topicToSession: t => this.topics.sessionForTopic(t),
1134
- isDuplicate: id => this.seenUpdateIds.has(id),
1501
+ isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
1135
1502
  });
1136
1503
  if (inbound.kind === "duplicate") return;
1137
1504
  if (inbound.kind === "inject") {
1138
- this.seenUpdateIds.add(inbound.updateId);
1505
+ this.dispatchState.seenUpdateIds.add(inbound.updateId);
1139
1506
  const session = this.sessions.get(inbound.sessionId);
1140
1507
  if (session?.ws.readyState === WebSocket.OPEN) {
1141
- const cfg = parseInThreadConfigCommand(inbound.text);
1508
+ const attachmentResult = inbound.attachment
1509
+ ? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
1510
+ : undefined;
1511
+ const images = attachmentResult?.images ?? [];
1512
+ const fileNotes = attachmentResult?.fileNotes ?? [];
1513
+ const hasMedia = images.length > 0 || fileNotes.length > 0;
1514
+ const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
1515
+ const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
1142
1516
  // A plain (non-config) message while an ask is pending for this session
1143
1517
  // answers that ask as free-input — instead of starting a new user turn.
1144
1518
  // Telegram asks always accept custom text (the SDK maps a string answer
1145
1519
  // to the ask's custom-input slot), so route the latest pending ask here.
1146
- const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
1520
+ const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
1147
1521
  if (pendingAsk) {
1148
1522
  session.ws.send(
1149
1523
  JSON.stringify({
@@ -1163,10 +1537,11 @@ export class TelegramNotificationDaemon {
1163
1537
  : {
1164
1538
  type: "user_message",
1165
1539
  sessionId: inbound.sessionId,
1166
- text: inbound.text,
1540
+ text: injectedText,
1167
1541
  token: session.token,
1168
1542
  updateId: inbound.updateId,
1169
1543
  threadId: inbound.threadId,
1544
+ images,
1170
1545
  },
1171
1546
  ),
1172
1547
  );
@@ -1205,67 +1580,7 @@ export class TelegramNotificationDaemon {
1205
1580
  }
1206
1581
 
1207
1582
  async pollOnce(signal?: AbortSignal): Promise<number> {
1208
- let body: {
1209
- ok?: boolean;
1210
- error_code?: number;
1211
- description?: string;
1212
- result?: Array<{ update_id: number } & Record<string, unknown>>;
1213
- };
1214
- try {
1215
- body = (await this.botApi.call(
1216
- "getUpdates",
1217
- { offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
1218
- { signal },
1219
- )) as typeof body;
1220
- } catch (err) {
1221
- // A cooperative stop aborts the in-flight long poll; treat as a clean wake.
1222
- if (isAbortError(err)) return 0;
1223
- // A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
1224
- // never crash the daemon — that silently stops all delivery, including ask
1225
- // notifications. Log, back off, and let the run loop retry.
1226
- console.error("notifications daemon: getUpdates failed:", err);
1227
- await this.sleep(POLL_BACKOFF_MS, signal);
1228
- return 0;
1229
- }
1230
- // Telegram allows only one active getUpdates poller per bot. A 409 means
1231
- // another poller is live; back off boundedly instead of hot-looping.
1232
- if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
1233
- this.pollConflictBackoffMs = Math.min(
1234
- this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
1235
- 5_000,
1236
- );
1237
- console.error(
1238
- `notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
1239
- );
1240
- await this.sleep(this.pollConflictBackoffMs, signal);
1241
- return 0;
1242
- }
1243
- this.pollConflictBackoffMs = 0;
1244
- for (const update of body.result ?? []) {
1245
- this.offset = update.update_id + 1;
1246
- try {
1247
- await this.handleTelegramUpdate(update);
1248
- } catch (err) {
1249
- console.error("notifications daemon: handleTelegramUpdate failed:", err);
1250
- }
1251
- }
1252
- return body.result?.length ?? 0;
1253
- }
1254
-
1255
- /** Abortable sleep honoring the injected timer; resolves early on abort. */
1256
- private sleep(ms: number, signal?: AbortSignal): Promise<void> {
1257
- return new Promise<void>(resolve => {
1258
- if (signal?.aborted) return resolve();
1259
- const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
1260
- signal?.addEventListener(
1261
- "abort",
1262
- () => {
1263
- (this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
1264
- resolve();
1265
- },
1266
- { once: true },
1267
- );
1268
- });
1583
+ return this.poller.pollOnce(signal);
1269
1584
  }
1270
1585
 
1271
1586
  /** Sync the bot's Telegram command menu to what the daemon actually handles. */
@@ -1292,6 +1607,7 @@ export class TelegramNotificationDaemon {
1292
1607
  pid: this.opts.pid ?? process.pid,
1293
1608
  });
1294
1609
  if (!this.running) return;
1610
+ this.runtime.start();
1295
1611
  this.startFlushTimer();
1296
1612
  this.startScanTimer();
1297
1613
  this.startTypingTimer();
@@ -1300,8 +1616,7 @@ export class TelegramNotificationDaemon {
1300
1616
  await this.loadAliases();
1301
1617
  await this.loadTopics();
1302
1618
  await this.runScan();
1303
- let idleSince = (this.opts.now ?? Date.now)();
1304
- let pollBackoffMs = 0;
1619
+ let idleSince = this.runtime.now();
1305
1620
  while (this.running) {
1306
1621
  if (await this.controlStopRequested()) break;
1307
1622
  if (
@@ -1317,32 +1632,34 @@ export class TelegramNotificationDaemon {
1317
1632
  await this.runScan();
1318
1633
  if (await this.controlStopRequested()) break;
1319
1634
  if (this.sessions.size === 0) {
1320
- if ((this.opts.now ?? Date.now)() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
1635
+ if (this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
1321
1636
  } else {
1322
- idleSince = (this.opts.now ?? Date.now)();
1323
- this.activePoll = new AbortController();
1637
+ idleSince = this.runtime.now();
1638
+ const activePoll = this.runtime.createAbortController();
1324
1639
  try {
1325
- await this.pollOnce(this.activePoll.signal);
1326
- pollBackoffMs = 0;
1640
+ await this.pollOnce(activePoll.signal);
1641
+ this.loopBackoff.reset();
1327
1642
  } catch (e) {
1328
1643
  // A transient getUpdates/network failure must not kill the
1329
1644
  // daemon. Back off (bounded, below the heartbeat TTL) and keep
1330
1645
  // renewing ownership at the loop top.
1331
- pollBackoffMs = pollBackoffMs === 0 ? 250 : Math.min(pollBackoffMs * 2, 4_000);
1332
- logger.warn(`notifications: getUpdates failed, backing off ${pollBackoffMs}ms: ${String(e)}`);
1333
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, pollBackoffMs));
1646
+ const backoffMs = this.loopBackoff.next();
1647
+ logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
1648
+ await this.runtime.sleep(backoffMs);
1334
1649
  continue;
1335
1650
  } finally {
1336
- this.activePoll = undefined;
1651
+ this.runtime.clearAbortController(activePoll);
1337
1652
  }
1338
1653
  }
1339
1654
  if (await this.controlStopRequested()) break;
1340
- await new Promise(resolve => (this.opts.setTimeoutImpl ?? setTimeout)(resolve, 10));
1655
+ await this.runtime.sleep(10);
1341
1656
  }
1342
1657
  } finally {
1658
+ this.runtime.stop();
1343
1659
  this.stopFlushTimer();
1344
1660
  this.stopScanTimer();
1345
1661
  this.stopTypingTimer();
1662
+ await this.cleanupAllAttachmentDirs();
1346
1663
  // Persist durable state before releasing ownership so a fresh daemon
1347
1664
  // (e.g. after reload) reloads aliases/topics seamlessly.
1348
1665
  await this.persistAliases().catch(() => undefined);
@@ -1359,7 +1676,7 @@ export class TelegramNotificationDaemon {
1359
1676
 
1360
1677
  /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
1361
1678
  private async controlStopRequested(): Promise<boolean> {
1362
- if (this.stopRequested) return true;
1679
+ if (this.runtime.stopRequested) return true;
1363
1680
  if (!this.opts.control) return false;
1364
1681
  try {
1365
1682
  return await this.opts.control.shouldStop(this.opts.ownerId);