@llblab/pi-telegram 0.18.3 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Telegram bus local transport boundary
3
+ * Zones: multi-instance bus, IPC transport, Windows named pipes, Unix sockets
4
+ * Owns endpoint derivation, transport-kind detection, retry/error classification, and small timing policy.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { createConnection } from "node:net";
9
+ import { join, resolve } from "node:path";
10
+
11
+ export type TelegramBusTransportKind = "pipe" | "socket";
12
+
13
+ export type TelegramBusTransportEventRecorder = (
14
+ phase: string,
15
+ details: Record<string, unknown>,
16
+ ) => void;
17
+
18
+ export type TelegramBusTransportEndpointDiagnostics = Record<
19
+ string,
20
+ unknown
21
+ > & {
22
+ endpoint: string;
23
+ transport: TelegramBusTransportKind;
24
+ };
25
+
26
+ export interface TelegramBusTransportRetryPolicy {
27
+ attempts: number;
28
+ delayMs: number;
29
+ }
30
+
31
+ export interface TelegramBusTransportRetryPolicyOverrides {
32
+ attempts?: number;
33
+ delayMs?: number;
34
+ }
35
+
36
+ export type TelegramBusTransportOperation = "registration" | "operation";
37
+
38
+ export const TELEGRAM_BUS_REGISTRATION_RETRY: TelegramBusTransportRetryPolicy = {
39
+ attempts: 10,
40
+ delayMs: 150,
41
+ };
42
+
43
+ export const TELEGRAM_BUS_OPERATION_RETRY: TelegramBusTransportRetryPolicy = {
44
+ attempts: 3,
45
+ delayMs: 100,
46
+ };
47
+
48
+ export const TELEGRAM_BUS_PIPE_REGISTRATION_RETRY =
49
+ TELEGRAM_BUS_REGISTRATION_RETRY;
50
+
51
+ export const TELEGRAM_BUS_PIPE_OPERATION_RETRY = TELEGRAM_BUS_OPERATION_RETRY;
52
+
53
+ export function getTelegramBusPipePath(input: {
54
+ agentDir: string;
55
+ scope: string;
56
+ }): string {
57
+ const digest = createHash("sha256")
58
+ .update(resolve(input.agentDir))
59
+ .digest("base64url")
60
+ .slice(0, 16);
61
+ const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
62
+ return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
63
+ }
64
+
65
+ export function isTelegramBusPipePath(endpoint: string): boolean {
66
+ return /^\\\\[.?]\\pipe\\/i.test(endpoint);
67
+ }
68
+
69
+ export function getTelegramBusTransportKind(
70
+ endpoint: string,
71
+ ): TelegramBusTransportKind {
72
+ return isTelegramBusPipePath(endpoint) ? "pipe" : "socket";
73
+ }
74
+
75
+ export function getTelegramBusEndpointDiagnostics(
76
+ endpoint: string,
77
+ ): TelegramBusTransportEndpointDiagnostics {
78
+ return {
79
+ endpoint,
80
+ transport: getTelegramBusTransportKind(endpoint),
81
+ };
82
+ }
83
+
84
+ export function getTelegramBusTransportRetryPolicy(input: {
85
+ endpoint: string;
86
+ operation: TelegramBusTransportOperation;
87
+ overrides?: TelegramBusTransportRetryPolicyOverrides;
88
+ }): TelegramBusTransportRetryPolicy | undefined {
89
+ const base =
90
+ input.operation === "registration"
91
+ ? TELEGRAM_BUS_REGISTRATION_RETRY
92
+ : isTelegramBusPipePath(input.endpoint)
93
+ ? TELEGRAM_BUS_OPERATION_RETRY
94
+ : undefined;
95
+ if (!base && !input.overrides) return undefined;
96
+ return {
97
+ attempts: Math.max(1, input.overrides?.attempts ?? base?.attempts ?? 1),
98
+ delayMs: Math.max(0, input.overrides?.delayMs ?? base?.delayMs ?? 0),
99
+ };
100
+ }
101
+
102
+ export function getTelegramBusLeaderEndpoint(input: {
103
+ agentDir: string;
104
+ platform: NodeJS.Platform | string;
105
+ }): string {
106
+ return input.platform === "win32"
107
+ ? getTelegramBusPipePath({ agentDir: input.agentDir, scope: "bus" })
108
+ : join(input.agentDir, "tmp", "telegram", "bus.sock");
109
+ }
110
+
111
+ export function getTelegramBusFollowerEndpoint(input: {
112
+ agentDir: string;
113
+ platform: NodeJS.Platform | string;
114
+ instanceId: string;
115
+ }): string {
116
+ return input.platform === "win32"
117
+ ? getTelegramBusPipePath({
118
+ agentDir: input.agentDir,
119
+ scope: `follower-${input.instanceId}`,
120
+ })
121
+ : join(
122
+ input.agentDir,
123
+ "tmp",
124
+ "telegram",
125
+ "followers",
126
+ `${input.instanceId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.sock`,
127
+ );
128
+ }
129
+
130
+ export interface TelegramBusTransportErrorInfo {
131
+ message: string;
132
+ code?: string;
133
+ syscall?: string;
134
+ kind: "connect" | "timeout" | "auth" | "protocol" | "unknown";
135
+ retryable: boolean;
136
+ }
137
+
138
+ export function classifyTelegramBusTransportError(
139
+ error: unknown,
140
+ ): TelegramBusTransportErrorInfo {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ const maybeNodeError = error as NodeJS.ErrnoException;
143
+ const code =
144
+ typeof maybeNodeError?.code === "string" ? maybeNodeError.code : undefined;
145
+ const syscall =
146
+ typeof maybeNodeError?.syscall === "string"
147
+ ? maybeNodeError.syscall
148
+ : undefined;
149
+ const timeout = code === "ETIMEDOUT" || /timed out/i.test(message);
150
+ const retryable =
151
+ code === "ENOENT" ||
152
+ code === "ECONNREFUSED" ||
153
+ code === "EPIPE" ||
154
+ code === "ECONNRESET" ||
155
+ code === "EBUSY" ||
156
+ timeout;
157
+ const kind = timeout
158
+ ? "timeout"
159
+ : code === "ENOENT" || code === "ECONNREFUSED"
160
+ ? "connect"
161
+ : "unknown";
162
+ return { message, code, syscall, kind, retryable };
163
+ }
164
+
165
+ export function isRetryableTelegramBusTransportError(error: unknown): boolean {
166
+ return classifyTelegramBusTransportError(error).retryable;
167
+ }
168
+
169
+ export function createTelegramBusTransportTimeoutError(
170
+ message: string,
171
+ ): NodeJS.ErrnoException {
172
+ const error = new Error(message) as NodeJS.ErrnoException;
173
+ error.code = "ETIMEDOUT";
174
+ return error;
175
+ }
176
+
177
+ export function delayTelegramBusTransportRetry(ms: number): Promise<void> {
178
+ return new Promise((resolve) => {
179
+ const timer = setTimeout(resolve, ms);
180
+ timer.unref?.();
181
+ });
182
+ }
183
+
184
+ export interface TelegramBusTransportProbeResult
185
+ extends TelegramBusTransportEndpointDiagnostics {
186
+ reachable: boolean;
187
+ error?: TelegramBusTransportErrorInfo;
188
+ }
189
+
190
+ export function probeTelegramBusEndpoint(input: {
191
+ endpoint: string;
192
+ timeoutMs?: number;
193
+ }): Promise<TelegramBusTransportProbeResult> {
194
+ const timeoutMs = input.timeoutMs ?? 250;
195
+ const diagnostics = getTelegramBusEndpointDiagnostics(input.endpoint);
196
+ return new Promise((resolve) => {
197
+ const socket = createConnection(input.endpoint);
198
+ let settled = false;
199
+ const settle = (result: TelegramBusTransportProbeResult): void => {
200
+ if (settled) return;
201
+ settled = true;
202
+ clearTimeout(timeout);
203
+ socket.destroy();
204
+ resolve(result);
205
+ };
206
+ const timeout = setTimeout(() => {
207
+ settle({
208
+ ...diagnostics,
209
+ reachable: false,
210
+ error: classifyTelegramBusTransportError(
211
+ createTelegramBusTransportTimeoutError(
212
+ "Timed out probing Telegram bus endpoint",
213
+ ),
214
+ ),
215
+ });
216
+ }, timeoutMs);
217
+ timeout.unref?.();
218
+ socket.once("connect", () => settle({ ...diagnostics, reachable: true }));
219
+ socket.once("error", (error) =>
220
+ settle({
221
+ ...diagnostics,
222
+ reachable: false,
223
+ error: classifyTelegramBusTransportError(error),
224
+ }),
225
+ );
226
+ });
227
+ }
package/lib/bus.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * cross-instance forwarding helpers, and the live follower registry model.
6
6
  */
