@llblab/pi-telegram 0.39.1 → 0.39.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.
package/AGENTS.md CHANGED
@@ -81,7 +81,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
81
81
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
82
82
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
83
83
  - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof discards all session-owned sources without replay; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
84
- - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
84
+ - Execution failures persist bounded diagnostics and attempt state as `retry-wait`, except that an exact Telegram HTTP 400 stale/deleted-thread API failure with a proven `{chatId, threadId}` terminally settles the currently executing source after best-effort shared binding invalidation. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
85
85
  - An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
86
86
  - The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
87
87
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.39.3: Windows IPC And Outbound Voice Hotfix
6
+
7
+ - `Windows IPC Replacement`: Replaces an older same-process Named Pipe server before a new session generation listens on the stable endpoint, preventing `EADDRINUSE` during reload while keeping a late stop from invalidating its successor.
8
+ - `Windows IPC Fencing`: Applies the ownership commit fence before publishing a Named Pipe, so a stale generation that loses authority cannot expose an endpoint or block its replacement.
9
+ - `Windows Voice Commands`: Executes trusted `.cmd` and `.bat` outbound-handler wrappers through escaped `%ComSpec%` adaptation while preserving direct shell-free execution for native executables, bounded process controls, npm command-shim argument isolation, and paths containing spaces.
10
+ - `Windows Template Paths`: Preserves backslashes in quoted and unquoted Windows executable and artifact paths without breaking intentional escaped whitespace or quotes, allowing configured TTS handlers to produce and upload OGG/Opus voice replies through direct and follower transport.
11
+
12
+ ## 0.39.2: Deleted Thread Receipt Hotfix
13
+
14
+ - `Deleted Thread Receipt`: Terminally settles the currently executing durable update when Telegram returns exact HTTP 400 stale/deleted-thread evidence for its `{chatId, threadId}`, including leader-first shared-store invalidation followed by follower settlement. Transient, ambiguous, unrelated, and stale-looking HTTP 5xx failures retain indefinite retry authority; persisted follower records remain restart hints rather than speculative live registrations.
15
+
5
16
  ## 0.39.1: Compaction Failure Formatting Hotfix
6
17
 
7
18
  - `Compaction Failure Formatting`: Preserves the HTML parse mode when a confirmed `/compact` callback reports asynchronous failure, preventing bold standalone failure headings from appearing as literal `<b>…</b>` markup in Telegram.
@@ -209,7 +209,7 @@ The profile-scoped journal separates transport progress from semantic progress.
209
209
 
210
210
  Compaction runs under the journal transaction lock when either 256 unapplied segments or 4 MiB of segment bytes is reached. It publishes the complete private (`0600`) snapshot at revision `R` before best-effort deletion of segments `<= R`; failed cleanup leaves redundant segments that readers ignore. Interrupted cleanup therefore leaves either an older snapshot plus newer authoritative segments or a newer snapshot plus harmless redundant older segments. Revision gaps, conflicting duplicates, malformed segments, and identity mismatches fail closed. The logical reconstructed journal and aggregate unapplied segment bytes are independently bounded at 10,000 entries and 32 MiB as applicable; rejected growth publishes neither snapshot nor segment bytes. Compaction may temporarily require exactly one private complete snapshot of at most 32 MiB. Capacity pauses polling and valid authority files are never automatically deleted, reset, or quarantined. Only a missing-snapshot history that cannot be reconstructed safely uses the explicit evidence-preserving quarantine-and-reset compatibility fallback above.
211
211
 
212
- `pending` entries remain immediately executable while raw interception, routing, or grouping is incomplete. Every execution failure becomes `retry-wait` with durable attempt count, next eligible time, failure class, bounded summary, and latest failure time. The `failed` state remains schema-compatible only for legacy candidate journals and is converted to automatic retry during lifecycle startup. `queued` entries carry exact prompt/control receipts plus the acquiring Pi runtime instance, OS pid/birth identity, session generation, acquisition id, and acquisition time. Queueing alone is never completion.
212
+ `pending` entries remain immediately executable while raw interception, routing, or grouping is incomplete. Execution failures become `retry-wait` with durable attempt count, next eligible time, failure class, bounded summary, and latest failure time, except that an exact HTTP 400 stale/deleted Telegram thread error carrying its proven request `{chatId, threadId}` terminally settles the currently executing source after shared binding invalidation; follower settlement remains idempotent when the leader already persisted that stale binding. The `failed` state remains schema-compatible only for legacy candidate journals and is converted to automatic retry during lifecycle startup. `queued` entries carry exact prompt/control receipts plus the acquiring Pi runtime instance, OS pid/birth identity, session generation, acquisition id, and acquisition time. Queueing alone is never completion.
213
213
 
