@llblab/pi-telegram 0.17.4 → 0.18.0

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.
Files changed (62) hide show
  1. package/AGENTS.md +67 -32
  2. package/BACKLOG.md +59 -14
  3. package/CHANGELOG.md +40 -15
  4. package/README.md +63 -35
  5. package/docs/README.md +3 -1
  6. package/docs/architecture.md +55 -23
  7. package/docs/callback-namespaces.md +1 -1
  8. package/docs/inbound.md +1 -1
  9. package/docs/locks.md +0 -2
  10. package/docs/multi-instance-bus.md +483 -0
  11. package/docs/outbound.md +4 -3
  12. package/docs/public-api.md +12 -10
  13. package/docs/sections.md +2 -2
  14. package/docs/ui-style.md +76 -0
  15. package/index.ts +789 -32
  16. package/lib/bindings.ts +68 -12
  17. package/lib/bus-api.ts +314 -0
  18. package/lib/bus-follower.ts +853 -0
  19. package/lib/bus-leader.ts +915 -0
  20. package/lib/bus.ts +866 -0
  21. package/lib/command-templates.ts +9 -11
  22. package/lib/commands.ts +133 -47
  23. package/lib/config.ts +53 -5
  24. package/lib/lifecycle.ts +23 -7
  25. package/lib/locks.ts +230 -66
  26. package/lib/media.ts +30 -2
  27. package/lib/menu-model.ts +48 -17
  28. package/lib/menu-queue.ts +51 -20
  29. package/lib/menu-settings.ts +9 -5
  30. package/lib/menu-status.ts +3 -0
  31. package/lib/menu-thinking.ts +3 -0
  32. package/lib/menu.ts +67 -26
  33. package/lib/outbound-attachments.ts +102 -17
  34. package/lib/outbound-buttons.ts +6 -2
  35. package/lib/outbound-voice.ts +31 -11
  36. package/lib/outbound.ts +6 -4
  37. package/lib/ownership.ts +119 -0
  38. package/lib/pi.ts +26 -3
  39. package/lib/polling.ts +477 -7
  40. package/lib/preview.ts +141 -88
  41. package/lib/prompt-templates.ts +3 -3
  42. package/lib/prompts.ts +80 -30
  43. package/lib/queue.ts +193 -91
  44. package/lib/rendering.ts +0 -25
  45. package/lib/replies.ts +187 -55
  46. package/lib/routing.ts +1673 -9
  47. package/lib/runtime-log.ts +123 -0
  48. package/lib/runtime.ts +84 -12
  49. package/lib/sections.ts +28 -21
  50. package/lib/setup.ts +1 -1
  51. package/lib/status.ts +532 -9
  52. package/lib/sync.ts +618 -0
  53. package/lib/target.ts +49 -0
  54. package/lib/telegram-api.ts +405 -40
  55. package/lib/text-groups.ts +5 -1
  56. package/lib/thread-reconciler.ts +915 -0
  57. package/lib/threads.ts +2205 -0
  58. package/lib/turns.ts +48 -3
  59. package/lib/updates.ts +355 -32
  60. package/package.json +24 -2
  61. package/screenshot.png +0 -0
  62. package/docs/telegram-bot-api-rich-messages.md +0 -890