7
7
 
8
- import { createHash, randomBytes } from "node:crypto";
8
+ import { randomBytes } from "node:crypto";
9
9
  import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
10
10
  import {
11
11
  createConnection,
@@ -16,6 +16,19 @@ import {
16
16
  import { homedir, platform as getPlatform } from "node:os";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
 
19
+ import {
20
+ classifyTelegramBusTransportError,
21
+ createTelegramBusTransportTimeoutError,
22
+ delayTelegramBusTransportRetry,
23
+ getTelegramBusEndpointDiagnostics,
24
+ getTelegramBusFollowerEndpoint,
25
+ getTelegramBusLeaderEndpoint,
26
+ getTelegramBusTransportRetryPolicy,
27
+ isTelegramBusPipePath,
28
+ isRetryableTelegramBusTransportError,
29
+ type TelegramBusTransportEventRecorder,
30
+ type TelegramBusTransportRetryPolicy,
31
+ } from "./bus-transport.ts";
19
32
  import type { TelegramTarget } from "./target.ts";
20
33
 
21
34
  export type TelegramBusRole = "leader" | "follower";
@@ -30,30 +43,11 @@ export function createTelegramBusAuthSecret(): string {
30
43
  return randomBytes(32).toString("base64url");
31
44
  }
32
45
 
33
- function getTelegramBusPipePath(input: {
34
- agentDir: string;
35
- scope: string;
36
- }): string {
37
- const digest = createHash("sha256")
38
- .update(resolve(input.agentDir))
39
- .digest("base64url")
40
- .slice(0, 16);
41
- const scope = input.scope.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
42
- return `\\\\.\\pipe\\pi-telegram-${digest}-${scope}`;
43
- }
44
-
45
- function isWindowsPipePath(socketPath: string): boolean {
46
- return /^\\\\[.?]\\pipe\\/i.test(socketPath);
47
- }
48
-
49
46
  export function getTelegramBusSocketPath(
50
47
  agentDir = getAgentDir(),
51
48
  platform = getPlatform(),
52
49
  ): string {
53
- if (platform === "win32") {
54
- return getTelegramBusPipePath({ agentDir, scope: "bus" });
55
- }
56
- return join(agentDir, "tmp", "telegram", "bus.sock");
50
+ return getTelegramBusLeaderEndpoint({ agentDir, platform });
57
51
  }
58
52
 
59
53
  export function getTelegramBusFollowerSocketPath(
@@ -61,19 +55,7 @@ export function getTelegramBusFollowerSocketPath(
61
55
  agentDir = getAgentDir(),
62
56
  platform = getPlatform(),
63
57
  ): string {
64
- if (platform === "win32") {
65
- return getTelegramBusPipePath({
66
- agentDir,
67
- scope: `follower-${instanceId}`,
68
- });
69
- }
70
- return join(
71
- agentDir,
72
- "tmp",
73
- "telegram",
74
- "followers",
75
- `${instanceId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.sock`,
76
- );
58
+ return getTelegramBusFollowerEndpoint({ agentDir, platform, instanceId });
77
59
  }
78
60
 
79
61
  export interface TelegramBusInstanceRegistration {
@@ -91,6 +73,35 @@ export interface TelegramBusFollowerView extends TelegramBusInstanceRegistration
91
73
  lastHeartbeatMs: number;
92
74
  }
93
75
 
76
+ export function getTelegramFollowerTargetOwnership(input: {
77
+ target: TelegramTarget;
78
+ followers: TelegramBusFollowerView[];
79
+ activeThreadRecords?: Array<{
80
+ status?: string;
81
+ instanceId?: string;
82
+ target: TelegramTarget;
83
+ }>;
84
+ currentInstanceId?: string;
85
+ }): { instanceId: string } | undefined {
86
+ const liveFollower = input.followers.find((follower) => {
87
+ return (
88
+ follower.target?.chatId === input.target.chatId &&
89
+ follower.target.threadId === input.target.threadId
90
+ );
91
+ });
92
+ if (liveFollower) return { instanceId: liveFollower.instanceId };
93
+ const record = input.activeThreadRecords?.find((candidate) => {
94
+ return (
95
+ candidate.status === "active" &&
96
+ candidate.instanceId &&
97
+ candidate.instanceId !== input.currentInstanceId &&
98
+ candidate.target.chatId === input.target.chatId &&
99
+ candidate.target.threadId === input.target.threadId
100
+ );
101
+ });
102
+ return record?.instanceId ? { instanceId: record.instanceId } : undefined;
103
+ }
104
+
94
105
  export function isTelegramFollowerApiCallAllowed(input: {
95
106
  follower: TelegramBusFollowerView;
96
107
  method: string;
@@ -310,12 +321,15 @@ export interface TelegramBusLocalServerDeps {
310
321
  | Promise<TelegramBusEnvelope | undefined>
311
322
  | TelegramBusEnvelope
312
323
  | undefined;
324
+ recordTransportEvent?: TelegramBusTransportEventRecorder;
313
325
  }
314
326
 
315
327
  export interface TelegramBusLocalClientOptions {
316
328
  socketPath: string;
317
329
  envelope: TelegramBusEnvelope;
318
330
  timeoutMs?: number;
331
+ retry?: TelegramBusTransportRetryPolicy;
332
+ recordTransportEvent?: TelegramBusTransportEventRecorder;
319
333
  }
320
334
 
321
335
  export interface TelegramBusForeignOwnedForwarderDeps {
@@ -362,6 +376,10 @@ export function createTelegramBusForeignOwnedUpdateForwarder<
362
376
  socketPath: deps.socketPath,
363
377
  envelope,
364
378
  timeoutMs: deps.timeoutMs,
379
+ retry: getTelegramBusTransportRetryPolicy({
380
+ endpoint: deps.socketPath,
381
+ operation: "operation",
382
+ }),
365
383
  });
366
384
  return response?.kind === "bus.ack" && response.ok;
367
385
  };
@@ -449,6 +467,10 @@ export function createTelegramBusFollowerTargetController(
449
467
  socketPath: follower.busSocketPath,
450
468
  envelope,
451
469
  timeoutMs: deps.timeoutMs,
470
+ retry: getTelegramBusTransportRetryPolicy({
471
+ endpoint: follower.busSocketPath,
472
+ operation: "operation",
473
+ }),
452
474
  });
453
475
  return response?.kind === "bus.ack" && response.ok;
454
476
  },
@@ -517,7 +539,11 @@ export function createTelegramBusLocalServer(
517
539
  return {
518
540
  start: async () => {
519
541
  if (server) return;
520
- const usesWindowsPipe = isWindowsPipePath(deps.socketPath);
542
+ const usesWindowsPipe = isTelegramBusPipePath(deps.socketPath);
543
+ deps.recordTransportEvent?.(
544
+ "server-start",
545
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
546
+ );
521
547
  if (!usesWindowsPipe) {
522
548
  const socketDir = dirname(deps.socketPath);
523
549
  mkdirSync(socketDir, { recursive: true, mode: 0o700 });
@@ -533,16 +559,40 @@ export function createTelegramBusLocalServer(
533
559
  const lines = buffer.split("\n");
534
560
  buffer = lines.pop() ?? "";
535
561
  for (const line of lines) {
536
- void handleTelegramBusSocketLine(line, socket, deps.handleEnvelope);
562
+ void handleTelegramBusSocketLine(
563
+ line,
564
+ socket,
565
+ deps.handleEnvelope,
566
+ deps.recordTransportEvent,
567
+ deps.socketPath,
568
+ );
537
569
  }
538
570
  });
539
571
  socket.on("close", () => sockets.delete(socket));
540
- socket.on("error", () => closeSocket(socket));
541
- });
542
- await new Promise<void>((resolve, reject) => {
543
- server?.once("error", reject);
544
- server?.listen(deps.socketPath, resolve);
572
+ socket.on("error", (error) => {
573
+ deps.recordTransportEvent?.("server-socket-error", {
574
+ ...getTelegramBusEndpointDiagnostics(deps.socketPath),
575
+ ...classifyTelegramBusTransportError(error),
576
+ });
577
+ closeSocket(socket);
578
+ });
545
579
  });
580
+ try {
581
+ await new Promise<void>((resolve, reject) => {
582
+ server?.once("error", reject);
583
+ server?.listen(deps.socketPath, resolve);
584
+ });
585
+ deps.recordTransportEvent?.(
586
+ "server-started",
587
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
588
+ );
589
+ } catch (error) {
590
+ deps.recordTransportEvent?.("server-start-failed", {
591
+ ...getTelegramBusEndpointDiagnostics(deps.socketPath),
592
+ ...classifyTelegramBusTransportError(error),
593
+ });
594
+ throw error;
595
+ }
546
596
  if (!usesWindowsPipe) chmodSync(deps.socketPath, 0o600);
547
597
  },
548
598
  stop: async () => {
@@ -554,14 +604,27 @@ export function createTelegramBusLocalServer(
554
604
  activeServer.close(() => resolve()),
555
605
  );
556
606
  }
557
- if (!isWindowsPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
607
+ if (!isTelegramBusPipePath(deps.socketPath) && existsSync(deps.socketPath)) {
558
608
  unlinkSync(deps.socketPath);
559
609
  }
610
+ deps.recordTransportEvent?.(
611
+ "server-stopped",
612
+ getTelegramBusEndpointDiagnostics(deps.socketPath),
613
+ );
560
614
  },
561
615
  };
