@llblab/pi-telegram 0.18.4 → 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.
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)
package/lib/preview.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  } from "./target.ts";
13
13
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
14
14
 
15
- const TELEGRAM_PREVIEW_THROTTLE_MS = 0;
16
15
  const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
17
16
  const TELEGRAM_DRAFT_PREVIEW_MAX_CHARS = 4096;
18
17
 
@@ -26,7 +25,6 @@ export interface TelegramPreviewState {
26
25
  }
27
26
 
28
27
  export interface TelegramPreviewRuntimeState extends TelegramPreviewState {
29
- flushTimer?: ReturnType<typeof setTimeout>;
30
28
  flushPromise?: Promise<void>;
31
29
  flushRequested?: boolean;
32
30
  }
@@ -36,7 +34,6 @@ export type TelegramPreviewReplyMarkup = unknown;
36
34
  export interface TelegramPreviewRuntimeDeps {
37
35
  getState: () => TelegramPreviewRuntimeState | undefined;
38
36
  setState: (state: TelegramPreviewRuntimeState | undefined) => void;
39
- clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void;
40
37
  maxMessageLength: number;
41
38
  getDraftSupport: () => TelegramDraftSupport;
42
39
  setDraftSupport: (support: TelegramDraftSupport) => void;
@@ -132,13 +129,7 @@ export interface TelegramPreviewControllerDeps {
132
129
  },
133
130
  ) => Promise<unknown>;
134
131
  canSend?: () => boolean;
135
- throttleMs?: number;
136
132
  maxDraftId?: number;