@@ -0,0 +1,915 @@
1
+ /**
2
+ * Telegram thread lifecycle reconciler
3
+ * Zones: telegram, multi-instance bus, synchronization control plane
4
+ * Owns pure planning for Telegram thread/tab lifecycle reconciliation and destructive topic cleanup authority
5
+ * Excludes live Telegram API calls, inbound routing, menu rendering, and direct thread-store persistence
6
+ */
7
+
8
+ import type { TelegramTarget } from "./target.ts";
9
+
10
+ type ThreadTarget = TelegramTarget & { threadId: number };
11
+
12
+ type ThreadRecordStatus =
13
+ | "active"
14
+ | "offline"
15
+ | "stale"
16
+ | "pending"
17
+ | "starting"
18
+ | "failed";
19
+
20
+ export interface ThreadReconciliationRecord {
21
+ target: ThreadTarget;
22
+ status: ThreadRecordStatus;
23
+ instanceId?: string;
24
+ profileKey?: string;
25
+ ownerKind?: "leader" | "manual-follower" | "pending-topic" | "legacy";
26
+ }
27
+
28
+ export interface ThreadReconciliationReservation {
29
+ target: ThreadTarget;
30
+ expiresAtMs?: number;
31
+ }
32
+
33
+ export interface ThreadReconciliationObservation {
34
+ target: ThreadTarget;
35
+ syncStatus: "open" | "closed" | "deleted" | "unknown";
36
+ observedAtMs: number;
37
+ }
38
+
39
+ export interface TelegramThreadPendingProvision {
40
+ id: string;
41
+ owner: "leader" | "manual-follower";
42
+ instanceId: string;
43
+ slot?: string;
44
+ target?: TelegramTarget & { threadId: number };
45
+ startedAtMs: number;
46
+ expiresAtMs?: number;
47
+ leaderEpoch?: number | string;
48
+ }
49
+
50
+ export interface TelegramUnboundThreadMessageObservation {
51
+ target: TelegramTarget & { threadId: number };
52
+ observedAtMs: number;
53
+ messageId?: number;
54
+ leaderEpoch?: number | string;
55
+ }
56
+
57
+ export interface TelegramReservedThreadMessageObservation {
58
+ target: TelegramTarget & { threadId: number };
59
+ observedAtMs: number;
60
+ messageId?: number;
61
+ leaderEpoch?: number | string;
62
+ }
63
+
64
+ export interface ThreadReservationProbeResult {
65
+ target: ThreadTarget;
66
+ stale: boolean;
67
+ }
68
+
69
+ export interface ReplacedInstanceBindingInput {
70
+ instanceId: string;
71
+ replacementTarget: ThreadTarget;
72
+ }
73
+
74
+ export interface PreviousLeaderCleanupInput {
75
+ currentInstanceId: string;
76
+ }
77
+
78
+ export type ThreadReconciliationAction =
79
+ | {
80
+ kind: "mark-topic-active";
81
+ target: TelegramTarget & { threadId: number };
82
+ reason: "observed-open";
83
+ }
84
+ | {
85
+ kind: "mark-topic-stale";
86
+ target: TelegramTarget & { threadId: number };
87
+ syncStatus: "closed" | "deleted";
88
+ reason: "observed-closed" | "observed-deleted";
89
+ }
90
+ | {
91
+ kind: "close-delete-unbound-topic";
92
+ target: TelegramTarget & { threadId: number };
93
+ observedAtMs: number;
94
+ messageId?: number;
95
+ reason: "unbound-user-message";
96
+ leaderEpoch?: number | string;
97
+ }
98
+ | {
99
+ kind: "close-delete-reserved-topic";
100
+ target: TelegramTarget & { threadId: number };
101
+ observedAtMs: number;
102
+ messageId?: number;
103
+ reason: "reserved-user-message" | "startup-reservation";
104
+ leaderEpoch?: number | string;
105
+ }
106
+ | {
107
+ kind: "close-stale-replaced-topic";
108
+ target: TelegramTarget & { threadId: number };
109
+ reason: "replaced-instance-binding";
110
+ instanceId?: string;
111
+ leaderEpoch?: number | string;
112
+ }
113
+ | {
114
+ kind: "close-delete-pruned-follower-topic";
115
+ target: TelegramTarget & { threadId: number };
116
+ reason: "pruned-follower";
117
+ instanceId?: string;
118
+ messageId?: number;
119
+ leaderEpoch?: number | string;
120
+ }
121
+ | {
122
+ kind: "close-delete-replaced-follower-topic";
123
+ target: TelegramTarget & { threadId: number };
124
+ reason: "replaced-follower";
125
+ instanceId?: string;
126
+ messageId?: number;
127
+ leaderEpoch?: number | string;
128
+ }
129
+ | {
130
+ kind: "close-delete-previous-leader-topic";
131
+ target: TelegramTarget & { threadId: number };
132
+ reason: "previous-leader";
133
+ instanceId?: string;
134
+ messageId?: number;
135
+ leaderEpoch?: number | string;
136
+ }
137
+ | {
138
+ kind: "close-delete-disconnected-instance-topic";
139
+ target: TelegramTarget & { threadId: number };
140
+ reason: "manual-disconnect";
141
+ instanceId?: string;
142
+ messageId?: number;
143
+ leaderEpoch?: number | string;
144
+ }
145
+ | {
146
+ kind: "close-delete-expired-pending-provision-topic";
147
+ target: TelegramTarget & { threadId: number };
148
+ reason: "expired-pending-provision";
149
+ pendingProvisionId: string;
150
+ instanceId?: string;
151
+ leaderEpoch?: number | string;
152
+ }
153
+ | {
154
+ kind: "remove-reservation";
155
+ target: TelegramTarget & { threadId: number };
156
+ reason: "reservation-probe-stale";
157
+ };
158
+
159
+ export type ThreadReconciliationPhase =
160
+ | "stable"
161
+ | "provisioning"
162
+ | "sync-required"
163
+ | "cleanup-required";
164
+
165
+ export type ThreadReconciliationEvent =
166
+ | "settled"
167
+ | "pending-provision"
168
+ | "sync-required"
169
+ | "cleanup-required";
170
+
171
+ export interface ThreadReconciliationMachineState {
172
+ phase: ThreadReconciliationPhase;
173
+ event: ThreadReconciliationEvent;
174
+ atMs: number;
175
+ leaderEpoch?: number | string;
176
+ pendingProvisionCount: number;
177
+ syncActionCount: number;
178
+ cleanupActionCount: number;
179
+ }
180
+
181
+ export interface ThreadReconciliationTransition {
182
+ from: ThreadReconciliationPhase;
183
+ to: ThreadReconciliationPhase;
184
+ event: ThreadReconciliationEvent;
185
+ atMs: number;
186
+ }
187
+
188
+ export interface ThreadReconciliationPlan {
189
+ actions: ThreadReconciliationAction[];
190
+ state?: ThreadReconciliationMachineState;
191
+ transition?: ThreadReconciliationTransition;
192
+ }
193
+
194
+ export interface ThreadReconciliationApplyResult {
195
+ changed: boolean;
196
+ }
197
+
198
+ export interface ThreadReconciliationApplyPorts {
199
+ callApi?: <TResponse>(
200
+ method: string,
201
+ body: Record<string, unknown>,
202
+ ) => Promise<TResponse>;
203
+ markActiveByTarget?: (target: ThreadTarget) => boolean;
204
+ markStaleByTarget?: (
205
+ target: ThreadTarget,
206
+ syncStatus?: "closed" | "deleted",
207
+ lastSyncError?: string,
208
+ ) => boolean;
209
+ persist?: () => Promise<void>;
210
+ removeReservationByTarget?: (target: ThreadTarget) => boolean;
211
+ removePendingProvisionById?: (id: string) => boolean;
212
+ getCurrentLeaderEpoch?: () => number | string | undefined;
213
+ recordRuntimeEvent?: (
214
+ category: string,
215
+ error: unknown,
216
+ details?: Record<string, unknown>,
217
+ ) => void;
218
+ }
219
+
220
+ export interface ThreadReconciliationInput {
221
+ nowMs: number;
222
+ currentLeaderEpoch?: number | string;
223
+ records: readonly ThreadReconciliationRecord[];
224
+ reservations?: readonly ThreadReconciliationReservation[];
225
+ observations?: readonly ThreadReconciliationObservation[];
226
+ pendingProvisions?: readonly TelegramThreadPendingProvision[];
227
+ unboundMessages?: readonly TelegramUnboundThreadMessageObservation[];
228
+ reservedMessages?: readonly TelegramReservedThreadMessageObservation[];
229
+ proactiveReservationCleanup?: boolean;
230
+ reservationProbeResults?: readonly ThreadReservationProbeResult[];
231
+ prunedFollowerInstanceIds?: readonly string[];
232
+ replacedBindings?: readonly ReplacedInstanceBindingInput[];
233
+ previousLeaderCleanup?: PreviousLeaderCleanupInput;
234
+ previousState?: ThreadReconciliationMachineState;
235
+ freshCreationGraceMs?: number;
236
+ }
237
+
238
+ const DEFAULT_FRESH_CREATION_GRACE_MS = 30_000;
239
+
240
+ function targetKey(target: ThreadTarget): string {
241
+ return `${target.chatId}:${target.threadId}`;
242
+ }
243
+
244
+ function isCurrentRecord(record: ThreadReconciliationRecord): boolean {
245
+ return (
246
+ record.status === "active" ||
247
+ record.status === "starting" ||
248
+ record.status === "pending"
249
+ );
250
+ }
251
+
252
+ function isActiveOrStartingRecord(record: ThreadReconciliationRecord): boolean {
253
+ return record.status === "active" || record.status === "starting";
254
+ }
255
+
256
+ function isPreviousLeaderRecord(record: ThreadReconciliationRecord): boolean {
257
+ return (
258
+ record.ownerKind === "leader" ||
259
+ (!record.ownerKind &&
260
+ (record.profileKey?.startsWith("cwd:") === true ||
261
+ record.profileKey?.startsWith("leader:") === true))
262
+ );
263
+ }
264
+
265
+ function isReservationAlive(
266
+ reservation: ThreadReconciliationReservation,
267
+ nowMs: number,
268
+ ): boolean {
269
+ return (
270
+ reservation.expiresAtMs === undefined || reservation.expiresAtMs > nowMs
271
+ );
272
+ }
273
+
274
+ function isPendingProvisionExpired(
275
+ provision: TelegramThreadPendingProvision,
276
+ nowMs: number,
277
+ ): boolean {
278
+ return provision.expiresAtMs !== undefined && provision.expiresAtMs <= nowMs;
279
+ }
280
+
281
+ function isPendingProvisionAlive(
282
+ provision: TelegramThreadPendingProvision,
283
+ nowMs: number,
284
+ currentLeaderEpoch: number | string | undefined,
285
+ ): boolean {
286
+ if (isPendingProvisionExpired(provision, nowMs)) {
287
+ return false;
288
+ }
289
+ if (
290
+ currentLeaderEpoch !== undefined &&
291
+ provision.leaderEpoch !== undefined &&
292
+ provision.leaderEpoch !== currentLeaderEpoch
293
+ ) {
294
+ return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ function isRecentOpenObservation(
300
+ observation: ThreadReconciliationObservation,
301
+ nowMs: number,
302
+ graceMs: number,
303
+ ): boolean {
304
+ return (
305
+ observation.syncStatus === "open" &&
306
+ nowMs - observation.observedAtMs >= 0 &&
307
+ nowMs - observation.observedAtMs <= graceMs
308
+ );
309
+ }
310
+
311
+ function isCleanupAction(action: ThreadReconciliationAction): boolean {
312
+ return (
313
+ action.kind === "close-delete-unbound-topic" ||
314
+ action.kind === "close-delete-reserved-topic" ||
315
+ action.kind === "close-stale-replaced-topic" ||
316
+ action.kind === "close-delete-pruned-follower-topic" ||
317
+ action.kind === "close-delete-replaced-follower-topic" ||
318
+ action.kind === "close-delete-previous-leader-topic" ||
319
+ action.kind === "close-delete-disconnected-instance-topic" ||
320
+ action.kind === "close-delete-expired-pending-provision-topic"
321
+ );
322
+ }
323
+
324
+ function isSyncAction(action: ThreadReconciliationAction): boolean {
325
+ return (
326
+ action.kind === "mark-topic-active" ||
327
+ action.kind === "mark-topic-stale" ||
328
+ action.kind === "remove-reservation"
329
+ );
330
+ }
331
+
332
+ function createThreadReconciliationMachineState(
333
+ input: ThreadReconciliationInput,
334
+ actions: readonly ThreadReconciliationAction[],
335
+ ): ThreadReconciliationMachineState {
336
+ const pendingProvisionCount = (input.pendingProvisions ?? []).filter(
337
+ (provision) =>
338
+ isPendingProvisionAlive(provision, input.nowMs, input.currentLeaderEpoch),
339
+ ).length;
340
+ const cleanupActionCount = actions.filter(isCleanupAction).length;
341
+ const syncActionCount = actions.filter(isSyncAction).length;
342
+ const phase: ThreadReconciliationPhase =
343
+ cleanupActionCount > 0
344
+ ? "cleanup-required"
345
+ : pendingProvisionCount > 0
346
+ ? "provisioning"
347
+ : syncActionCount > 0
348
+ ? "sync-required"
349
+ : "stable";
350
+ const event: ThreadReconciliationEvent =
351
+ phase === "cleanup-required"
352
+ ? "cleanup-required"
353
+ : phase === "provisioning"
354
+ ? "pending-provision"
355
+ : phase === "sync-required"
356
+ ? "sync-required"
357
+ : "settled";
358
+ return {
359
+ phase,
360
+ event,
361
+ atMs: input.nowMs,
362
+ ...(input.currentLeaderEpoch !== undefined
363
+ ? { leaderEpoch: input.currentLeaderEpoch }
364
+ : {}),
365
+ pendingProvisionCount,
366
+ syncActionCount,
367
+ cleanupActionCount,
368
+ };
369
+ }
370
+
371
+ function createThreadReconciliationTransition(
372
+ previous: ThreadReconciliationMachineState | undefined,
373
+ current: ThreadReconciliationMachineState,
374
+ ): ThreadReconciliationTransition | undefined {
375
+ if (!previous || previous.phase === current.phase) return undefined;
376
+ return {
377
+ from: previous.phase,
378
+ to: current.phase,
379
+ event: current.event,
380
+ atMs: current.atMs,
381
+ };
382
+ }
383
+
384
+ function getActionLeaderEpoch(
385
+ action: ThreadReconciliationAction,
386
+ ): number | string | undefined {
387
+ return "leaderEpoch" in action ? action.leaderEpoch : undefined;
388
+ }
389
+
390
+ function isTelegramTopicTargetGoneError(error: unknown): boolean {
391
+ if (!(error instanceof Error)) return false;
392
+ const message = error.message.toLowerCase();
393
+ return (
394
+ message.includes("topic_id_invalid") ||
395
+ message.includes("message thread not found") ||
396
+ message.includes("thread not found") ||
397
+ message.includes("topic not found") ||
398
+ message.includes("topic deleted") ||
399
+ message.includes("topic closed") ||
400
+ message.includes("thread closed") ||
401
+ message.includes("forum topic closed") ||
402
+ message.includes("message thread closed")
403
+ );
404
+ }
405
+
406
+ function shouldSkipForStaleLeaderEpoch(
407
+ action: ThreadReconciliationAction,
408
+ ports: ThreadReconciliationApplyPorts,
409
+ ): boolean {
410
+ const actionLeaderEpoch = getActionLeaderEpoch(action);
411
+ const currentLeaderEpoch = ports.getCurrentLeaderEpoch?.();
412
+ if (actionLeaderEpoch === undefined) {
413
+ if (currentLeaderEpoch !== undefined) {
414
+ ports.recordRuntimeEvent?.(
415
+ "telegram",
416
+ "Thread reconciliation destructive action has no leader epoch",
417
+ {
418
+ phase: "thread-reconciler-missing-leader-epoch",
419
+ action: action.kind,
420
+ currentLeaderEpoch,
421
+ chatId: action.target.chatId,
422
+ threadId: action.target.threadId,
423
+ },
424
+ );
425
+ }
426
+ return false;
427
+ }
428
+ if (currentLeaderEpoch === actionLeaderEpoch) return false;
429
+ ports.recordRuntimeEvent?.(
430
+ "telegram",
431
+ "Skipped stale-epoch thread reconciliation action",
432
+ {
433
+ phase: "thread-reconciler-stale-epoch-skip",
434
+ action: action.kind,
435
+ actionLeaderEpoch,
436
+ currentLeaderEpoch,
437
+ chatId: action.target.chatId,
438
+ threadId: action.target.threadId,
439
+ },
440
+ );
441
+ return true;
442
+ }
443
+
444
+ export interface ThreadReconciliationRuntime {
445
+ getState: () => ThreadReconciliationMachineState | undefined;
446
+ recordPlan: (plan: ThreadReconciliationPlan) => void;
447
+ plan: (
448
+ input: Omit<ThreadReconciliationInput, "previousState">,
449
+ ) => ThreadReconciliationPlan;
450
+ }
451
+
452
+ export function createThreadReconciliationRuntime(deps: {
453
+ recordRuntimeEvent: (
454
+ category: string,
455
+ message: unknown,
456
+ details?: Record<string, unknown>,
457
+ ) => void;
458
+ scheduleSnapshotPersist: () => void;
459
+ }): ThreadReconciliationRuntime {
460
+ let state: ThreadReconciliationMachineState | undefined;
461
+ const recordPlan = (plan: ThreadReconciliationPlan): void => {
462
+ if (!plan.state) return;
463
+ state = plan.state;
464
+ if (plan.transition) {
465
+ deps.recordRuntimeEvent(
466
+ "telegram",
467
+ "Thread reconciliation phase changed",
468
+ {
469
+ phase: "thread-reconciliation-transition",
470
+ from: plan.transition.from,
471
+ to: plan.transition.to,
472
+ event: plan.transition.event,
473
+ },
474
+ );
475
+ }
476
+ deps.scheduleSnapshotPersist();
477
+ };
478
+ return {
479
+ getState: () => state,
480
+ recordPlan,
481
+ plan(input) {
482
+ const plan = planThreadReconciliation({
483
+ ...input,
484
+ previousState: state,
485
+ });
486
+ recordPlan(plan);
487
+ return plan;
488
+ },
489
+ };
490
+ }
491
+
492
+ export function planDisconnectedInstanceThreadCleanup(input: {
493
+ target: TelegramTarget & { threadId: number };
494
+ instanceId?: string;
495
+ }): ThreadReconciliationPlan {
496
+ return {
497
+ actions: [
498
+ {
499
+ kind: "close-delete-disconnected-instance-topic",
500
+ target: input.target,
501
+ reason: "manual-disconnect",
502
+ instanceId: input.instanceId,
503
+ },
504
+ ],
505
+ };
506
+ }
507
+
508
+ export async function applyThreadReconciliationPlan(
509
+ plan: ThreadReconciliationPlan,
510
+ ports: ThreadReconciliationApplyPorts,
511
+ ): Promise<ThreadReconciliationApplyResult> {
512
+ let shouldPersist = false;
513
+ for (const action of plan.actions) {
514
+ if (action.kind === "remove-reservation") {
515
+ shouldPersist =
516
+ ports.removeReservationByTarget?.(action.target) || shouldPersist;
517
+ continue;
518
+ }
519
+ if (action.kind === "mark-topic-active") {
520
+ shouldPersist =
521
+ ports.markActiveByTarget?.(action.target) || shouldPersist;
522
+ continue;
523
+ }
524
+ if (action.kind === "mark-topic-stale") {
525
+ shouldPersist =
526
+ ports.markStaleByTarget?.(action.target, action.syncStatus) ||
527
+ shouldPersist;
528
+ continue;
529
+ }
530
+ if (action.kind === "close-stale-replaced-topic") {
531
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
532
+ if (!ports.callApi) {
533
+ ports.recordRuntimeEvent?.(
534
+ "telegram",
535
+ "Skipped topic close without API port",
536
+ {
537
+ phase: "thread-reconciler-close-skip-no-api",
538
+ reason: action.reason,
539
+ chatId: action.target.chatId,
540
+ threadId: action.target.threadId,
541
+ },
542
+ );
543
+ continue;
544
+ }
545
+ try {
546
+ await ports.callApi("closeForumTopic", {
547
+ chat_id: action.target.chatId,
548
+ message_thread_id: action.target.threadId,
549
+ });
550
+ shouldPersist =
551
+ ports.markStaleByTarget?.(action.target, "closed") || shouldPersist;
552
+ } catch (error) {
553
+ ports.recordRuntimeEvent?.("telegram", error, {
554
+ phase: `thread-reconciler-${action.reason}-closeForumTopic`,
555
+ instanceId: action.instanceId,
556
+ chatId: action.target.chatId,
557
+ threadId: action.target.threadId,
558
+ });
559
+ }
560
+ continue;
561
+ }
562
+ if (
563
+ action.kind === "close-delete-unbound-topic" ||
564
+ action.kind === "close-delete-reserved-topic" ||
565
+ action.kind === "close-delete-pruned-follower-topic" ||
566
+ action.kind === "close-delete-replaced-follower-topic" ||
567
+ action.kind === "close-delete-previous-leader-topic" ||
568
+ action.kind === "close-delete-disconnected-instance-topic" ||
569
+ action.kind === "close-delete-expired-pending-provision-topic"
570
+ ) {
571
+ if (shouldSkipForStaleLeaderEpoch(action, ports)) continue;
572
+ if (!ports.callApi) {
573
+ ports.recordRuntimeEvent?.(
574
+ "telegram",
575
+ "Skipped topic cleanup without API port",
576
+ {
577
+ phase: "thread-reconciler-cleanup-skip-no-api",
578
+ reason: action.reason,
579
+ chatId: action.target.chatId,
580
+ threadId: action.target.threadId,
581
+ },
582
+ );
583
+ continue;
584
+ }
585
+ let deleteConfirmed = false;
586
+ for (const method of ["closeForumTopic", "deleteForumTopic"]) {
587
+ try {
588
+ await ports.callApi(method, {
589
+ chat_id: action.target.chatId,
590
+ message_thread_id: action.target.threadId,
591
+ });
592
+ if (method === "deleteForumTopic") deleteConfirmed = true;
593
+ } catch (error) {
594
+ if (isTelegramTopicTargetGoneError(error)) {
595
+ if (method === "deleteForumTopic") deleteConfirmed = true;
596
+ } else {
597
+ ports.recordRuntimeEvent?.("telegram", error, {
598
+ phase: `thread-reconciler-${action.reason}-${method}`,
599
+ chatId: action.target.chatId,
600
+ threadId: action.target.threadId,
601
+ });
602
+ }
603
+ }
604
+ }
605
+ if (!deleteConfirmed) {
606
+ ports.recordRuntimeEvent?.(
607
+ "telegram",
608
+ "Telegram topic cleanup incomplete",
609
+ {
610
+ phase: "thread-reconciler-cleanup-incomplete",
611
+ action: action.kind,
612
+ reason: action.reason,
613
+ chatId: action.target.chatId,
614
+ threadId: action.target.threadId,
615
+ ...("messageId" in action ? { messageId: action.messageId } : {}),
616
+ },
617
+ );
618
+ continue;
619
+ }
620
+ if (
621
+ action.kind !== "close-delete-previous-leader-topic" &&
622
+ action.kind !== "close-delete-pruned-follower-topic" &&
623
+ action.kind !== "close-delete-replaced-follower-topic" &&
624
+ action.kind !== "close-delete-disconnected-instance-topic" &&
625
+ action.kind !== "close-delete-expired-pending-provision-topic"
626
+ ) {
627
+ shouldPersist =
628
+ ports.markStaleByTarget?.(action.target, "deleted") || shouldPersist;
629
+ }
630
+ ports.recordRuntimeEvent?.(
631
+ "telegram",
632
+ action.kind === "close-delete-unbound-topic"
633
+ ? "Unbound Telegram topic deleted"
634
+ : action.kind === "close-delete-reserved-topic"
635
+ ? "Reserved Telegram topic deleted"
636
+ : action.kind === "close-delete-pruned-follower-topic"
637
+ ? "Pruned follower Telegram topic deleted"
638
+ : action.kind === "close-delete-replaced-follower-topic"
639
+ ? "Replaced follower Telegram topic deleted"
640
+ : action.kind === "close-delete-previous-leader-topic"
641
+ ? "Previous leader Telegram topic deleted"
642
+ : action.kind === "close-delete-disconnected-instance-topic"
643
+ ? "Disconnected instance Telegram topic deleted"
644
+ : "Expired pending provision Telegram topic deleted",
645
+ {
646
+ phase:
647
+ action.kind === "close-delete-unbound-topic"
648
+ ? "thread-reconciler-unbound-topic-delete"
649
+ : action.kind === "close-delete-reserved-topic"
650
+ ? "thread-reconciler-reserved-topic-delete"
651
+ : action.kind === "close-delete-pruned-follower-topic"
652
+ ? "thread-reconciler-pruned-follower-topic-delete"
653
+ : action.kind === "close-delete-replaced-follower-topic"
654
+ ? "thread-reconciler-replaced-follower-topic-delete"
655
+ : action.kind === "close-delete-previous-leader-topic"
656
+ ? "thread-reconciler-previous-leader-topic-delete"
657
+ : action.kind ===
658
+ "close-delete-disconnected-instance-topic"
659
+ ? "thread-reconciler-disconnected-instance-topic-delete"
660
+ : "thread-reconciler-expired-pending-provision-topic-delete",
661
+ chatId: action.target.chatId,
662
+ threadId: action.target.threadId,
663
+ ...("messageId" in action ? { messageId: action.messageId } : {}),
664
+ },
665
+ );
666
+ if (
667
+ action.kind === "close-delete-expired-pending-provision-topic" &&
668
+ deleteConfirmed
669
+ ) {
670
+ shouldPersist =
671
+ ports.removePendingProvisionById?.(action.pendingProvisionId) ||
672
+ shouldPersist;
673
+ }
674
+ }
675
+ }
676
+ if (shouldPersist) await ports.persist?.();
677
+ return { changed: shouldPersist };
678
+ }
679
+
680
+ export function planThreadReconciliation(
681
+ input: ThreadReconciliationInput,
682
+ ): ThreadReconciliationPlan {
683
+ const graceMs = input.freshCreationGraceMs ?? DEFAULT_FRESH_CREATION_GRACE_MS;
684
+ const knownTargets = new Set(
685
+ input.records.map((record) => targetKey(record.target)),
686
+ );
687
+ const currentTargets = new Set(
688
+ input.records
689
+ .filter(isCurrentRecord)
690
+ .map((record) => targetKey(record.target)),
691
+ );
692
+ const allReservationTargets = new Set(
693
+ (input.reservations ?? []).map((reservation) =>
694
+ targetKey(reservation.target),
695
+ ),
696
+ );
697
+ const reservedTargets = new Set(
698
+ (input.reservations ?? [])
699
+ .filter((reservation) => isReservationAlive(reservation, input.nowMs))
700
+ .map((reservation) => targetKey(reservation.target)),
701
+ );
702
+ const pendingTargets = new Set(
703
+ (input.pendingProvisions ?? [])
704
+ .filter((provision) =>
705
+ isPendingProvisionAlive(
706
+ provision,
707
+ input.nowMs,
708
+ input.currentLeaderEpoch,
709
+ ),
710
+ )
711
+ .flatMap((provision) =>
712
+ provision.target ? [targetKey(provision.target)] : [],
713
+ ),
714
+ );
715
+ const recentOpenTargets = new Set(
716
+ (input.observations ?? [])
717
+ .filter((observation) =>
718
+ isRecentOpenObservation(observation, input.nowMs, graceMs),
719
+ )
720
+ .map((observation) => targetKey(observation.target)),
721
+ );
722
+
723
+ const actions: ThreadReconciliationAction[] = [];
724
+ for (const provision of input.pendingProvisions ?? []) {
725
+ if (!provision.target) continue;
726
+ if (!isPendingProvisionExpired(provision, input.nowMs)) continue;
727
+ const key = targetKey(provision.target);
728
+ if (currentTargets.has(key)) continue;
729
+ if (reservedTargets.has(key)) continue;
730
+ if (recentOpenTargets.has(key)) continue;
731
+ actions.push({
732
+ kind: "close-delete-expired-pending-provision-topic",
733
+ target: provision.target,
734
+ reason: "expired-pending-provision",
735
+ pendingProvisionId: provision.id,
736
+ instanceId: provision.instanceId,
737
+ ...(input.currentLeaderEpoch !== undefined
738
+ ? { leaderEpoch: input.currentLeaderEpoch }
739
+ : {}),
740
+ });
741
+ }
742
+
743
+ for (const observation of input.observations ?? []) {
744
+ if (observation.syncStatus === "open") {
745
+ if (knownTargets.has(targetKey(observation.target))) {
746
+ actions.push({
747
+ kind: "mark-topic-active",
748
+ target: observation.target,
749
+ reason: "observed-open",
750
+ });
751
+ }
752
+ continue;
753
+ }
754
+ if (
755
+ observation.syncStatus === "closed" ||
756
+ observation.syncStatus === "deleted"
757
+ ) {
758
+ if (currentTargets.has(targetKey(observation.target))) {
759
+ actions.push({
760
+ kind: "mark-topic-stale",
761
+ target: observation.target,
762
+ syncStatus: observation.syncStatus,
763
+ reason:
764
+ observation.syncStatus === "closed"
765
+ ? "observed-closed"
766
+ : "observed-deleted",
767
+ });
768
+ }
769
+ }
770
+ }
771
+
772
+ for (const prunedInstanceId of input.prunedFollowerInstanceIds ?? []) {
773
+ for (const record of input.records) {
774
+ if (record.instanceId !== prunedInstanceId) continue;
775
+ if (!isActiveOrStartingRecord(record)) continue;
776
+ actions.push({
777
+ kind: "close-delete-pruned-follower-topic",
778
+ target: record.target,
779
+ reason: "pruned-follower",
780
+ instanceId: prunedInstanceId,
781
+ ...(input.currentLeaderEpoch !== undefined
782
+ ? { leaderEpoch: input.currentLeaderEpoch }
783
+ : {}),
784
+ });
785
+ }
786
+ }
787
+
788
+ for (const replacement of input.replacedBindings ?? []) {
789
+ for (const record of input.records) {
790
+ if (record.instanceId !== replacement.instanceId) continue;
791
+ if (!isActiveOrStartingRecord(record)) continue;
792
+ if (
793
+ targetKey(record.target) === targetKey(replacement.replacementTarget)
794
+ ) {
795
+ continue;
796
+ }
797
+ actions.push({
798
+ kind: "close-stale-replaced-topic",
799
+ target: record.target,
800
+ reason: "replaced-instance-binding",
801
+ instanceId: replacement.instanceId,
802
+ ...(input.currentLeaderEpoch !== undefined
803
+ ? { leaderEpoch: input.currentLeaderEpoch }
804
+ : {}),
805
+ });
806
+ }
807
+ }
808
+
809
+ if (input.previousLeaderCleanup) {
810
+ for (const record of input.records) {
811
+ if (!record.instanceId) continue;
812
+ if (record.instanceId === input.previousLeaderCleanup.currentInstanceId) {
813
+ continue;
814
+ }
815
+ if (!isActiveOrStartingRecord(record)) continue;
816
+ if (!isPreviousLeaderRecord(record)) continue;
817
+ actions.push({
818
+ kind: "close-delete-previous-leader-topic",
819
+ target: record.target,
820
+ reason: "previous-leader",
821
+ instanceId: input.previousLeaderCleanup.currentInstanceId,
822
+ ...(input.currentLeaderEpoch !== undefined
823
+ ? { leaderEpoch: input.currentLeaderEpoch }
824
+ : {}),
825
+ });
826
+ }
827
+ }
828
+
829
+ if (input.proactiveReservationCleanup) {
830
+ for (const reservation of input.reservations ?? []) {
831
+ const key = targetKey(reservation.target);
832
+ if (currentTargets.has(key)) continue;
833
+ if (pendingTargets.has(key)) continue;
834
+ actions.push({
835
+ kind: "close-delete-reserved-topic",
836
+ target: reservation.target,
837
+ observedAtMs: input.nowMs,
838
+ reason: "startup-reservation",
839
+ ...(input.currentLeaderEpoch !== undefined
840
+ ? { leaderEpoch: input.currentLeaderEpoch }
841
+ : {}),
842
+ });
843
+ }
844
+ }
845
+
846
+ for (const probe of input.reservationProbeResults ?? []) {
847
+ if (!probe.stale) continue;
848
+ if (!allReservationTargets.has(targetKey(probe.target))) continue;
849
+ actions.push({
850
+ kind: "remove-reservation",
851
+ target: probe.target,
852
+ reason: "reservation-probe-stale",
853
+ });
854
+ }
855
+
856
+ for (const message of input.reservedMessages ?? []) {
857
+ const key = targetKey(message.target);
858
+ if (!reservedTargets.has(key)) continue;
859
+ if (currentTargets.has(key)) continue;
860
+ if (pendingTargets.has(key)) continue;
861
+ if (
862
+ input.currentLeaderEpoch !== undefined &&
863
+ message.leaderEpoch !== undefined &&
864
+ message.leaderEpoch !== input.currentLeaderEpoch
865
+ ) {
866
+ continue;
867
+ }
868
+ actions.push({
869
+ kind: "close-delete-reserved-topic",
870
+ target: message.target,
871
+ observedAtMs: message.observedAtMs,
872
+ messageId: message.messageId,
873
+ reason: "reserved-user-message",
874
+ ...(input.currentLeaderEpoch !== undefined
875
+ ? { leaderEpoch: input.currentLeaderEpoch }
876
+ : {}),
877
+ });
878
+ }
879
+
880
+ for (const message of input.unboundMessages ?? []) {
881
+ const key = targetKey(message.target);
882
+ if (currentTargets.has(key)) continue;
883
+ if (reservedTargets.has(key)) continue;
884
+ if (pendingTargets.has(key)) continue;
885
+ if (recentOpenTargets.has(key)) continue;
886
+ if (
887
+ input.currentLeaderEpoch !== undefined &&
888
+ message.leaderEpoch !== undefined &&
889
+ message.leaderEpoch !== input.currentLeaderEpoch
890
+ ) {
891
+ continue;
892
+ }
893
+ actions.push({
894
+ kind: "close-delete-unbound-topic",
895
+ target: message.target,
896
+ observedAtMs: message.observedAtMs,
897
+ messageId: message.messageId,
898
+ reason: "unbound-user-message",
899
+ ...(input.currentLeaderEpoch !== undefined
900
+ ? { leaderEpoch: input.currentLeaderEpoch }
901
+ : {}),
902
+ });
903
+ }
904
+
905
+ const state = createThreadReconciliationMachineState(input, actions);
906
+ const transition = createThreadReconciliationTransition(
907
+ input.previousState,
908
+ state,
909
+ );
910
+ return {
911
+ actions,
912
+ state,
913
+ ...(transition ? { transition } : {}),
914
+ };
915
+ }