562
616
  }
563
617
 
564
- export function sendTelegramBusLocalEnvelope(
618
+ function getTelegramBusEnvelopeDiagnostics(
619
+ envelope: TelegramBusEnvelope,
620
+ ): Record<string, unknown> {
621
+ return {
622
+ envelopeKind: envelope.kind,
623
+ requestId: envelope.requestId,
624
+ };
625
+ }
626
+
627
+ function sendTelegramBusLocalEnvelopeOnce(
565
628
  options: TelegramBusLocalClientOptions,
566
629
  ): Promise<TelegramBusEnvelope | undefined> {
567
630
  const timeoutMs = options.timeoutMs ?? 1000;
@@ -578,7 +641,11 @@ export function sendTelegramBusLocalEnvelope(
578
641
  };
579
642
  const timeout = setTimeout(() => {
580
643
  settle(() =>
581
- reject(new Error("Timed out waiting for Telegram bus response")),
644
+ reject(
645
+ createTelegramBusTransportTimeoutError(
646
+ "Timed out waiting for Telegram bus response",
647
+ ),
648
+ ),
582
649
  );
583
650
  }, timeoutMs);
584
651
  timeout.unref?.();
@@ -598,6 +665,39 @@ export function sendTelegramBusLocalEnvelope(
598
665
  });
599
666
  }