214
214
  Queue receipt ownership is independent from the Telegram transport lock. A same-instance, same-process generation may reconstruct its local receipt across a fenced session replacement and may settle it after transport ownership moves. A different process reports the receipt as foreign, never republishes it into its local queue, and cannot complete it even if it reads the acquisition id. Startup no longer treats process replacement as proof that an owner died: foreign and legacy unowned receipts remain durable.
215
215
 
package/index.ts CHANGED
@@ -844,6 +844,16 @@ export default function (pi: Pi.ExtensionAPI) {
844
844
  });
845
845
  },
846
846
  );
847
+ const staleTopicApiErrorRecoveryDeps = {
848
+ topicTargetStore: threadStore,
849
+ getSyncState: telegramSyncStateRuntime.getState,
850
+ setSyncState: telegramSyncStateRuntime.setState,
851
+ recordEvent: recordRuntimeEvent,
852
+ };
853
+ const recoverStaleTelegramTopicApiError =
854
+ Sync.createTelegramStaleTopicApiErrorRecoveryRuntime(
855
+ staleTopicApiErrorRecoveryDeps,
856
+ );
847
857
  const {
848
858
  owner: updateWorkerOwnerRuntime,
849
859
  leader: updateAdmissionLifecycleRuntime,
@@ -867,6 +877,12 @@ export default function (pi: Pi.ExtensionAPI) {
867
877
  worker: {
868
878
  defaultHandle: inboundRouteRuntime.handleUpdate,
869
879
  onStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
880
+ settleTerminalExecutionFailure(error) {
881
+ return Sync.settleStaleTelegramTopicExecutionFailure(
882
+ error,
883
+ staleTopicApiErrorRecoveryDeps,
884
+ );
885
+ },
870
886
  },
871
887
  leader: {
872
888
  resolveBinding: resolveTelegramUpdateJournalBinding,
@@ -1056,13 +1072,6 @@ export default function (pi: Pi.ExtensionAPI) {
1056
1072
  onPollingStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
1057
1073
  recordRuntimeEvent,
1058
1074
  });
1059
- const recoverStaleTelegramTopicApiError =
1060
- Sync.createTelegramStaleTopicApiErrorRecoveryRuntime({
1061
- topicTargetStore: threadStore,
1062
- getSyncState: telegramSyncStateRuntime.getState,
1063
- setSyncState: telegramSyncStateRuntime.setState,
1064
- recordEvent: recordRuntimeEvent,
1065
- });
1066
1075
  const authorizeFollowerApiCall = Bus.createTelegramFollowerApiCallAuthorizer({
1067
1076
  isMessageOwned: messageOwnershipRuntime.isOwnedByFollower,
1068
1077
  });
@@ -20,6 +20,7 @@ import type { TelegramTarget } from "./target.ts";
20
20
  import {
21
21
  isTelegramApiMethodRetrySafe,
22
22
  TelegramApiCommitUnknownError,
23
+ TelegramApiStaleTargetError,
23
24
  } from "./telegram-api.ts";
24
25
  import {
25
26
  createTelegramBusFollowerDeliveryIdentity,
@@ -898,6 +899,20 @@ export function createTelegramBusFollowerApiCaller(
898
899
  response?.kind === "bus.ack"
899
900
  ? response.message
900
901
  : "Telegram bus API call did not return an acknowledgement.";
902
+ if (
903
+ response?.kind === "bus.ack" &&
904
+ response.error?.code === "stale-target" &&
905
+ response.error.chatId !== undefined &&
906
+ response.error.threadId !== undefined
907
+ ) {
908
+ throw new TelegramApiStaleTargetError(
909
+ message ?? "Telegram thread target is stale.",
910
+ {
911
+ chatId: response.error.chatId,
912
+ threadId: response.error.threadId,
913
+ },
914
+ );
915
+ }
901
916
  if (
902
917
  response?.kind === "bus.ack" &&
903
918
  response.error?.code === "commit-unknown"
package/lib/bus-leader.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as Sync from "./sync.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
10
  import {
11
+ getTelegramApiErrorRequestTarget,
11
12
  isTelegramApiCommitUnknownError,
12
13
  type TelegramApiCallOptions,
13
14
  } from "./telegram-api.ts";
@@ -1756,6 +1757,10 @@ async function handleFollowerApiCall(
1756
1757
  result,
1757
1758
  };
1758
1759
  } catch (error) {
1760
+ const staleTarget =
1761
+ Threads.isTelegramTopicTargetStaleError(error)
1762
+ ? getTelegramApiErrorRequestTarget(error)
1763
+ : undefined;
1759
1764
  return {
1760
1765
  kind: "bus.ack",
1761
1766
  requestId: envelope.requestId,
@@ -1764,14 +1769,22 @@ async function handleFollowerApiCall(
1764
1769
  error instanceof Error
1765
1770
  ? error.message
1766
1771
  : "Telegram bus API call failed.",
1767
- ...(isTelegramApiCommitUnknownError(error)
1772
+ ...(staleTarget
1768
1773
  ? {
1769
1774
  error: {
1770
- code: "commit-unknown" as const,
1771
- method: error.method,
1775
+ code: "stale-target" as const,
1776
+ chatId: staleTarget.chatId,
1777
+ threadId: staleTarget.threadId,
1772
1778
  },
1773
1779
  }
1774
- : {}),
1780
+ : isTelegramApiCommitUnknownError(error)
1781
+ ? {
1782
+ error: {
1783
+ code: "commit-unknown" as const,
1784
+ method: error.method,
1785
+ },
1786
+ }
1787
+ : {}),
1775
1788
  };
1776
1789
  }
1777
1790
  }
package/lib/bus.ts CHANGED
@@ -816,8 +816,11 @@ export type TelegramBusEnvelope = (
816
816
  | "commit-unknown"
817
817
  | "request-id-collision"
818
818
  | "ledger-overloaded"
819
- | "incompatible-protocol";
819
+ | "incompatible-protocol"
820
+ | "stale-target";
820
821
  method?: string;
822
+ chatId?: number;
823
+ threadId?: number;
821
824
  };
822
825
  }
823
826
  ) & { auth?: string };
@@ -947,6 +950,18 @@ export interface TelegramBusLocalServer {
947
950
  ensureEndpoint: () => Promise<boolean>;
948
951
  }
949
952
 
953
+ const TELEGRAM_ACTIVE_LOCAL_SERVERS = Symbol.for(
954
+ "@llblab/pi-telegram/active-local-servers",
955
+ );
956
+ type TelegramBusServerGlobal = typeof globalThis & {
957
+ [TELEGRAM_ACTIVE_LOCAL_SERVERS]?: Map<string, TelegramBusLocalServer>;
958
+ };
959
+
960
+ function getActiveTelegramBusLocalServers(): Map<string, TelegramBusLocalServer> {
961
+ const root = globalThis as TelegramBusServerGlobal;
962
+ return (root[TELEGRAM_ACTIVE_LOCAL_SERVERS] ??= new Map());
963
+ }
964
+
950
965
  export type TelegramBusSocketPathSource = string | (() => string);
951
966
 
952
967
  const TELEGRAM_BUS_MAX_DIRECT_UNIX_ENDPOINT_BYTES = 80;
@@ -1560,6 +1575,11 @@ export function createTelegramBusLocalServer(
1560
1575
  start: async () => {
1561
1576
  if (server) return;
1562
1577
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
1578
+ const activeServers = getActiveTelegramBusLocalServers();
1579
+ const replacedServer = activeServers.get(socketPath);
1580
+ if (replacedServer && replacedServer !== runtime) {
1581
+ await replacedServer.stop();
1582
+ }
1563
1583
  const usesWindowsPipe = isTelegramBusPipePath(socketPath);
1564
1584
  const endpointGeneration = randomBytes(8).toString("hex");
1565
1585
  const listenPath = usesWindowsPipe
@@ -1598,6 +1618,19 @@ export function createTelegramBusLocalServer(
1598
1618
  await delayTelegramBusTransportRetry(25);
1599
1619
  }
1600
1620
  }
1621
+ if (usesWindowsPipe) {
1622
+ await deps.beforeEndpointPublication?.();
1623
+ const committed = deps.commitEndpointPublication
1624
+ ? deps.commitEndpointPublication(() => {})
1625
+ : true;
1626
+ if (!committed) {
1627
+ activeSocketPath = undefined;
1628
+ activeListenPath = undefined;
1629
+ throw new Error(
1630
+ "Telegram bus endpoint publication lost transport ownership.",
1631
+ );
1632
+ }
1633
+ }
1601
1634
  server = createServer((socket) => {
1602
1635
  sockets.add(socket);
1603
1636
  let buffer = "";
@@ -1631,6 +1664,7 @@ export function createTelegramBusLocalServer(
1631
1664
  server?.once("error", reject);
1632
1665
  server?.listen(listenPath, resolve);
1633
1666
  });
1667
+ activeServers.set(socketPath, runtime);
1634
1668
  deps.recordTransportEvent?.(
1635
1669
  "server-started",
1636
1670
  getTelegramBusEndpointDiagnostics(socketPath),
@@ -1671,6 +1705,9 @@ export function createTelegramBusLocalServer(
1671
1705
  server = undefined;
1672
1706
  activeSocketPath = undefined;
1673
1707
  activeListenPath = undefined;
1708
+ if (activeServers.get(socketPath) === runtime) {
1709
+ activeServers.delete(socketPath);
1710
+ }
1674
1711
  if (failedServer) {
1675
1712
  await new Promise<void>((resolve) =>
1676
1713
  failedServer.close(() => resolve()),
@@ -1686,6 +1723,12 @@ export function createTelegramBusLocalServer(
1686
1723
  const activeServer = server;
1687
1724
  const socketPath = activeSocketPath;
1688
1725
  const listenPath = activeListenPath;
1726
+ if (
1727
+ socketPath &&
1728
+ getActiveTelegramBusLocalServers().get(socketPath) === runtime
1729
+ ) {
1730
+ getActiveTelegramBusLocalServers().delete(socketPath);
1731
+ }
1689
1732
  server = undefined;
1690
1733
  activeSocketPath = undefined;
1691
1734
  activeListenPath = undefined;
@@ -2527,13 +2570,24 @@ function parseAckEnvelope(
2527
2570
  code === "commit-unknown" ||
2528
2571
  code === "request-id-collision" ||
2529
2572
  code === "ledger-overloaded" ||
2530
- code === "incompatible-protocol"
2573
+ code === "incompatible-protocol" ||
2574
+ code === "stale-target"
2531
2575
  ) {
2576
+ const chatId = value.error.chatId;
2577
+ const threadId = value.error.threadId;
2578
+ if (
2579
+ code === "stale-target" &&
2580
+ (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))
2581
+ ) {
2582
+ return undefined;
2583
+ }
2532
2584
  envelope.error = {
2533
2585
  code,
2534
2586
  ...(typeof value.error.method === "string"
2535
2587
  ? { method: value.error.method }
2536
2588
  : {}),
2589
+ ...(typeof chatId === "number" ? { chatId } : {}),
2590
+ ...(typeof threadId === "number" ? { threadId } : {}),
2537
2591
  };
2538
2592
  }
2539
2593
  }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { spawn } from "node:child_process";
8
8
  import { homedir } from "node:os";
9
- import { isAbsolute, resolve } from "node:path";
9
+ import { extname, isAbsolute, normalize, resolve } from "node:path";
10
10
 
11
11
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
12
12
 
@@ -541,7 +541,8 @@ export function splitCommandTemplate(input: string): string[] {
541
541
  let quote: "'" | '"' | undefined;
542
542
  let escaped = false;
543
543
  let active = false;
544
- for (const char of input) {
544
+ for (let index = 0; index < input.length; index += 1) {
545
+ const char = input[index] ?? "";
545
546
  if (escaped) {
546
547
  current += char;
547
548
  escaped = false;
@@ -549,7 +550,16 @@ export function splitCommandTemplate(input: string): string[] {
549
550
  continue;
550
551
  }
551
552
  if (char === "\\" && quote !== "'") {
552
- escaped = true;
553
+ const next = input[index + 1];
554
+ const escapesNext = quote === '"'
555
+ ? next === '"' || next === "\\"
556
+ : next !== undefined &&
557
+ (/\s/u.test(next) || next === "'" || next === '"' || next === "\\");
558
+ if (escapesNext) {
559
+ escaped = true;
560
+ } else {
561
+ current += "\\";
562
+ }
553
563
  active = true;
554
564
  continue;
555
565
  }
@@ -844,15 +854,66 @@ export async function execCommandTemplate(
844
854
  return lastResult;
845
855
  }
846
856
 
857
+ const WINDOWS_COMMAND_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
858
+
859
+ function escapeWindowsCommand(value: string): string {
860
+ return value.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
861
+ }
862
+
863
+ function escapeWindowsCommandArgument(
864
+ value: string,
865
+ doubleEscapeMetaChars: boolean,
866
+ ): string {
867
+ let escaped = value
868
+ .replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
869
+ .replace(/(?=(\\+?)?)\1$/g, "$1$1");
870
+ escaped = `"${escaped}"`.replace(WINDOWS_COMMAND_META_CHARS, "^$1");
871
+ return doubleEscapeMetaChars
872
+ ? escaped.replace(WINDOWS_COMMAND_META_CHARS, "^$1")
873
+ : escaped;
874
+ }
875
+
876
+ function resolveCommandTemplateSpawn(
877
+ command: string,
878
+ args: string[],
879
+ ): {
880
+ command: string;
881
+ args: string[];
882
+ windowsVerbatimArguments?: boolean;
883
+ } {
884
+ if (
885
+ process.platform !== "win32" ||
886
+ ![".bat", ".cmd"].includes(extname(command).toLowerCase())
887
+ ) {
888
+ return { command, args };
889
+ }
890
+ const normalizedCommand = normalize(command);
891
+ const isNodeModulesShim = /[\\/]node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu
892
+ .test(normalizedCommand);
893
+ const shellCommand = [
894
+ escapeWindowsCommand(normalizedCommand),
895
+ ...args.map((arg) =>
896
+ escapeWindowsCommandArgument(arg, isNodeModulesShim)
897
+ ),
898
+ ].join(" ");
899
+ return {
900
+ command: process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe",
901
+ args: ["/d", "/s", "/c", `"${shellCommand}"`],
902
+ windowsVerbatimArguments: true,
903
+ };
904
+ }
905
+
847
906
  function execCommandTemplateOnce(
848
907
  command: string,
849
908
  args: string[],
850
909
  options: CommandTemplateExecOptions = {},
851
910
  ): Promise<CommandTemplateExecResult> {
852
911
  return new Promise((resolve) => {
853
- const proc = spawn(command, args, {
912
+ const invocation = resolveCommandTemplateSpawn(command, args);
913
+ const proc = spawn(invocation.command, invocation.args, {
854
914
  cwd: options.cwd,
855
915
  shell: false,
916
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
856
917
  stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
857
918
  });
858
919
  let stdout = "";
package/lib/locks.ts CHANGED
@@ -423,7 +423,12 @@ function reclaimAbandonedDirectoryGuard(
423
423
  // Claim inside the still-occupied guard before making its stable path free.
424
424
  renameRecovery(sourcePath, reclaimPath);
425
425
  } catch (error) {
426
- if ((error as { code?: unknown })?.code === "ENOENT") return false;
426
+ const code = (error as { code?: unknown })?.code;
427
+ if (code === "ENOENT") return false;
428
+ // macOS may report EINVAL instead of ENOENT when another process wins
429
+ // the same source rename. Only classify it as contention once the
430
+ // observed source is actually gone; preserve unrelated EINVAL failures.
431
+ if (code === "EINVAL" && !existsSync(sourcePath)) return false;
427
432
  throw error;
428
433
  }
429
434
 
package/lib/sync.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
5
5
  */
6
6
 
7
+ import { getTelegramApiErrorRequestTarget } from "./telegram-api.ts";
7
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
8
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
9
10
  import {
@@ -423,6 +424,22 @@ export function createTelegramStaleTopicApiErrorRecoveryRuntime<
423
424
  recoverStaleTelegramTopicApiError(apiBody, error, deps);
424
425
  }
425
426
 
427
+ export async function settleStaleTelegramTopicExecutionFailure<
428
+ TSyncState extends TelegramSyncState,
429
+ >(
430
+ error: unknown,
431
+ deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
432
+ ): Promise<boolean> {
433
+ const target = getTelegramApiErrorRequestTarget(error);
434
+ if (!target || !isTelegramTopicTargetStaleError(error)) return false;
435
+ await recoverStaleTelegramTopicApiError(
436
+ { chat_id: target.chatId, message_thread_id: target.threadId },
437
+ error,
438
+ deps,
439
+ );
440
+ return true;
441
+ }
442
+
426
443
  export async function recoverStaleTelegramTopicApiError<
427
444
  TSyncState extends TelegramSyncState,
428
445
  >(
@@ -587,6 +587,7 @@ export function isTelegramApiCommitUnknownError(
587
587
  class TelegramApiHttpError extends Error {
588
588
  readonly status: number | undefined;
589
589
  readonly retryAfterSeconds: number | undefined;
590
+ requestTarget?: { chatId: number; threadId: number };
590
591
  constructor(
591
592
  message: string,
592
593
  status: number | undefined,
@@ -598,6 +599,41 @@ class TelegramApiHttpError extends Error {
598
599
  }
599
600
  }
600
601
 
602
+ function attachTelegramApiRequestTarget(
603
+ error: unknown,
604
+ body: Record<string, unknown> | Record<string, string>,
605
+ ): void {
606
+ if (!(error instanceof TelegramApiHttpError)) return;
607
+ const chatId = Number(body.chat_id);
608
+ const threadId = Number(body.message_thread_id);
609
+ if (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId)) return;
610
+ error.requestTarget = { chatId, threadId };
611
+ }
612
+
613
+ export class TelegramApiStaleTargetError extends Error {
614
+ readonly requestTarget: { chatId: number; threadId: number };
615
+
616
+ constructor(
617
+ message: string,
618
+ requestTarget: { chatId: number; threadId: number },
619
+ ) {
620
+ super(message);
621
+ this.name = "TelegramApiStaleTargetError";
622
+ this.requestTarget = { ...requestTarget };
623
+ }
624
+ }
625
+
626
+ export function getTelegramApiErrorRequestTarget(
627
+ error: unknown,
628
+ ): { chatId: number; threadId: number } | undefined {
629
+ const target =
630
+ error instanceof TelegramApiHttpError ||
631
+ error instanceof TelegramApiStaleTargetError
632
+ ? error.requestTarget
633
+ : undefined;
634
+ return target ? { ...target } : undefined;
635
+ }
636
+
601
637
  export function isTelegramMessageNotModifiedError(error: unknown): boolean {
602
638
  return (
603
639
  error instanceof Error && error.message.includes("message is not modified")
@@ -1156,21 +1192,26 @@ export async function callTelegram<TResponse>(
1156
1192
  options?: TelegramApiCallOptions,
1157
1193
  ): Promise<TResponse> {
1158
1194
  const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
1159
- return callTelegramWithRetry(
1160
- method,
1161
- async (family) =>
1162
- telegramFetch(
1163
- `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1164
- {
1165
- method: "POST",
1166
- headers: { "content-type": "application/json" },
1167
- body: JSON.stringify(body),
1168
- signal: options?.signal,
1169
- },
1170
- family,
1171
- ),
1172
- options,
1173
- );
1195
+ try {
1196
+ return await callTelegramWithRetry(
1197
+ method,
1198
+ async (family) =>
1199
+ telegramFetch(
1200
+ `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1201
+ {
1202
+ method: "POST",
1203
+ headers: { "content-type": "application/json" },
1204
+ body: JSON.stringify(body),
1205
+ signal: options?.signal,
1206
+ },
1207
+ family,
1208
+ ),
1209
+ options,
1210
+ );
1211
+ } catch (error) {
1212
+ attachTelegramApiRequestTarget(error, body);
1213
+ throw error;
1214
+ }
1174
1215
  }
1175
1216
 
1176
1217
  export type TelegramBotIdentityResponse = Pick<
@@ -1206,43 +1247,48 @@ export async function callTelegramMultipart<TResponse>(
1206
1247
  ): Promise<TResponse> {
1207
1248
  const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
1208
1249
  const fileBlob = await openAsBlob(filePath);
1209
- return callTelegramWithRetry(
1210
- method,
1211
- async (family) => {
1212
- if (family) {
1213
- const multipart = await buildTelegramMultipartBody(
1214
- fields,
1215
- fileField,
1216
- fileBlob,
1217
- fileName,
1218
- );
1250
+ try {
1251
+ return await callTelegramWithRetry(
1252
+ method,
1253
+ async (family) => {
1254
+ if (family) {
1255
+ const multipart = await buildTelegramMultipartBody(
1256
+ fields,
1257
+ fileField,
1258
+ fileBlob,
1259
+ fileName,
1260
+ );
1261
+ return telegramFetch(
1262
+ `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1263
+ {
1264
+ method: "POST",
1265
+ headers: { "content-type": multipart.contentType },
1266
+ body: multipart.body as unknown as BodyInit,
1267
+ signal: options?.signal,
1268
+ },
1269
+ family,
1270
+ );
1271
+ }
1272
+ const form = new FormData();
1273
+ for (const [key, value] of Object.entries(fields)) {
1274
+ form.set(key, value);
1275
+ }
1276
+ form.set(fileField, fileBlob, fileName);
1219
1277
  return telegramFetch(
1220
1278
  `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1221
1279
  {
1222
1280
  method: "POST",
1223
- headers: { "content-type": multipart.contentType },
1224
- body: multipart.body as unknown as BodyInit,
1281
+ body: form,
1225
1282
  signal: options?.signal,
1226
1283
  },
1227
- family,
1228
1284
  );
1229
- }
1230
- const form = new FormData();
1231
- for (const [key, value] of Object.entries(fields)) {
1232
- form.set(key, value);
1233
- }
1234
- form.set(fileField, fileBlob, fileName);
1235
- return telegramFetch(
1236
- `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
1237
- {
1238
- method: "POST",
1239
- body: form,
1240
- signal: options?.signal,
1241
- },
1242
- );
1243
- },
1244
- options,
1245
- );
1285
+ },
1286
+ options,
1287
+ );
1288
+ } catch (error) {
1289
+ attachTelegramApiRequestTarget(error, fields);
1290
+ throw error;
1291
+ }
1246
1292
  }
1247
1293
 
1248
1294
  export async function downloadTelegramFile(
package/lib/threads.ts CHANGED
@@ -2882,6 +2882,11 @@ export function getTelegramTargetFromApiBody(
2882
2882
 
2883
2883
  export function isTelegramTopicTargetStaleError(error: unknown): boolean {
2884
2884
  if (!(error instanceof Error)) return false;
2885
+ const status =
2886
+ "status" in error && typeof error.status === "number"
2887
+ ? error.status
2888
+ : undefined;
2889
+ if (status !== undefined && status !== 400) return false;
2885
2890
  const message = error.message.toLowerCase();
2886
2891
  return (
2887
2892
  message.includes("topic_id_invalid") ||
package/lib/updates.ts CHANGED
@@ -1782,6 +1782,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
1782
1782
  classifyExecutionFailure?: (
1783
1783
  error: unknown,
1784
1784
  ) => TelegramUpdateExecutionFailureClassification;
1785
+ settleTerminalExecutionFailure?: (error: unknown) => Promise<boolean>;
1785
1786
  scheduleRetry?: (callback: () => void, delayMs: number) => unknown;
1786
1787
  cancelRetry?: (handle: unknown) => void;
1787
1788
  batchSize?: number;
@@ -4833,13 +4834,20 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
4833
4834
  });
4834
4835
  worker = createTelegramUpdateWorkerRuntime({
4835
4836
  ...deps,
4836
- executeUpdate: (update, ctx, signal) => {
4837
+ async executeUpdate(update, ctx, signal) {
4837
4838
  const typedUpdate = update as TUpdate;
4838
- return executeUpdate(
4839
- deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
4840
- ctx,
4841
- signal,
4842
- );
4839
+ try {
4840
+ return await executeUpdate(
4841
+ deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
4842
+ ctx,
4843
+ signal,
4844
+ );
4845
+ } catch (error) {
4846
+ if (await deps.settleTerminalExecutionFailure?.(error)) {
4847
+ return { kind: "complete" };
4848
+ }
4849
+ throw error;
4850
+ }
4843
4851
  },
4844
4852
  });
4845
4853
  return worker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.39.1",
3
+ "version": "0.39.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"