@llblab/pi-telegram 0.22.1 → 0.23.1

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.
@@ -4,6 +4,15 @@
4
4
  * Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
5
5
  */
6
6
 
7
+ import { setTimeout as waitForTimeout } from "node:timers/promises";
8
+
9
+ import {
10
+ extractTelegramMessageText,
11
+ type TelegramMessageForwardOrigin,
12
+ type TelegramMessageUser,
13
+ type TelegramRichMessage,
14
+ } from "./media.ts";
15
+
7
16
  const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
8
17
  const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
9
18
  const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
@@ -13,9 +22,13 @@ export interface TelegramTextGroupMessage {
13
22
  media_group_id?: string;
14
23
  chat: { id: number };
15
24
  message_thread_id?: number;
16
- from?: { id: number; is_bot?: boolean };
25
+ from?: { id?: number; is_bot?: boolean };
17
26
  text?: string;
18
27
  caption?: string;
28
+ rich_message?: TelegramRichMessage;
29
+ forward_origin?: TelegramMessageForwardOrigin;
30
+ forward_from?: TelegramMessageUser;
31
+ forward_sender_name?: string;
19
32
  }
20
33
 
21
34
  export interface TelegramTextGroupState<TMessage, TContext = unknown> {
@@ -24,10 +37,24 @@ export interface TelegramTextGroupState<TMessage, TContext = unknown> {
24
37
  flushTimer?: ReturnType<typeof setTimeout>;
25
38
  dispatching?: boolean;
26
39
  suspended?: boolean;
27
- reschedule?: () => void;
40
+ reschedule?: (delayMs?: number) => void;
41
+ dispatchLimit?: number;
42
+ forwardCommentCandidate?: boolean;
28
43
  }
29
44
 
45
+ export type TelegramForwardCommentBatchPosition = "comment" | "forward";
46
+
30
47
  export interface TelegramTextGroupController<TMessage, TContext = unknown> {
48
+ prepareUpdateBatch: (
49
+ updates: readonly { message?: TMessage }[],
50
+ ) => void;
51
+ getPreparedForwardingPosition: (
52
+ message: TelegramTextGroupMessage,
53
+ ) => TelegramForwardCommentBatchPosition | undefined;
54
+ prepareForwardedMessage: (
55
+ message: TelegramTextGroupMessage,
56
+ position: TelegramForwardCommentBatchPosition,
57
+ ) => void;
31
58
  queueMessage: (options: {
32
59
  message: TMessage;
33
60
  context: TContext;
@@ -43,6 +70,7 @@ export interface TelegramTextGroupController<TMessage, TContext = unknown> {
43
70
 
44
71
  export interface TelegramTextGroupControllerOptions {
45
72
  debounceMs?: number;
73
+ forwardCommentWaitMs?: number | false;
46
74
  minSplitLength?: number;
47
75
  setTimer?: (
48
76
  callback: () => void,
@@ -66,19 +94,44 @@ export interface TelegramGroupedInputClearerDeps {
66
94
  function extractTelegramTextGroupText(
67
95
  message: TelegramTextGroupMessage,
68
96
  ): string {
69
- return typeof message.text === "string" ? message.text : "";
97
+ return extractTelegramMessageText(message);
98
+ }
99
+
100
+ function isTelegramForwardedMessage(
101
+ message: TelegramTextGroupMessage,
102
+ ): boolean {
103
+ return (
104
+ message.forward_origin !== undefined ||
105
+ message.forward_from !== undefined ||
106
+ typeof message.forward_sender_name === "string"
107
+ );
70
108
  }
71
109
 
72
110
  function isTelegramTextGroupCommand(text: string): boolean {
73
111
  return text.trimStart().startsWith("/");
74
112
  }
75
113
 
114
+ function isTelegramTextGroupClearingCommand(text: string): boolean {
115
+ const command = text.trimStart().split(/\s+/, 1)[0]?.split("@", 1)[0];
116
+ return command === "/stop";
117
+ }
118
+
119
+ function getTelegramTextGroupMessageIdentity(
120
+ message: TelegramTextGroupMessage,
121
+ ): string {
122
+ const threadKey =
123
+ typeof message.message_thread_id === "number"
124
+ ? `thread:${message.message_thread_id}`
125
+ : "private";
126
+ return `${message.chat.id}:${threadKey}:${message.message_id}`;
127
+ }
128
+
76
129
  function getTelegramTextGroupKey(
77
130
  message: TelegramTextGroupMessage,
78
131
  ): string | undefined {
79
132
  if (message.media_group_id) return undefined;
80
133
  if (!message.from || message.from.is_bot) return undefined;
81
- if (typeof message.text !== "string") return undefined;
134
+ if (!extractTelegramTextGroupText(message)) return undefined;
82
135
  const threadKey =
83
136
  typeof message.message_thread_id === "number"
84
137
  ? `thread:${message.message_thread_id}`
@@ -127,12 +180,20 @@ export function queueTelegramTextGroupMessage<
127
180
  messages: TMessage[],
128
181
  ctx: TContext,
129
182
  ) => unknown | Promise<unknown>;
183
+ forceStart?: boolean;
184
+ dispatchImmediately?: boolean;
185
+ forwardCommentCandidate?: boolean;
186
+ delayMs?: number;
130
187
  }): boolean {
131
188
  const key = getTelegramTextGroupKey(options.message);
132
189
  if (!key) return false;
133
190
  const existing = options.groups.get(key);
191
+ if (existing?.messages.some(
192
+ (message) => message.message_id === options.message.message_id,
193
+ )) return true;
134
194
  if (
135
195
  !existing &&
196
+ !options.forceStart &&
136
197
  !canStartTelegramTextGroup(options.message, options.minSplitLength)
137
198
  )
138
199
  return false;
@@ -141,9 +202,8 @@ export function queueTelegramTextGroupMessage<
141
202
  const state = existing ?? { messages: [] };
142
203
  state.messages.push(options.message);
143
204
  state.context = options.context;
144
- const scheduleDispatch = (): void => {
145
- if (state.suspended) return;
146
- state.flushTimer = options.setTimer(() => {
205
+ state.forwardCommentCandidate = options.forwardCommentCandidate;
206
+ const dispatchQueued = (): void => {
147
207
  state.flushTimer = undefined;
148
208
  const queued = options.groups.get(key);
149
209
  if (!queued || queued.context === undefined) return;
@@ -151,7 +211,9 @@ export function queueTelegramTextGroupMessage<
151
211
  scheduleDispatch();
152
212
  return;
153
213
  }
154
- const dispatchedMessages = [...queued.messages];
214
+ const dispatchCount = queued.dispatchLimit ?? queued.messages.length;
215
+ queued.dispatchLimit = undefined;
216
+ const dispatchedMessages = queued.messages.slice(0, dispatchCount);
155
217
  const dispatchedIds = new Set(
156
218
  dispatchedMessages.map((message) => message.message_id),
157
219
  );
@@ -174,12 +236,17 @@ export function queueTelegramTextGroupMessage<
174
236
  if (!queued.flushTimer) scheduleDispatch();
175
237
  },
176
238
  );
177
- }, options.debounceMs);
239
+ };
240
+ const scheduleDispatch = (delayMs = options.debounceMs): void => {
241
+ if (state.suspended) return;
242
+ state.flushTimer = options.setTimer(dispatchQueued, delayMs);
178
243
  state.flushTimer.unref?.();
179
244
  };
180
245
  state.reschedule = scheduleDispatch;
181
246
  if (state.flushTimer) options.clearTimer(state.flushTimer);
182
- scheduleDispatch();
247
+ scheduleDispatch(
248
+ options.dispatchImmediately ? 0 : (options.delayMs ?? options.debounceMs),
249
+ );
183
250
  options.groups.set(key, state);
184
251
  return true;
185
252
  }
@@ -191,17 +258,102 @@ export function createTelegramTextGroupController<
191
258
  options: TelegramTextGroupControllerOptions = {},
192
259
  ): TelegramTextGroupController<TMessage, TContext> {
193
260
  const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
261
+ const plannedForwardCommentStarts = new Set<string>();
262
+ const plannedForwardCommentEnds = new Set<string>();
194
263
  const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
195
264
  const minSplitLength =
196
265
  options.minSplitLength ?? TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH;
266
+ const forwardCommentWaitMs =
267
+ options.forwardCommentWaitMs === undefined
268
+ ? debounceMs
269
+ : options.forwardCommentWaitMs;
197
270
  const setTimer =
198
271
  options.setTimer ??
199
- ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
200
- setTimeout(callback, ms));
201
- const clearTimer = options.clearTimer ?? clearTimeout;
272
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> => {
273
+ const controller = new AbortController();
274
+ void waitForTimeout(ms, undefined, {
275
+ signal: controller.signal,
276
+ }).then(callback, () => undefined);
277
+ return controller as unknown as ReturnType<typeof setTimeout>;
278
+ });
279
+ const clearTimer =
280
+ options.clearTimer ??
281
+ (options.setTimer
282
+ ? clearTimeout
283
+ : (timer: ReturnType<typeof setTimeout>): void => {
284
+ (timer as unknown as AbortController).abort();
285
+ });
202
286
  return {
203
- queueMessage: ({ message, context, dispatchMessages }) =>
204
- queueTelegramTextGroupMessage({
287
+ prepareUpdateBatch(updates) {
288
+ for (let index = 0; index + 1 < updates.length; index += 1) {
289
+ const comment = updates[index]?.message;
290
+ const forwarded = updates[index + 1]?.message;
291
+ if (!comment || !forwarded) continue;
292
+ const commentText = extractTelegramTextGroupText(comment);
293
+ const commentKey = getTelegramTextGroupKey(comment);
294
+ const forwardedKey = getTelegramTextGroupKey(forwarded);
295
+ if (
296
+ !commentKey ||
297
+ commentKey !== forwardedKey ||
298
+ !commentText ||
299
+ isTelegramTextGroupCommand(commentText) ||
300
+ isTelegramForwardedMessage(comment) ||
301
+ !isTelegramForwardedMessage(forwarded) ||
302
+ forwarded.message_id <= comment.message_id ||
303
+ forwarded.message_id >
304
+ comment.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP
305
+ ) {
306
+ continue;
307
+ }
308
+ plannedForwardCommentStarts.add(
309
+ getTelegramTextGroupMessageIdentity(comment),
310
+ );
311
+ plannedForwardCommentEnds.add(
312
+ getTelegramTextGroupMessageIdentity(forwarded),
313
+ );
314
+ }
315
+ },
316
+ getPreparedForwardingPosition(message) {
317
+ const identity = getTelegramTextGroupMessageIdentity(message);
318
+ if (plannedForwardCommentStarts.has(identity)) return "comment";
319
+ if (plannedForwardCommentEnds.has(identity)) return "forward";
320
+ return undefined;
321
+ },
322
+ prepareForwardedMessage(message, position) {
323
+ const identity = getTelegramTextGroupMessageIdentity(message);
324
+ if (position === "comment") plannedForwardCommentStarts.add(identity);
325
+ else plannedForwardCommentEnds.add(identity);
326
+ },
327
+ queueMessage: ({ message, context, dispatchMessages }) => {
328
+ const identity = getTelegramTextGroupMessageIdentity(message);
329
+ const key = getTelegramTextGroupKey(message);
330
+ const plannedStart = plannedForwardCommentStarts.delete(identity);
331
+ const forwarded = isTelegramForwardedMessage(message);
332
+ const existing = key ? groups.get(key) : undefined;
333
+ const text = extractTelegramTextGroupText(message);
334
+ if (existing && isTelegramTextGroupClearingCommand(text)) {
335
+ if (existing.flushTimer) clearTimer(existing.flushTimer);
336
+ groups.delete(key!);
337
+ }
338
+ const separateFromCandidate =
339
+ !!existing?.forwardCommentCandidate &&
340
+ !forwarded &&
341
+ !isTelegramTextGroupCommand(text);
342
+ if (separateFromCandidate) {
343
+ existing.dispatchLimit = existing.messages.length;
344
+ }
345
+ const forceStart =
346
+ plannedStart ||
347
+ (forwardCommentWaitMs !== false &&
348
+ !forwarded &&
349
+ !!key &&
350
+ typeof message.text === "string" &&
351
+ !isTelegramTextGroupCommand(extractTelegramTextGroupText(message)));
352
+ const dispatchImmediately =
353
+ separateFromCandidate ||
354
+ plannedForwardCommentEnds.delete(identity) ||
355
+ (forwarded && !!key && groups.has(key));
356
+ return queueTelegramTextGroupMessage({
205
357
  message,
206
358
  context,
207
359
  groups,
@@ -210,7 +362,20 @@ export function createTelegramTextGroupController<
210
362
  setTimer,
211
363
  clearTimer,
212
364
  dispatchMessages,
213
- }),
365
+ forceStart,
366
+ dispatchImmediately,
367
+ forwardCommentCandidate:
368
+ forceStart &&
369
+ !forwarded &&
370
+ !canStartTelegramTextGroup(message, minSplitLength),
371
+ delayMs:
372
+ forceStart && !canStartTelegramTextGroup(message, minSplitLength)
373
+ ? forwardCommentWaitMs === false
374
+ ? undefined
375
+ : forwardCommentWaitMs
376
+ : undefined,
377
+ });
378
+ },
214
379
  suspend: () => {
215
380
  for (const state of groups.values()) {
216
381
  state.suspended = true;
@@ -230,6 +395,8 @@ export function createTelegramTextGroupController<
230
395
  if (state.flushTimer) clearTimer(state.flushTimer);
231
396
  }
232
397
  groups.clear();
398
+ plannedForwardCommentStarts.clear();
399
+ plannedForwardCommentEnds.clear();
233
400
  },
234
401
  };
235
402
  }
@@ -10,7 +10,13 @@ import type { TelegramTarget } from "./target.ts";
10
10
  type ThreadTarget = TelegramTarget & { threadId: number };
11
11
 
12
12
  type ThreadRecordStatus =
13
- "active" | "offline" | "stale" | "pending" | "starting" | "failed";
13
+ | "active"
14
+ | "offline"
15
+ | "stale"
16
+ | "pending"
17
+ | "starting"
18
+ | "probe-required"
19
+ | "failed";
14
20
 
15
21
  export interface ThreadReconciliationRecord {
16
22
  target: ThreadTarget;
@@ -56,11 +62,6 @@ export interface TelegramReservedThreadMessageObservation {
56
62
  leaderEpoch?: number | string;
57
63
  }
58
64
 
59
- export interface ThreadReservationProbeResult {
60
- target: ThreadTarget;
61
- stale: boolean;
62
- }
63
-
64
65
  export interface ReplacedInstanceBindingInput {
65
66
  instanceId: string;
66
67
  replacementTarget: ThreadTarget;
@@ -105,14 +106,6 @@ export type ThreadReconciliationAction =
105
106
  instanceId?: string;
106
107
  leaderEpoch?: number | string;
107
108
  }
108
- | {
109
- kind: "close-delete-pruned-follower-topic";
110
- target: TelegramTarget & { threadId: number };
111
- reason: "pruned-follower";
112
- instanceId?: string;
113
- messageId?: number;
114
- leaderEpoch?: number | string;
115
- }
116
109
  | {
117
110
  kind: "close-delete-replaced-follower-topic";
118
111
  target: TelegramTarget & { threadId: number };
@@ -144,11 +137,6 @@ export type ThreadReconciliationAction =
144
137
  pendingProvisionId: string;
145
138
  instanceId?: string;
146
139
  leaderEpoch?: number | string;
147
- }
148
- | {
149
- kind: "remove-reservation";
150
- target: TelegramTarget & { threadId: number };
151
- reason: "reservation-probe-stale";
152
140
  };
153
141
 
154
142
  export type ThreadReconciliationPhase =
@@ -182,6 +170,7 @@ export interface ThreadReconciliationPlan {
182
170
 
183
171
  export interface ThreadReconciliationApplyResult {
184
172
  changed: boolean;
173
+ incompleteActions?: ThreadReconciliationAction[];
185
174
  }
186
175
 
187
176
  export interface ThreadReconciliationApplyPorts {
@@ -196,7 +185,6 @@ export interface ThreadReconciliationApplyPorts {
196
185
  lastSyncError?: string,
197
186
  ) => boolean;
198
187
  persist?: () => Promise<void>;
199
- removeReservationByTarget?: (target: ThreadTarget) => boolean;
200
188
  removePendingProvisionById?: (id: string) => boolean;
201
189
  getCurrentLeaderEpoch?: () => number | string | undefined;
202
190
  recordRuntimeEvent?: (
@@ -216,8 +204,6 @@ export interface ThreadReconciliationInput {
216
204
  unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
217
205
  reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
218
206
  proactiveReservationCleanup?: boolean;
219
- reservationProbeResults?: readonly ThreadReservationProbeResult[];
220
- prunedFollowerInstanceIds?: readonly string[];
221
207
  replacedBindings?: readonly ReplacedInstanceBindingInput[];
222
208
  previousLeaderCleanup?: PreviousLeaderCleanupInput;
223
209
  previousState?: ThreadReconciliationMachineState;
@@ -302,7 +288,6 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
302
288
  action.kind === "close-delete-unbound-topic" ||
303
289
  action.kind === "close-delete-reserved-topic" ||
304
290
  action.kind === "close-stale-replaced-topic" ||
305
- action.kind === "close-delete-pruned-follower-topic" ||
306
291
  action.kind === "close-delete-replaced-follower-topic" ||
307
292
  action.kind === "close-delete-previous-leader-topic" ||
308
293
  action.kind === "close-delete-disconnected-instance-topic" ||
@@ -311,11 +296,7 @@ function isCleanupAction(action: ThreadReconciliationAction): boolean {
311
296
  }
312
297
 
313
298
  function isSyncAction(action: ThreadReconciliationAction): boolean {
314
- return (
315
- action.kind === "mark-topic-active" ||
316
- action.kind === "mark-topic-stale" ||
317
- action.kind === "remove-reservation"
318
- );
299
+ return action.kind === "mark-topic-active" || action.kind === "mark-topic-stale";
319
300
  }
320
301
 
321
302
  function createThreadReconciliationMachineState(
@@ -376,7 +357,7 @@ function getActionLeaderEpoch(
376
357
  return "leaderEpoch" in action ? action.leaderEpoch : undefined;
377
358
  }
378
359
 
379
- function isTelegramTopicTargetGoneError(error: unknown): boolean {
360
+ function isTelegramTopicTargetDeletedOrMissingError(error: unknown): boolean {
380
361
  if (!(error instanceof Error)) return false;
381
362
  const message = error.message.toLowerCase();
382
363
  return (
@@ -384,7 +365,15 @@ function isTelegramTopicTargetGoneError(error: unknown): boolean {
384
365
  message.includes("message thread not found") ||
385
366
  message.includes("thread not found") ||
386
367
  message.includes("topic not found") ||
387
- message.includes("topic deleted") ||
368
+ message.includes("topic deleted")
369
+ );
370
+ }
371
+
372
+ function isTelegramTopicTargetGoneError(error: unknown): boolean {
373
+ if (isTelegramTopicTargetDeletedOrMissingError(error)) return true;
374
+ if (!(error instanceof Error)) return false;
375
+ const message = error.message.toLowerCase();
376
+ return (
388
377
  message.includes("topic closed") ||
389
378
  message.includes("thread closed") ||
390
379
  message.includes("forum topic closed") ||
@@ -503,12 +492,8 @@ export async function applyThreadReconciliationPlan(
503
492
  ): Promise<ThreadReconciliationApplyResult> {
504
493
  let shouldPersist = false;
505
494
  const persistFences: ThreadReconciliationAction[] = [];
495
+ const incompleteActions: ThreadReconciliationAction[] = [];
506
496
  for (const action of plan.actions) {
507
- if (action.kind === "remove-reservation") {
508
- shouldPersist =
509
- ports.removeReservationByTarget?.(action.target) || shouldPersist;
510
- continue;
511
- }
512
497
  if (action.kind === "mark-topic-active") {
513
498
  shouldPersist =
514
499
  ports.markActiveByTarget?.(action.target) || shouldPersist;
@@ -521,7 +506,10 @@ export async function applyThreadReconciliationPlan(
521
506
  continue;
522
507
  }
523
508
  if (action.kind === "close-stale-replaced-topic") {
524
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
509
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
510
+ incompleteActions.push(action);
511
+ continue;
512
+ }
525
513
  if (!ports.callApi) {
526
514
  ports.recordRuntimeEvent?.(
527
515
  "telegram",
@@ -533,40 +521,49 @@ export async function applyThreadReconciliationPlan(
533
521
  threadId: action.target.threadId,
534
522
  },
535
523
  );
524
+ incompleteActions.push(action);
536
525
  continue;
537
526
  }
527
+ let closeConfirmed = false;
538
528
  try {
539
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
540
529
  await ports.callApi("closeForumTopic", {
541
530
  chat_id: action.target.chatId,
542
531
  message_thread_id: action.target.threadId,
543
532
  });
544
- if (!shouldSkipForStaleLeaderEpoch(action, ports)) {
545
- const changed =
546
- ports.markStaleByTarget?.(action.target, "closed") ?? false;
547
- if (changed) persistFences.push(action);
548
- shouldPersist = changed || shouldPersist;
549
- }
533
+ closeConfirmed = true;
550
534
  } catch (error) {
551
- ports.recordRuntimeEvent?.("telegram", error, {
552
- phase: `thread-reconciler-${action.reason}-closeForumTopic`,
553
- instanceId: action.instanceId,
554
- chatId: action.target.chatId,
555
- threadId: action.target.threadId,
556
- });
535
+ closeConfirmed = isTelegramTopicTargetGoneError(error);
536
+ if (!closeConfirmed) {
537
+ ports.recordRuntimeEvent?.("telegram", error, {
538
+ phase: `thread-reconciler-${action.reason}-closeForumTopic`,
539
+ instanceId: action.instanceId,
540
+ chatId: action.target.chatId,
541
+ threadId: action.target.threadId,
542
+ });
543
+ }
557
544
  }
545
+ if (shouldSkipForStaleLeaderEpoch(action, ports) || !closeConfirmed) {
546
+ incompleteActions.push(action);
547
+ continue;
548
+ }
549
+ const changed =
550
+ ports.markStaleByTarget?.(action.target, "closed") ?? false;
551
+ if (changed) persistFences.push(action);
552
+ shouldPersist = changed || shouldPersist;
558
553
  continue;
559
554
  }
560
555
  if (
561
556
  action.kind === "close-delete-unbound-topic" ||
562
557
  action.kind === "close-delete-reserved-topic" ||
563
- action.kind === "close-delete-pruned-follower-topic" ||
564
558
  action.kind === "close-delete-replaced-follower-topic" ||
565
559
  action.kind === "close-delete-previous-leader-topic" ||
566
560
  action.kind === "close-delete-disconnected-instance-topic" ||
567
561
  action.kind === "close-delete-expired-pending-provision-topic"
568
562
  ) {
569
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
563
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
564
+ incompleteActions.push(action);
565
+ continue;
566
+ }
570
567
  if (!ports.callApi) {
571
568
  ports.recordRuntimeEvent?.(
572
569
  "telegram",
@@ -578,6 +575,7 @@ export async function applyThreadReconciliationPlan(
578
575
  threadId: action.target.threadId,
579
576
  },
580
577
  );
578
+ incompleteActions.push(action);
581
579
  continue;
582
580
  }
583
581
  let deleteConfirmed = false;
@@ -590,7 +588,11 @@ export async function applyThreadReconciliationPlan(
590
588
  });
591
589
  if (method === "deleteForumTopic") deleteConfirmed = true;
592
590
  } catch (error) {
593
- if (isTelegramTopicTargetGoneError(error)) {
591
+ const confirmsMethodOutcome =
592
+ method === "deleteForumTopic"
593
+ ? isTelegramTopicTargetDeletedOrMissingError(error)
594
+ : isTelegramTopicTargetGoneError(error);
595
+ if (confirmsMethodOutcome) {
594
596
  if (method === "deleteForumTopic") deleteConfirmed = true;
595
597
  } else {
596
598
  ports.recordRuntimeEvent?.("telegram", error, {
@@ -601,7 +603,10 @@ export async function applyThreadReconciliationPlan(
601
603
  }
602
604
  }
603
605
  }
604
- if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
606
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) {
607
+ incompleteActions.push(action);
608
+ continue;
609
+ }
605
610
  if (!deleteConfirmed) {
606
611
  ports.recordRuntimeEvent?.(
607
612
  "telegram",
@@ -615,12 +620,12 @@ export async function applyThreadReconciliationPlan(
615
620
  ...("messageId" in action ? { messageId: action.messageId } : {}),
616
621
  },
617
622
  );
623
+ incompleteActions.push(action);
618
624
  continue;
619
625
  }
620
626
  if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
621
627
  if (
622
628
  action.kind !== "close-delete-previous-leader-topic" &&
623
- action.kind !== "close-delete-pruned-follower-topic" &&
624
629
  action.kind !== "close-delete-replaced-follower-topic" &&
625
630
  action.kind !== "close-delete-disconnected-instance-topic" &&
626
631
  action.kind !== "close-delete-expired-pending-provision-topic"
@@ -636,9 +641,7 @@ export async function applyThreadReconciliationPlan(
636
641
  ? "Unbound Telegram topic deleted"
637
642
  : action.kind === "close-delete-reserved-topic"
638
643
  ? "Reserved Telegram topic deleted"
639
- : action.kind === "close-delete-pruned-follower-topic"
640
- ? "Pruned follower Telegram topic deleted"
641
- : action.kind === "close-delete-replaced-follower-topic"
644
+ : action.kind === "close-delete-replaced-follower-topic"
642
645
  ? "Replaced follower Telegram topic deleted"
643
646
  : action.kind === "close-delete-previous-leader-topic"
644
647
  ? "Previous leader Telegram topic deleted"
@@ -651,9 +654,7 @@ export async function applyThreadReconciliationPlan(
651
654
  ? "thread-reconciler-unbound-topic-delete"
652
655
  : action.kind === "close-delete-reserved-topic"
653
656
  ? "thread-reconciler-reserved-topic-delete"
654
- : action.kind === "close-delete-pruned-follower-topic"
655
- ? "thread-reconciler-pruned-follower-topic-delete"
656
- : action.kind === "close-delete-replaced-follower-topic"
657
+ : action.kind === "close-delete-replaced-follower-topic"
657
658
  ? "thread-reconciler-replaced-follower-topic-delete"
658
659
  : action.kind === "close-delete-previous-leader-topic"
659
660
  ? "thread-reconciler-previous-leader-topic-delete"
@@ -682,12 +683,18 @@ export async function applyThreadReconciliationPlan(
682
683
  if (shouldPersist) {
683
684
  for (const action of persistFences) {
684
685
  if (shouldSkipForStaleLeaderEpoch(action, ports)) {
685
- return { changed: true };
686
+ return {
687
+ changed: true,
688
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
689
+ };
686
690
  }
687
691
  }
688
692
  await ports.persist?.();
689
693
  }
690
- return { changed: shouldPersist };
694
+ return {
695
+ changed: shouldPersist,
696
+ ...(incompleteActions.length > 0 ? { incompleteActions } : {}),
697
+ };
691
698
  }
692
699
 
693
700
  export function planThreadReconciliation(
@@ -702,11 +709,6 @@ export function planThreadReconciliation(
702
709
  .filter(isCurrentRecord)
703
710
  .map((record) => targetKey(record.target)),
704
711
  );
705
- const allReservationTargets = new Set(
706
- (input.reservations ?? []).map((reservation) =>
707
- targetKey(reservation.target),
708
- ),
709
- );
710
712
  const reservedTargets = new Set(
711
713
  (input.reservations ?? [])
712
714
  .filter((reservation) => isReservationAlive(reservation, input.nowMs))
@@ -782,22 +784,6 @@ export function planThreadReconciliation(
782
784
  }
783
785
  }
784
786
 
785
- for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
786
- for (const record of input.records) {
787
- if (record.instanceId !== prunedInstanceId) continue;
788
- if (!isActiveOrStartingRecord(record)) continue;
789
- actions.push({
790
- kind: "close-delete-pruned-follower-topic",
791
- target: record.target,
792
- reason: "pruned-follower",
793
- instanceId: prunedInstanceId,
794
- ...(input.currentLeaderEpoch !== undefined
795
- ? { leaderEpoch: input.currentLeaderEpoch }
796
- : {}),
797
- });
798
- }
799
- }
800
-
801
787
  for (const replacement of input.replacedBindings ?? []) {
802
788
  for (const record of input.records) {
803
789
  if (record.instanceId !== replacement.instanceId) continue;
@@ -856,16 +842,6 @@ export function planThreadReconciliation(
856
842
  }
857
843
  }
858
844
 
859
- for (const probe of input.reservationProbeResults ?? []) {
860
- if (!probe.stale) continue;
861
- if (!allReservationTargets.has(targetKey(probe.target))) continue;
862
- actions.push({
863
- kind: "remove-reservation",
864
- target: probe.target,
865
- reason: "reservation-probe-stale",
866
- });
867
- }
868
-
869
845
  for (const message of input.reservedMessages ?? []) {
870
846
  const key = targetKey(message.target);
871
847
  if (!reservedTargets.has(key)) continue;