@llblab/pi-telegram 0.52.0 → 0.52.2

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.
@@ -189,6 +189,7 @@ function formatTelegramTemporaryThreadChooserText(command) {
189
189
  "<b>🧵 Choose target thread:</b>",
190
190
  "",
191
191
  command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
192
+ "Unaccepted routing expires after 60 minutes; disposable tabs are removed when clear. Accepted work and restored Threads are kept.",
192
193
  "Select the Pi thread that should handle it, or restore a Pi into this tab:",
193
194
  ].join("\n");
194
195
  }
@@ -1902,9 +1903,9 @@ export function createTelegramInboundRouteRuntime(deps) {
1902
1903
  * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
1903
1904
  * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
1904
1905
  */
1905
- const runTemporaryThreadCleanup = async (token, cap) => {
1906
- const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
1907
- if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1906
+ const runTemporaryThreadCleanup = async (token, cap, reconcileExpiry) => {
1907
+ const store = cap.store;
1908
+ if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1908
1909
  return;
1909
1910
  await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
1910
1911
  operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
@@ -1912,15 +1913,48 @@ export function createTelegramInboundRouteRuntime(deps) {
1912
1913
  return;
1913
1914
  let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
1914
1915
  const target = entry?.target;
1915
- if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
1916
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
1916
+ if (!entry || !target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
1917
+ entry.source.journalBindingKey !== cap.journalBindingKey)
1918
+ return;
1919
+ const owner = `telegram-owner:${cap.operatorUserId}`;
1920
+ if (reconcileExpiry && deps.inspectRoutingInputGroupExpiry) {
1921
+ for (const group of Threads.getTelegramTemporaryThreadInputs(entry)) {
1922
+ if ([...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])].some(value => isDeepStrictEqual(value, group)))
1923
+ continue;
1924
+ const inspect = (id) => deps.inspectRoutingInputGroupExpiry(group)?.find(value => value.updateId === id);
1925
+ if (!group.updateIds.every(id => inspect(id)?.operatorAuthorityId === owner))
1926
+ continue;
1927
+ entry = cap.adopt(entry);
1928
+ if (!entry || !cap.isCurrent())
1929
+ return;
1930
+ let recorded, failure;
1931
+ try {
1932
+ recorded = store.recordTemporaryThreadInputExpiry(entry, group, cap.authority, inspect);
1933
+ }
1934
+ catch (error) {
1935
+ failure = error;
1936
+ }
1937
+ if (!cap.isCurrent())
1938
+ return;
1939
+ const retained = store.listTemporaryThreads().find(value => value.token === token);
1940
+ // A lost publication ACK may already have recorded the group or retired a bound tab's temporary frame.
1941
+ if (!retained)
1942
+ return;
1943
+ if (!recorded && !retained.cancelledInputs?.some(value => isDeepStrictEqual(value, group)))
1944
+ throw failure ?? new Error("Chooser expiry metadata was not confirmed.");
1945
+ entry = retained;
1946
+ }
1947
+ }
1948
+ if (!Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target))
1917
1949
  return;
1918
- const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
1919
- const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
1920
- const evidence = inspect(updateId, input.journalBindingKey);
1921
- return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
1922
- evidence.operatorAuthorityId === owner;
1923
- }));
1950
+ const cancelled = entry.cancelledInputs ?? [];
1951
+ const cancelledCurrent = () => cancelled.every(input => {
1952
+ const expiry = deps.inspectRoutingInputGroupExpiry?.(input);
1953
+ return input.updateIds.every(updateId => {
1954
+ const evidence = deps.inspectRestoreSourceAbandonment?.(updateId, input.journalBindingKey) ?? expiry?.find(value => value.updateId === updateId);
1955
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId && evidence.operatorAuthorityId === owner;
1956
+ });
1957
+ });
1924
1958
  if (!cancelledCurrent())
1925
1959
  return;
1926
1960
  entry = cap.adopt(entry);
@@ -1940,8 +1974,40 @@ export function createTelegramInboundRouteRuntime(deps) {
1940
1974
  store.retireTemporaryThread(entry, cap.authority);
1941
1975
  });
1942
1976
  };