600
667
 
668
+ export async function sendTelegramBusLocalEnvelope(
669
+ options: TelegramBusLocalClientOptions,
670
+ ): Promise<TelegramBusEnvelope | undefined> {
671
+ const attempts = Math.max(1, options.retry?.attempts ?? 1);
672
+ const delayMs = Math.max(0, options.retry?.delayMs ?? 0);
673
+ for (let attempt = 1; ; attempt += 1) {
674
+ try {
675
+ return await sendTelegramBusLocalEnvelopeOnce(options);
676
+ } catch (error) {
677
+ const info = classifyTelegramBusTransportError(error);
678
+ options.recordTransportEvent?.("client-failed", {
679
+ ...getTelegramBusEndpointDiagnostics(options.socketPath),
680
+ ...getTelegramBusEnvelopeDiagnostics(options.envelope),
681
+ attempt,
682
+ attempts,
683
+ ...info,
684
+ });
685
+ if (attempt >= attempts || !isRetryableTelegramBusTransportError(error)) {
686
+ throw error;
687
+ }
688
+ options.recordTransportEvent?.("client-retry", {
689
+ ...getTelegramBusEndpointDiagnostics(options.socketPath),
690
+ ...getTelegramBusEnvelopeDiagnostics(options.envelope),
691
+ attempt,
692
+ attempts,
693
+ delayMs,
694
+ ...info,
695
+ });
696
+ await delayTelegramBusTransportRetry(delayMs);
697
+ }
698
+ }
699
+ }
700
+
601
701
  export interface TelegramBusFollowerRegistry {
602
702
  register: (
603
703
  registration: TelegramBusInstanceRegistration,
@@ -610,6 +710,7 @@ export interface TelegramBusFollowerRegistry {
610
710
  getByTarget: (target: TelegramTarget) => TelegramBusFollowerView | undefined;
611
711
  list: () => TelegramBusFollowerView[];
612
712
  remove: (instanceId: string) => boolean;
713
+ clear: () => void;
613
714
  pruneStale: (
614
715
  nowMs: number,
615
716
  staleAfterMs: number,
@@ -660,6 +761,7 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
660
761
  },
661
762
  list: () => [...followers.values()].map(clone),
662
763
  remove: (instanceId) => followers.delete(instanceId),
764
+ clear: () => followers.clear(),
663
765
  pruneStale: (nowMs, staleAfterMs) => {
664
766
  const removed: TelegramBusFollowerView[] = [];
665
767
  for (const [instanceId, follower] of followers.entries()) {
@@ -676,9 +778,15 @@ async function handleTelegramBusSocketLine(
676
778
  line: string,
677
779
  socket: Socket,
678
780
  handleEnvelope: TelegramBusLocalServerDeps["handleEnvelope"],
781
+ recordTransportEvent: TelegramBusTransportEventRecorder | undefined,
782
+ socketPath: string,
679
783
  ): Promise<void> {
680
784
  const envelope = parseTelegramBusEnvelope(line);
681
785
  if (!envelope) {
786
+ recordTransportEvent?.("server-invalid-envelope", {
787
+ ...getTelegramBusEndpointDiagnostics(socketPath),
788
+ byteLength: Buffer.byteLength(line),
789
+ });
682
790
  socket.write(
683
791
  encodeTelegramBusEnvelope({
684
792
  kind: "bus.ack",
@@ -689,8 +797,24 @@ async function handleTelegramBusSocketLine(
689
797
  );
690
798
  return;
691
799
  }
692
- const response = await handleEnvelope(envelope);
693
- if (response) socket.write(encodeTelegramBusEnvelope(response));
800
+ try {
801
+ const response = await handleEnvelope(envelope);
802
+ if (response) socket.write(encodeTelegramBusEnvelope(response));
803
+ } catch (error) {
804
+ recordTransportEvent?.("server-handler-failed", {
805
+ ...getTelegramBusEndpointDiagnostics(socketPath),
806
+ ...getTelegramBusEnvelopeDiagnostics(envelope),
807
+ ...classifyTelegramBusTransportError(error),
808
+ });
809
+ socket.write(
810
+ encodeTelegramBusEnvelope({
811
+ kind: "bus.ack",
812
+ requestId: envelope.requestId,
813
+ ok: false,
814
+ message: "Telegram bus handler failed.",
815
+ }),
816
+ );
817
+ }
694
818
  }
695
819
 
696
820
  function parseRegisterEnvelope(
package/lib/polling.ts CHANGED
@@ -20,7 +20,7 @@ const TELEGRAM_INITIAL_SYNC_LIMIT = 1;
20
20
  const TELEGRAM_INITIAL_SYNC_TIMEOUT_SECONDS = 0;
21
21
  const TELEGRAM_LONG_POLL_LIMIT = 10;
22
22
  const TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 30;
23
- const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 5_000;
23
+ const TELEGRAM_THREAD_CAPABILITY_MONITOR_INTERVAL_MS = 2_500;
24
24
  const TELEGRAM_THREAD_CAPABILITY_DISABLED_CONFIRMATION_PROBES = 2;
25
25
  const TELEGRAM_POLLING_DEFAULT_MAX_UPDATE_FAILURES = 3;
26
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
@@ -426,6 +426,12 @@ export async function probeTelegramStartupThreadCapability(
426
426
  return threadModeEnabled;
427
427
  }
428
428
 
429
+ function hasTelegramClassicRestoreFailure(
430
+ state: TelegramThreadCapabilityState,
431
+ ): boolean {
432
+ return state.lastReconcileAction?.endsWith("-classic-restore-failed") ?? false;
433
+ }
434
+
429
435
  function hasTelegramThreadCapabilityBindings(
430
436
  store: TelegramThreadCapabilityStore,
431
437
  ): boolean {
@@ -451,6 +457,7 @@ export async function applyTelegramThreadCapability<TContext>(
451
457
  await deps.topicTargetStore.load();
452
458
  if (!deps.isBusConfigured()) return;
453
459
  const nowMs = (deps.getNowMs ?? Date.now)();
460
+ const previousBotState = deps.topicTargetStore.getBotState();
454
461
  if (!threadModeEnabled) {
455
462
  if (
456
463
  hasTelegramThreadCapabilityBindings(deps.topicTargetStore) &&
@@ -470,11 +477,26 @@ export async function applyTelegramThreadCapability<TContext>(
470
477
  await deps.topicTargetStore.persist();
471
478
  deps.setTopicModeUnavailable(true);
472
479
  deps.stopFollowerRegistration();
473
- if (deps.getPollingStartedWithTelegramBus()) {
480
+ if (
481
+ deps.getPollingStartedWithTelegramBus() ||
482
+ hasTelegramClassicRestoreFailure(previousBotState)
483
+ ) {
474
484
  deps.stopLeaderHealth();
475
485
  await deps.stopBusPolling();
476
486
  deps.setPollingStartedWithTelegramBus(false);
477
- await deps.startClassicPolling(ctx);
487
+ try {
488
+ await deps.startClassicPolling(ctx);
489
+ } catch (classicError) {
490
+ deps.topicTargetStore.setBotState({
491
+ threadMode: "disabled",
492
+ updatedAtMs: (deps.getNowMs ?? Date.now)(),
493
+ lastReconcileAction: `${phase}-classic-restore-failed`,
494
+ });
495
+ await deps.topicTargetStore.persist();
496
+ deps.recordEvent("bus", classicError, {
497
+ phase: `${phase}-classic-restore`,
498
+ });
499
+ }
478
500
  }
479
501
  deps.updateStatus(ctx);
480
502
  return;
@@ -509,6 +531,12 @@ export async function applyTelegramThreadCapability<TContext>(
509
531
  try {
510
532
  await deps.startClassicPolling(ctx);
511
533
  } catch (classicError) {
534
+ deps.topicTargetStore.setBotState({
535
+ threadMode: "disabled",
536
+ updatedAtMs: (deps.getNowMs ?? Date.now)(),
537
+ lastReconcileAction: `${phase}-classic-restore-failed`,
538
+ });
539
+ await deps.topicTargetStore.persist();
512
540
  deps.recordEvent("bus", classicError, {
513
541
  phase: `${phase}-classic-restore`,
514
542
  });
@@ -583,6 +611,19 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
583
611
  ): Promise<boolean | undefined> => {
584
612
  await deps.topicTargetStore.load();
585
613
  if (deps.topicTargetStore.getBotState().threadMode !== "enabled") {
614
+ if (hasTelegramThreadCapabilityBindings(deps.topicTargetStore)) {
615
+ deps.recordEvent(
616
+ "bus",
617
+ "Telegram Threaded Mode disabled; follower takeover blocked",
618
+ {
619
+ phase: "follower-register-thread-mode-disabled",
620
+ reason: "active-thread-bindings-present",
621
+ },
622
+ );
623
+ throw new Error(
624
+ "Telegram Threaded Mode is disabled; the current leader remains the classic polling owner.",
625
+ );
626
+ }
586
627
  return undefined;
587
628
  }
588
629
  if (!deps.isBusRuntimeEnabled()) return undefined;
@@ -653,9 +694,21 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
653
694
  return;
654
695
  }
655
696
  if (threadModeEnabled) consecutiveDisabledProbes = 0;
656
- const current = deps.topicTargetStore.getBotState().threadMode;
697
+ const botState = deps.topicTargetStore.getBotState();
698
+ const current = botState.threadMode;
657
699
  if (threadModeEnabled && current === "enabled") return;
658
- if (!threadModeEnabled && current === "disabled") return;
700
+ if (!threadModeEnabled && current === "disabled") {
701
+ if (!deps.ownsLock(ctx) || !hasTelegramClassicRestoreFailure(botState)) {
702
+ return;
703
+ }
704
+ await applyTelegramThreadCapability(
705
+ ctx,
706
+ false,
707
+ "capability-monitor-disabled-confirmed",
708
+ deps,
709
+ );
710
+ return;
711
+ }
659
712
  if (
660
713
  !threadModeEnabled &&
661
714
  hasTelegramThreadCapabilityBindings(deps.topicTargetStore)