137
- setTimer?: (
138
- callback: () => void,
139
- ms: number,
140
- ) => ReturnType<typeof setTimeout>;
141
- clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
142
133
  recordRuntimeEvent?: (
143
134
  category: string,
144
135
  error: unknown,
@@ -154,7 +145,7 @@ export interface TelegramPreviewController {
154
145
  resetState: () => void;
155
146
  clear: (
156
147
  chatId: number,
157
- options?: { target?: TelegramTarget },
148
+ options?: { awaitFlush?: boolean; target?: TelegramTarget },
158
149
  ) => Promise<void>;
159
150
  flush: (
160
151
  chatId: number,
@@ -171,7 +162,8 @@ export interface TelegramPreviewController {
171
162
  ) => Promise<boolean>;
172
163
  }
173
164
 
174
- export type TelegramPreviewControllerRuntimeDeps = TelegramPreviewControllerDeps;
165
+ export type TelegramPreviewControllerRuntimeDeps =
166
+ TelegramPreviewControllerDeps;
175
167
 
176
168
  export function createTelegramPreviewControllerRuntime(
177
169
  deps: TelegramPreviewControllerRuntimeDeps,
@@ -181,10 +173,7 @@ export function createTelegramPreviewControllerRuntime(
181
173
  maxMessageLength: deps.maxMessageLength,
182
174
  initialDraftSupport: deps.initialDraftSupport,
183
175
  sendDraft: deps.sendDraft,
184
- throttleMs: deps.throttleMs,
185
176
  maxDraftId: deps.maxDraftId,
186
- setTimer: deps.setTimer,
187
- clearTimer: deps.clearTimer,
188
177
  recordRuntimeEvent: deps.recordRuntimeEvent,
189
178
  });
190
179
  }
@@ -223,7 +212,7 @@ export function createTelegramNativeMarkdownPreviewFinalizer<
223
212
  getState: () => TelegramPreviewRuntimeState | undefined;
224
213
  clear: (
225
214
  chatId: number,
226
- options?: { target?: TelegramTarget },
215
+ options?: { awaitFlush?: boolean; target?: TelegramTarget },
227
216
  ) => Promise<void>;
228
217
  discard?: () => void;
229
218
  sendMarkdownReply: (
@@ -283,12 +272,6 @@ export function createTelegramPreviewController(
283
272
  deps: TelegramPreviewControllerDeps,
284
273
  ): TelegramPreviewController {
285
274
  let state: TelegramPreviewRuntimeState | undefined;
286
- const clearTimer = deps.clearTimer ?? clearTimeout;
287
- const setTimer =
288
- deps.setTimer ??
289
- ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
290
- setTimeout(callback, ms));
291
- const throttleMs = deps.throttleMs ?? TELEGRAM_PREVIEW_THROTTLE_MS;
292
275
  const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX;
293
276
  const maxMessageLength =
294
277
  deps.maxMessageLength ?? TELEGRAM_DRAFT_PREVIEW_MAX_CHARS;
@@ -299,11 +282,6 @@ export function createTelegramPreviewController(
299
282
  setState: (nextState) => {
300
283
  state = nextState;
301
284
  },
302
- clearScheduledFlush: (nextState) => {
303
- if (!nextState.flushTimer) return;
304
- clearTimer(nextState.flushTimer);
305
- nextState.flushTimer = undefined;
306
- },
307
285
  maxMessageLength,
308
286
  getDraftSupport: () => draftSupport,
309
287
  setDraftSupport: (support) => {
@@ -334,15 +312,8 @@ export function createTelegramPreviewController(
334
312
  flush: (chatId, options) =>
335
313
  flushTelegramPreview(chatId, getRuntimeDeps(), options),
336
314
  scheduleFlush: (chatId, options) => {
337
- if (!state || state.flushTimer) return;
338
- if (throttleMs <= 0) {
339
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
340
- return;
341
- }
342
- state.flushTimer = setTimer(() => {
343
- void flushTelegramPreview(chatId, getRuntimeDeps(), options);
344
- }, throttleMs);
345
- state.flushTimer.unref?.();
315
+ if (!state) return;
316
+ void flushTelegramPreview(chatId, getRuntimeDeps(), options);
346
317
  },
347
318
  finalize: (chatId, _replyToMessageId, options) =>
348
319
  finalizeTelegramPreview(chatId, getRuntimeDeps(), options),
@@ -464,7 +435,6 @@ export async function clearTelegramPreview(
464
435
  ): Promise<void> {
465
436
  const state = deps.getState();
466
437
  if (!state) return;
467
- deps.clearScheduledFlush(state);
468
438
  if (state.flushPromise && options.awaitFlush !== false) {
469
439
  state.flushRequested = false;
470
440
  await state.flushPromise.catch(() => {});
@@ -699,6 +669,10 @@ function findSafeTelegramRichMarkdownDraftEnd(markdown: string): number {
699
669
  return safeEnd;
700
670
  }
701
671
 
672
+ function hasTelegramPreviewVisibleContent(markdown: string): boolean {
673
+ return /[\p{L}\p{N}]/u.test(markdown);
674
+ }
675
+
702
676
  export function getSafeTelegramRichMarkdownDraftPrefix(
703
677
  markdown: string,
704
678
  maxMessageLength: number,
@@ -710,13 +684,21 @@ export function getSafeTelegramRichMarkdownDraftPrefix(
710
684
  ? source.slice(0, maxMessageLength)
711
685
  : source;
712
686
  const safeEnd = findSafeTelegramRichMarkdownDraftEnd(limited);
713
- if (safeEnd > 0) return limited.slice(0, safeEnd).trimEnd() || undefined;
687
+ if (safeEnd > 0) {
688
+ const safePrefix = limited.slice(0, safeEnd).trimEnd();
689
+ return hasTelegramPreviewVisibleContent(safePrefix)
690
+ ? safePrefix
691
+ : undefined;
692
+ }
714
693
  let candidateEnd = limited.length;
715
694
  while (candidateEnd > 0) {
716
695
  candidateEnd = limited.lastIndexOf(" ", candidateEnd - 1);
717
696
  if (candidateEnd <= 0) return undefined;
718
697
  const candidate = limited.slice(0, candidateEnd).trimEnd();
719
- if (findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length) {
698
+ if (
699
+ hasTelegramPreviewVisibleContent(candidate) &&
700
+ findSafeTelegramRichMarkdownDraftEnd(candidate) === candidate.length
701
+ ) {
720
702
  return candidate || undefined;
721
703
  }
722
704
  }
@@ -795,7 +777,6 @@ export async function flushTelegramPreview(
795
777
  await state.flushPromise;
796
778
  return;
797
779
  }
798
- state.flushTimer = undefined;
799
780
  state.flushPromise = (async () => {
800
781
  do {
801
782
  state.flushRequested = false;
@@ -4,7 +4,14 @@
4
4
  * Owns session-local append-only runtime evidence for debugging without becoming routing state
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync, statSync, writeFileSync, appendFile } from "node:fs";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ statSync,
12
+ writeFileSync,
13
+ appendFile,
14
+ } from "node:fs";
8
15
  import { homedir } from "node:os";
9
16
  import { dirname, join, resolve } from "node:path";
10
17
 
@@ -17,6 +24,7 @@ export interface TelegramRuntimeJsonlEvent {
17
24
 
18
25
  export interface TelegramRuntimeJsonlLogOptions {
19
26
  path?: string;
27
+ previousPath?: string;
20
28
  maxBytes?: number;
21
29
  getNowMs?: () => number;
22
30
  }
@@ -57,6 +65,8 @@ export function createTelegramRuntimeJsonlLog(
57
65
  options: TelegramRuntimeJsonlLogOptions = {},
58
66
  ): TelegramRuntimeJsonlLog {
59
67
  const path = options.path ?? getTelegramRuntimeLogPath();
68
+ const previousPath =
69
+ options.previousPath ?? path.replace(/\.jsonl$/u, ".previous.jsonl");
60
70
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
61
71
  const getNowMs = options.getNowMs ?? Date.now;
62
72
  let scopeKey: string | undefined;
@@ -66,8 +76,15 @@ export function createTelegramRuntimeJsonlLog(
66
76
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
67
77
  };
68
78
 
79
+ const preserveCurrentLog = () => {
80
+ if (!existsSync(path)) return;
81
+ mkdirSync(dirname(previousPath), { recursive: true, mode: 0o700 });
82
+ copyFileSync(path, previousPath);
83
+ };
84
+
69
85
  const writeReset = (reason: string, scope?: Record<string, unknown>) => {
70
86
  ensureParent();
87
+ preserveCurrentLog();
71
88
  writeFileSync(
72
89
  path,
73
90
  safeJsonLine({
@@ -75,6 +92,7 @@ export function createTelegramRuntimeJsonlLog(
75
92
  kind: "reset",
76
93
  reason,
77
94
  scope,
95
+ previousPath,
78
96
  }) + "\n",
79
97
  { mode: 0o600 },
80
98
  );