1943
- /** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
1944
- const scheduleTemporaryThreadCleanup = (target, ctx) => {
1977
+ // Reuse the one timer per tab for body-free metadata retries. Future retry timers do not join settlement waits;
1978
+ // only their running attempt does. No retry is licensed once a destructive grant may have been issued.
1979
+ const queueTemporaryThreadCleanup = (token, cap, delay, reconcileExpiry, retry = false) => {
1980
+ cancelTemporaryThreadCleanup(token);
1981
+ let settle = () => undefined;
1982
+ const task = new Promise(resolve => { settle = resolve; });
1983
+ const timer = setTimeout(() => {
1984
+ if (temporaryCleanupTimers.get(token)?.timer !== timer)
1985
+ return;
1986
+ temporaryCleanupTimers.delete(token);
1987
+ if (retry)
1988
+ restoreSettlementTasks.add(task);
1989
+ runTemporaryThreadCleanup(token, cap, reconcileExpiry)
1990
+ .catch(error => {
1991
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" });
1992
+ if (!reconcileExpiry || !cap.isCurrent())
1993
+ return;
1994
+ try {
1995
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
1996
+ if (retained && !retained.cleanupIssued && cap.isCurrent())
1997
+ queueTemporaryThreadCleanup(token, cap, 60_000, true, true);
1998
+ }
1999
+ catch { /* Unknown grant state never licenses a retry. */ }
2000
+ })
2001
+ .finally(settle);
2002
+ }, delay);
2003
+ timer.unref?.();
2004
+ temporaryCleanupTimers.set(token, { timer, settle });
2005
+ if (!retry)
2006
+ restoreSettlementTasks.add(task);
2007
+ void task.finally(() => restoreSettlementTasks.delete(task));
2008
+ };
2009
+ /** Starts the quiet period for resolved groups or body-free expiry reconciliation; fresh input/authority revokes it. */
2010
+ const scheduleTemporaryThreadCleanup = (target, ctx, reconcileExpiry = false) => {
1945
2011
  const cap = captureTemporaryThreadAuthority(ctx);
1946
2012
  if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target))
1947
2013
  return;
@@ -1953,27 +2019,22 @@ export function createTelegramInboundRouteRuntime(deps) {
1953
2019
  deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
1954
2020
  return;
1955
2021
  }
1956
- if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
1957
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
2022
+ if (!entry || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
2023
+ return;
2024
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) {
2025
+ try {
2026
+ reconcileExpiry = Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group));
2027
+ }
2028
+ catch (error) {
2029
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
2030
+ return;
2031
+ }
2032
+ }
2033
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry))
1958
2034
  return;
1959
- const token = entry.token;
1960
- cancelTemporaryThreadCleanup(token);
1961
2035
  const requested = deps.temporaryThreadCleanupDelayMs;
1962
2036
  const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
1963
- let settle = () => undefined;
1964
- const task = new Promise(resolve => { settle = resolve; });
1965
- const timer = setTimeout(() => {
1966
- if (temporaryCleanupTimers.get(token)?.timer !== timer)
1967
- return;
1968
- temporaryCleanupTimers.delete(token);
1969
- runTemporaryThreadCleanup(token, cap)
1970
- .catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
1971
- .finally(settle);
1972
- }, delay);
1973
- timer.unref?.();
1974
- temporaryCleanupTimers.set(token, { timer, settle });
1975
- restoreSettlementTasks.add(task);
1976
- void task.finally(() => restoreSettlementTasks.delete(task));
2037
+ queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
1977
2038
  };
1978
2039
  const recordCancelledTemporaryThreadInput = (pending, ctx, isCurrent) => {
1979
2040
  const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
@@ -2056,10 +2117,15 @@ export function createTelegramInboundRouteRuntime(deps) {
2056
2117
  if (!cap.isCurrent())
2057
2118
  return;
2058
2119
  const self = cap.authority.executor.instanceId;
2059
- const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
2120
+ const entries = cap.store.listTemporaryThreads();
2121
+ const preserved = entries.filter(entry => entry.operatorUserId === cap.operatorUserId && entry.phase === "created" &&
2122
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" &&
2123
+ Threads.getTelegramTemporaryThreadInputs(entry).some(source => source.journalBindingKey === journalBindingKey &&
2124
+ source.updateIds.some(id => input.routingSourceIds?.includes(id)))).map(entry => entry.token);
2125
+ const disposable = entries.flatMap(entry => entry.operatorUserId === cap.operatorUserId && !preserved.includes(entry.token) &&
2060
2126
  entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2061
2127
  cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2062
- const forgotten = cap.store.forgetPreviousWorld(cap.authority);
2128
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
2063
2129
  if (!forgotten)
2064
2130
  return;
2065
2131
  result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
@@ -2087,6 +2153,17 @@ export function createTelegramInboundRouteRuntime(deps) {
2087
2153
  deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" });
2088
2154
  }
2089
2155
  }
2156
+ // Same-instance session replacement may have lost a metadata-only timer after the source was already spent.
2157
+ // Reconstruct from retained tab/source proof under fresh authority, never from a prompt body or an issued delete.
2158
+ for (const entry of cap.store.listTemporaryThreads()) {
2159
+ if (!cap.isCurrent())
2160
+ break;
2161
+ if (entry.phase !== "created" || !entry.target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
2162
+ entry.source.journalBindingKey !== journalBindingKey)
2163
+ continue;
2164
+ if (Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)))
2165
+ scheduleTemporaryThreadCleanup(entry.target, ctx, true);
2166
+ }
2090
2167
  });
2091
2168
  }
2092
2169
  catch (error) {
@@ -2172,10 +2249,11 @@ export function createTelegramInboundRouteRuntime(deps) {
2172
2249
  const expireRoutingInput = async (source, ctx, signal) => {
2173
2250
  const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2174
2251
  const message = source.original.update.message;
2175
- const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
2252
+ const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
2176
2253
  deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2177
2254
  deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2178
2255
  source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
2256
+ const current = () => runtimeCurrent() && source.isCurrent();
2179
2257
  if (!current() || !deps.runWorkspaceOperation)
2180
2258
  return;
2181
2259
  await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
@@ -2185,19 +2263,11 @@ export function createTelegramInboundRouteRuntime(deps) {
2185
2263
  const restores = deps.getWorkspaceRestoreStore?.();
2186
2264
  if (deps.getWorkspaceRestoreStore && !restores)
2187
2265
  return;
2188
- if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
2189
- intent.request.source.updateIds.includes(source.original.updateId)))
2190
- return;
2191
- for (const pending of pendingUnboundReroutes.values()) {
2192
- if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2193
- continue;
2194
- if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running)
2195
- return;
2196
- }
2266
+ const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
2197
2267
  const result = source.expire();
2198
2268
  if (!result)
2199
2269
  return;
2200
- // Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
2270
+ // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
2201
2271
  for (const [id, pending] of pendingUnboundReroutes) {
2202
2272
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2203
2273
  continue;
@@ -2214,6 +2284,10 @@ export function createTelegramInboundRouteRuntime(deps) {
2214
2284
  }
2215
2285
  }
2216
2286
  }
2287
+ // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
2288
+ // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
2289
+ if (temporary?.target && runtimeCurrent())
2290
+ scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
2217
2291
  });
2218
2292
  };
2219
2293
  const renderCancellationReview = async (view, isCurrent) => {
@@ -92,6 +92,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
92
92
  recordRuntimeEvent: (category: string, error: unknown, details?: Record<string, unknown>) => void;
93
93
  runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
94
94
  workspaceOperationKind?: string;
95
+ isDisconnectCurrent?: () => boolean;
95
96
  getNowMs?: () => number;
96
97
  }
97
98
  export declare function createTelegramPreservedLeaderQuitHandler(deps: {
@@ -112,6 +113,10 @@ export interface TelegramThreadDisconnectAssembly {
112
113
  }
113
114
  export declare function createTelegramThreadDisconnectAssembly<TSyncState extends TelegramSyncState>(deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
114
115
  stopPolling: () => Promise<string>;
116
+ captureStopPolling?: () => {
117
+ isCurrent: () => boolean;
118
+ stop: () => Promise<string>;
119
+ };
115
120
  suspendPolling: () => Promise<void>;
116
121
  }): TelegramThreadDisconnectAssembly;
117
122
  export declare function createTelegramManualThreadDisconnectHandler<TSyncState extends TelegramSyncState>(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string>;
package/dist/lib/sync.js CHANGED
@@ -74,11 +74,41 @@ function createTelegramSessionRestartThreadCleanupHandler(deps) {
74
74
  }
75
75
  export function createTelegramThreadDisconnectAssembly(deps) {
76
76
  return {
77
- disconnect: createTelegramManualThreadDisconnectHandler({
78
- ...deps,
79
- workspaceOperationKind: "workspace.disconnect-thread",
80
- stopPolling: deps.stopPolling,
81
- }),
77
+ async disconnect() {
78
+ const captured = deps.captureStopPolling?.();
79
+ const stop = captured?.stop ?? deps.stopPolling;
80
+ let stopAttempted = false;
81
+ const disconnect = createTelegramManualThreadDisconnectHandler({
82
+ ...deps,
83
+ workspaceOperationKind: "workspace.disconnect-thread",
84
+ isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
85
+ stopPolling() {
86
+ stopAttempted = true;
87
+ return stop();
88
+ },
89
+ });
90
+ try {
91
+ return await disconnect();
92
+ }
93
+ catch (error) {
94
+ // Never retry a stop whose teardown/release outcome may already have committed.
95
+ if (stopAttempted) {
96
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
97
+ throw error;
98
+ }
99
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
100
+ let stopped;
101
+ try {
102
+ stopped = await stop();
103
+ }
104
+ catch (stopError) {
105
+ deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
106
+ throw stopError;
107
+ }
108
+ const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
109
+ return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
110
+ }
111
+ },
82
112
  cleanupForSessionRestart: createTelegramSessionRestartThreadCleanupHandler({
83
113
  ...deps,
84
114
  suspendPolling: deps.suspendPolling,
@@ -86,7 +116,12 @@ export function createTelegramThreadDisconnectAssembly(deps) {
86
116
  };
87
117
  }
88
118
  export function createTelegramManualThreadDisconnectHandler(deps) {
119
+ const assertCurrent = () => {
120
+ if (deps.isDisconnectCurrent?.() === false)
121
+ throw new Error("Telegram disconnect was superseded by a new connection.");
122
+ };
89
123
  const operation = async () => {
124
+ assertCurrent();
90
125
  const currentRecord = deps.getCurrentThreadRecord();
91
126
  let cleanupPending = false;
92
127
  if (currentRecord?.target.threadId) {
@@ -98,6 +133,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
98
133
  if (isManualFollower && !ownsLeader) {
99
134
  if (deps.disconnectFollowerThread) {
100
135
  const disconnected = await deps.disconnectFollowerThread();
136
+ assertCurrent();
101
137
  if (!disconnected) {
102
138
  throw new Error("Telegram follower thread deletion requires a live leader registration.");
103
139
  }
@@ -122,6 +158,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
122
158
  const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
123
159
  deps.topicTargetStore.upsertPendingCleanup(intent);
124
160
  await deps.topicTargetStore.persist();
161
+ assertCurrent();
125
162
  const cleanupPlan = ThreadReconciler.planThreadReconciliation({
126
163
  nowMs: (deps.getNowMs ?? Date.now)(),
127
164
  currentLeaderEpoch: leaderEpoch,
@@ -131,20 +168,25 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
131
168
  const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(cleanupPlan, {
132
169
  isCleanupTargetProtected,
133
170
  callApi(method, body) {
171
+ assertCurrent();
134
172
  return deps.callApi(method, body);
135
173
  },
136
174
  markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
175
+ assertCurrent();
137
176
  return deps.topicTargetStore.markStaleByTarget(targetToMark, syncStatus, lastSyncError);
138
177
  },
139
178
  removeCleanupIntentById(id) {
179
+ assertCurrent();
140
180
  return deps.topicTargetStore.removePendingCleanup(id);
141
181
  },
142
182
  persist() {
183
+ assertCurrent();
143
184
  return deps.topicTargetStore.persist();
144
185
  },
145
186
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
146
187
  recordRuntimeEvent: deps.recordRuntimeEvent,
147
188
  });
189
+ assertCurrent();
148
190
  if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
149
191
  return "Thread disconnect superseded by a new binding.";
150
192
  }
@@ -160,6 +202,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
160
202
  action: "manual-disconnect",
161
203
  }));
162
204
  }
205
+ assertCurrent();
163
206
  const stopped = await deps.stopPolling();
164
207
  return cleanupPending
165
208
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
@@ -514,6 +514,8 @@ export declare function fetchTelegramBotIdentity(botToken: string, fetchImpl?: t
514
514
  */
515
515
  export declare function callTelegramMultipart<TResponse>(botToken: string | undefined, method: string, fields: Record<string, string>, fileField: string, filePath: string, fileName: string, options?: TelegramApiCallOptions): Promise<TResponse>;
516
516
  export declare function downloadTelegramFile(botToken: string | undefined, fileId: string, suggestedName: string, tempDir: string, options?: TelegramFileDownloadOptions): Promise<string>;
517
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
518
+ export declare function formatTelegramCallbackAnswerText(text: string | undefined): string | undefined;
517
519
  export declare function answerTelegramCallbackQuery(botToken: string | undefined, callbackQueryId: string, text?: string, options?: TelegramAnswerCallbackQueryOptions): Promise<void>;
518
520
  export declare function createTelegramChatActionSender<TAction extends string>(sendChatAction: (chatId: number, action: TAction, options?: {
519
521
  message_thread_id?: number;
@@ -30,6 +30,7 @@ function getTelegramApiTempDir() {
30
30
  return resolveTelegramAttachmentsDir();
31
31
  }
32
32
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
+ const TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS = 6;
33
34
  const activeTelegramApiWorkspaceAdmissionOperationIds = new Set();
34
35
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(process.env, ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"], TELEGRAM_FILE_MAX_BYTES);
35
36
  const TELEGRAM_NETWORK_FAMILY_ENV = "PI_TELEGRAM_NETWORK_FAMILY";
@@ -391,6 +392,22 @@ async function writeTelegramDownloadResponse(response, targetPath, maxFileSizeBy
391
392
  }
392
393
  await pipeline(Readable.from(response.body, { objectMode: false }), createTelegramDownloadLimitTransform(maxFileSizeBytes), createWriteStream(targetPath, { mode: 0o600 }));
393
394
  }
395
+ async function publishTelegramDownload(partPath, targetPath, signal) {
396
+ for (let attempt = 0;; attempt++) {
397
+ throwIfTelegramApiCallAborted(signal);
398
+ try {
399
+ await rename(partPath, targetPath);
400
+ return;
401
+ }
402
+ catch (error) {
403
+ const code = error?.code;
404
+ // Windows may briefly deny replacement while another publisher/scanner holds the file.
405
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES") || attempt + 1 >= TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS)
406
+ throw error;
407
+ await sleepTelegramRetry(50 * 2 ** attempt, signal);
408
+ }
409
+ }
410
+ }
394
411
  async function removeTelegramPartialDownload(path) {
395
412
  try {
396
413
  await unlink(path);
@@ -787,7 +804,7 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
787
804
  assertTelegramFileSizeWithinLimit(contentLength ? Number.parseInt(contentLength, 10) : undefined, options?.maxFileSizeBytes);
788
805
  try {
789
806
  await writeTelegramDownloadResponse(response, partPath, options?.maxFileSizeBytes);
790
- await rename(partPath, targetPath);
807
+ await publishTelegramDownload(partPath, targetPath, options?.signal);
791
808
  }
792
809
  catch (error) {
793
810
  await removeTelegramPartialDownload(partPath);
@@ -795,6 +812,10 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
795
812
  }
796
813
  return targetPath;
797
814
  }
815
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
816
+ export function formatTelegramCallbackAnswerText(text) {
817
+ return text?.replace(/(?<!\.)\.\s*$/u, "");
818
+ }
798
819
  export async function answerTelegramCallbackQuery(botToken, callbackQueryId, text, options = {}) {
799
820
  try {
800
821
  await callTelegram(botToken, "answerCallbackQuery", text
@@ -252,7 +252,7 @@ export interface TelegramTemporaryThreadInput {
252
252
  journalBindingKey: string;
253
253
  updateIds: number[];
254
254
  }
255
- /** A strict journal observation of committed cancellation plus matching private retention. */
255
+ /** Journal-owned donor discard evidence: retained manual cancellation or body-free chooser expiry, never recipient cancellation. */
256
256
  export interface TelegramTemporaryThreadCancellationEvidence {
257
257
  journalBindingKey: string;
258
258
  updateId: number;
@@ -266,7 +266,7 @@ export interface TelegramTemporaryThreadEntry {
266
266
  };
267
267
  /** Append-only known source groups. Missing legacy metadata never proves that the creation source was alone. */
268
268
  inputs?: TelegramTemporaryThreadInput[];
269
- /** Whole known groups with positively observed cancellation; never proof that deletion may be issued. */
269
+ /** Whole known groups with positively observed donor cancellation or expiry; never recipient cancellation or deletion authority. */
270
270
  cancelledInputs?: TelegramTemporaryThreadInput[];
271
271
  /** Whole known groups whose Forward was positively completed by the journal owner; never deletion authority alone. */
272
272
  completedInputs?: TelegramTemporaryThreadInput[];
@@ -361,6 +361,8 @@ export interface TelegramWorkspaceRestore {
361
361
  recordTemporaryThreadForwardIssued(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
362
362
  recordTemporaryThreadInputCompletion(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
363
363
  recordTemporaryThreadInputCancellation(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority, inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
364
+ /** Body-free chooser expiry may terminate an uncertain donor Forward/Restore, never accepted recipient work or a bound target. */
365
+ recordTemporaryThreadInputExpiry(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority, inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
364
366
  /** Caller holds profile admission and proves fresh source/protection clearance; publication grants one attempt, never retry. */
365
367
  issueTemporaryThreadCleanup(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): {
366
368
  issued: true;
@@ -370,8 +372,8 @@ export interface TelegramWorkspaceRestore {
370
372
  /** `completed` names one newly completed group; every other known group must already be cancelled or completed. */
371
373
  retireTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority, completed?: TelegramTemporaryThreadInput): TelegramTemporaryThreadEntry | undefined;
372
374
  /** New-world restart: atomically forgets this operator's Restore intents and temporary entries from previous runtime instances.
373
- * Committed bindings stay; nothing is rolled back, replayed or deleted here. */
374
- forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority): {
375
+ * Caller may preserve exact unbound temporary tokens for clock-bearing sources. Committed bindings stay; nothing is rolled back, replayed or deleted here. */
376
+ forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority, preserveTemporaryTokens?: readonly string[]): {
375
377
  operations: TelegramWorkspaceRestoreIntent[];
376
378
  temporaryThreads: TelegramTemporaryThreadEntry[];
377
379
  } | undefined;
@@ -2750,6 +2750,47 @@ export function createTelegramTopicTargetStore(options) {
2750
2750
  });
2751
2751
  return recorded && current() ? recorded : undefined;
2752
2752
  },
2753
+ recordTemporaryThreadInputExpiry(expected, inputValue, authority, inspect) {
2754
+ const input = structuredClone(inputValue);
2755
+ if (!isTemporaryThreadInput(input) || typeof inspect !== "function")
2756
+ return undefined;
2757
+ const operator = authority.operatorUserId, executor = structuredClone(authority.executor);
2758
+ const bound = classifyTemporaryTarget(read(), expected).kind === "bound";
2759
+ const current = () => authority.isCurrent() && authority.operatorUserId === operator &&
2760
+ isDeepStrictEqual(authority.executor, executor) && input.updateIds.every(updateId => {
2761
+ const evidence = inspect(updateId);
2762
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
2763
+ evidence.operatorAuthorityId === `telegram-owner:${operator}`;
2764
+ }) && authority.isCurrent();
2765
+ return advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
2766
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(value => isDeepStrictEqual(value, input)) ||
2767
+ entry.completedInputs?.some(value => isDeepStrictEqual(value, input)))
2768
+ return false;
2769
+ const cancelled = entry.cancelledInputs ?? [];
2770
+ if (cancelled.some(value => isDeepStrictEqual(value, input)))
2771
+ return "unchanged";
2772
+ const operations = file.operations.filter(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
2773
+ request.source.updateIds.some(id => input.updateIds.includes(id)));
2774
+ if (operations.some(({ request }) => !request.source.updateIds.every(id => input.updateIds.includes(id))))
2775
+ return false;
2776
+ // Forget only expired donor intent; canonical binding, recipient queue and immutable settlement proofs are untouched.
2777
+ file.operations = file.operations.filter(value => !operations.includes(value));
2778
+ // Once bound, this is no longer a disposable tab. Forget its temporary frame, not the binding or other journal sources.
2779
+ if (bound) {
2780
+ file.temporaryThreads = file.temporaryThreads?.filter(value => value !== entry);
2781
+ if (!file.temporaryThreads?.length)
2782
+ delete file.temporaryThreads;
2783
+ return true;
2784
+ }
2785
+ const forwarded = entry.forwardedInputs?.filter(value => !isDeepStrictEqual(value, input));
2786
+ if (forwarded?.length)
2787
+ entry.forwardedInputs = forwarded;
2788
+ else
2789
+ delete entry.forwardedInputs;
2790
+ entry.cancelledInputs = [...cancelled, input];
2791
+ return true;
2792
+ });
2793
+ },
2753
2794
  recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
2754
2795
  const input = structuredClone(inputValue);
2755
2796
  if (!isTemporaryThreadInput(input))
@@ -2808,7 +2849,7 @@ export function createTelegramTopicTargetStore(options) {
2808
2849
  return entry;
2809
2850
  });
2810
2851
  },
2811
- forgetPreviousWorld(authority) {
2852
+ forgetPreviousWorld(authority, preserveTemporaryTokens = []) {
2812
2853
  if (!authority.isCurrent())
2813
2854
  return undefined;
2814
2855
  const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
@@ -2817,13 +2858,14 @@ export function createTelegramTopicTargetStore(options) {
2817
2858
  const current = () => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
2818
2859
  const previous = (value) => value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
2819
2860
  const before = storage.read(), file = structuredClone(before);
2820
- const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(previous);
2861
+ const forgetTemporary = (value) => previous(value) && !preserveTemporaryTokens.includes(value.token);
2862
+ const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(forgetTemporary);
2821
2863
  if (!current())
2822
2864
  return undefined;
2823
2865
  if (!operations.length && !temporaryThreads.length)
2824
2866
  return { operations, temporaryThreads };
2825
2867
  file.operations = file.operations.filter(value => !previous(value));
2826
- const remaining = (file.temporaryThreads ?? []).filter(value => !previous(value));
2868
+ const remaining = (file.temporaryThreads ?? []).filter(value => !forgetTemporary(value));
2827
2869
  if (remaining.length)
2828
2870
  file.temporaryThreads = remaining;
2829
2871
  else
@@ -6,7 +6,7 @@
6
6
  import { type TelegramTarget } from "./target.ts";
7
7
  import type { TelegramBusEnvelope, TelegramBusFollowerView, TelegramBusForwardOwnership, TelegramBusForeignUpdateSettlement, TelegramProcessLiveness } from "./bus.ts";
8
8
  import type { TelegramMessageOwnershipStore } from "./ownership.ts";
9
- import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, type TelegramUpdateJournalEntryDigest, type TelegramUpdateJournalSourceCompletion, type TelegramUpdateJournalQueuedCompletion, type TelegramUpdateJournalQueuedReceiptEvidence, type TelegramInputJournalReceipt, type TelegramInputJournalSourceReference, type TelegramInputJournalStore, type TelegramJournaledUpdate, type TelegramUpdateJournalDeadQueueOwnerRecoveryResult, type TelegramUpdateJournalAppendResult, type TelegramUpdateJournalInputClaim, type TelegramUpdateJournalOperatorDispositionInput, type TelegramUpdateJournalOperatorDispositionResult, type TelegramUpdateJournalPendingAbandonmentInput, type TelegramUpdateJournalPendingAbandonmentResult, type TelegramUpdateJournalPendingRetentionEvidence, type TelegramUpdateJournalEntry, type TelegramUpdateJournalRoutingInput, type TelegramRoutingInputJournal, type TelegramUpdateJournalQueueDiscardResult, type TelegramUpdateJournalQueueHandoffAcceptResult, type TelegramUpdateJournalQueueHandoffCancelResult, type TelegramUpdateJournalQueueHandoffInput, type TelegramUpdateJournalQueueHandoffOfferResult, type TelegramUpdateJournalQueueOwner, type TelegramUpdateJournalQueueOwnerIdentity } from "./journal.ts";
9
+ import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, type TelegramUpdateJournalEntryDigest, type TelegramUpdateJournalSourceCompletion, type TelegramUpdateJournalQueuedCompletion, type TelegramUpdateJournalQueuedReceiptEvidence, type TelegramInputJournalReceipt, type TelegramInputJournalSourceReference, type TelegramInputJournalStore, type TelegramJournaledUpdate, type TelegramUpdateJournalDeadQueueOwnerRecoveryResult, type TelegramUpdateJournalAppendResult, type TelegramUpdateJournalInputClaim, type TelegramUpdateJournalOperatorDispositionInput, type TelegramUpdateJournalOperatorDispositionResult, type TelegramUpdateJournalPendingAbandonmentInput, type TelegramUpdateJournalPendingAbandonmentResult, type TelegramUpdateJournalPendingRetentionEvidence, type TelegramUpdateJournalEntry, type TelegramUpdateJournalRoutingInput, type TelegramRoutingInputJournal, type TelegramRoutingInputExpiryResult, type TelegramUpdateJournalQueueDiscardResult, type TelegramUpdateJournalQueueHandoffAcceptResult, type TelegramUpdateJournalQueueHandoffCancelResult, type TelegramUpdateJournalQueueHandoffInput, type TelegramUpdateJournalQueueHandoffOfferResult, type TelegramUpdateJournalQueueOwner, type TelegramUpdateJournalQueueOwnerIdentity } from "./journal.ts";
10
10
  import { type PendingTelegramControlItem, type TelegramControlQueueHandoffPayload, type TelegramQueueAdmissionReceipt, type TelegramQueueHandoffPayload, type TelegramQueueHandoffStageResult, type TelegramQueueReactionDisposition, type TelegramQueueHandoffStagingRuntime, type TelegramQueueItem } from "./queue.ts";
11
11
  import { type TelegramAuthorizationState, type TelegramUserPairingRuntimeDeps } from "./config.ts";
12
12
  export interface TelegramReactionTypeEmoji {
@@ -189,6 +189,8 @@ export interface TelegramHeldSourcePreparation<TContext> {
189
189
  /** Exact prepared worker owner, context, key and authority; the consumer adds its own domain fences. */
190
190
  isCurrent: () => boolean;
191
191
  signal: AbortSignal;
192
+ /** Chooser-clock sources that must survive cold spending until their fixed expiry. */
193
+ routingSourceIds?: readonly number[];
192
194
  }
193
195
  export interface TelegramDeferredAbandonmentRecoveryPage {
194
196
  sources: {
@@ -504,7 +506,7 @@ export interface TelegramRoutingInputExpirySource {
504
506
  original: TelegramUpdateJournalEntry;
505
507
  journalBindingKey: string;
506
508
  isCurrent(): boolean;
507
- expire(): TelegramUpdateJournalPendingAbandonmentResult | undefined;
509
+ expire(): TelegramRoutingInputExpiryResult | undefined;
508
510
  }
509
511
  export interface TelegramUpdateWorkerJournalPort {
510
512
  routingInputs?: TelegramRoutingInputJournal;
@@ -585,7 +587,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
585
587
  getRecipientBindingKey?: () => string | undefined;
586
588
  /** True selects legacy historical review/spending; retain protects unsupported originals without disposition authority. */
587
589
  shouldReviewHistoricalInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | "retain" | Promise<boolean | "retain">;
588
- /** New-world restart: spend previous-process routing inputs (classified or armed) without delivery, copy or completion observers. Interrupted private abandonment keeps its own exact recovery. */
590
+ /** New-world restart: spend unclocked classified routing input without delivery, copy or completion. Chooser clocks wait for expiry; interrupted private abandonment keeps exact recovery. */
589
591
  spendHistoricalInput?: boolean;
590
592
  /** Holds protected live or retry sources before execution; never cancels or disposes them. */
591
593
  shouldHoldPendingInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | Promise<boolean>;
@@ -633,6 +635,10 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
633
635
  operatorUserId: number;
634
636
  sourceUpdateIds: readonly number[];
635
637
  }) => boolean;
638
+ isRoutingInputCurrent?: (input: {
639
+ updateId: number;
640
+ signal: AbortSignal;
641
+ }) => boolean;
636
642
  supportsDeferredAbandonment?: (input: {
637
643
  updateId: number;
638
644
  signal: AbortSignal;
@@ -744,6 +750,7 @@ export interface TelegramUpdateAdmissionHandleDeps<TUpdate extends TelegramUpdat
744
750
  abandonDeferred?: TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"];
745
751
  armRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["armRoutingInput"];
746
752
  selectRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["selectRoutingInput"];
753
+ isRoutingInputCurrent?: TelegramUpdateWorkerRuntime<TContext>["isRoutingInputCurrent"];
747
754
  supportsDeferredAbandonment?: TelegramUpdateWorkerRuntime<TContext>["supportsDeferredAbandonment"];
748
755
  inspectAbandoning?: TelegramUpdateWorkerRuntime<TContext>["inspectAbandoning"];
749
756
  inspectHistorical?: TelegramUpdateWorkerRuntime<TContext>["inspectHistorical"];
@@ -1135,7 +1142,7 @@ export declare function executeTelegramCustodiedInput(input: {
1135
1142
  export declare function createTelegramUpdateAdmissionHandle<TUpdate extends TelegramUpdateFlow & {
1136
1143
  update_id: number;
1137
1144
  }, TContext>(deps: TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>): (update: TUpdate, ctx: TContext, signal: AbortSignal) => Promise<TelegramUpdateAdmissionOutcome>;
1138
- export declare function createTelegramCustodiedUpdateAdmissionHandle<TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow, TContext>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>, "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
1145
+ export declare function createTelegramCustodiedUpdateAdmissionHandle<TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow, TContext>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>, "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "isRoutingInputCurrent" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
1139
1146
  journal: TelegramCustodyExecutionJournal;
1140
1147
  recipientBindingKey: string;
1141
1148
  onLateOutcomeError(error: unknown, updateId: number): void;