@llblab/pi-telegram 0.42.3 → 0.42.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +2 -0
- package/CHANGELOG.md +7 -0
- package/docs/multi-instance-bus.md +17 -5
- package/index.ts +9 -0
- package/lib/bus-follower.ts +29 -18
- package/lib/bus-leader.ts +15 -6
- package/lib/bus.ts +11 -4
- package/lib/routing.ts +89 -32
- package/lib/sync.ts +74 -15
- package/lib/telegram-api.ts +32 -1
- package/lib/thread-reconciler.ts +17 -0
- package/lib/threads.ts +123 -15
- package/package.json +1 -1
package/BACKLOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
|
+
- [ ] `Live-thread continuity and recovery`: Make loss and restoration of a live thread truthful and safe without discarding accepted work or deleting a currently owned target; the initial cause of the observed stale-thread API failures remains unproven.
|
|
6
|
+
- [ ] Complete the remaining operator-coordinated live smoke beyond the operator-confirmed successful leader Restore: verify follower Restore, inaccessible callbacks, already-absent chooser cleanup, direct stale-target diagnostics, and preservation of accepted local work/active-turn target. Use disposable test threads. The successful leader flow does not independently prove these failure paths or establish the initial stale-thread failure cause.
|
|
5
7
|
- [ ] `Operator-confirmed pairing`: Replace first-contact automatic ownership with an explicit pending request that the trusted Pi interface can allow or reject before `allowedUserId` is persisted or any Telegram prompt executes; retain manual preconfiguration, bound pending-request lifetime and multiplicity, fail closed across session/transport replacement, and cover unauthorized-first-contact races.
|
|
6
8
|
- [ ] `Environment-backed bot tokens`: Let each profile store an exact Pi-native `$ENV_VAR` or `${ENV_VAR}` reference in `telegram.json` instead of copying the resolved secret; preserve the originating alias when `/telegram-setup` prefills from a supported environment variable, resolve only at validation/activation boundaries, fail closed with a redacted named-variable diagnostic when unresolved, retain literal-token compatibility, and cover named profiles, reload, persistence, status, and secret-redaction paths.
|
|
7
9
|
- [ ] [`Inference bypass Generative Apps`](./docs/generative-apps.md): Let the agent install and bind one managed JavaScript app owner for a generated-prompt prefix so deterministic controls can bypass model inference without creating another button grammar.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.42.4: Thread Recovery Hotfix
|
|
6
|
+
|
|
7
|
+
- `Thread Restore`: Retains validated source and chooser identity when callback messages omit thread metadata, hides Restore in threadless/All choosers and explains how to supply a destination, rejects conflicting Restore instead of forwarding to the old target, and treats confirmed already-deleted chooser cleanup as complete without redispatch.
|
|
8
|
+
- `Target Safety`: Rechecks cleanup ownership before close/delete and local retirement across restore, disconnect, and provisioning paths; a rebound target is not deleted, invalidated, or reserved by obsolete cleanup. Follower restore rechecks registration generation and expected target after IPC, store-load, and persistence waits.
|
|
9
|
+
- `Stale Delivery`: Direct replies, menus, activity, edits, and multipart sends capture exact stale-target authority; guarded invalidation rechecks generation, profile, binding, and snapshot revision at the synchronous durable commit without replaying failed sends or redirecting accepted work.
|
|
10
|
+
- `State Continuity`: Snapshot reads cannot overwrite bindings or cleanup intents admitted while disk I/O was pending; equivalent persisted records retain cleanup authority regardless of property order or omitted optional fields.
|
|
11
|
+
|
|
5
12
|
## 0.42.3: Agent Diagnostics Hotfix
|
|
6
13
|
|
|
7
14
|
- `Agent Diagnostics`: Identifies `/telegram-status` as a Pi TUI command and routes agents without command access directly to the redacted diagnostic files instead of attempting a shell executable; runtime behavior and STT fallback remain unchanged.
|
|
@@ -272,7 +272,7 @@ Non-goal: group detection is not the control-plane model for this extension. Thr
|
|
|
272
272
|
|
|
273
273
|
Remaining live-verification points:
|
|
274
274
|
|
|
275
|
-
-
|
|
275
|
+
- Callback query messages can be `InaccessibleMessage` without `message_thread_id`; reroute controls use stored source/chooser identity, and ordinary generated buttons retain their message-ownership routing. Client-visible Restore and cleanup still require live smoke evidence.
|
|
276
276
|
- Whether message-reaction updates carry thread identity in the current Bot API shape. The reference exposes chat id and message id for reactions, so routing may need stored message ownership.
|
|
277
277
|
- Live client evidence now covers the probe-confirmed single-artifact multipart Rich final through both direct leader and registered follower transport: an assigned follower Telegram turn produced one reply-anchored PNG plus final text without a duplicate upload or notice. Deterministic bus tests additionally cover target-scoped multipart authorization, envelope preservation, and replacement-generation fencing.
|
|
278
278
|
|
|
@@ -412,11 +412,23 @@ All files containing routing, chat ids, thread ids, or process details use priva
|
|
|
412
412
|
- Destructive follower thread teardown belongs to confirmed `/telegram-disconnect`, graceful Pi quit, or confirmed reconciliation actions, not generic heartbeat pruning. Manual disconnect retains its destructive confirmation and clears restart ownership; quit deletes the tab without prompting when Thread cleanup is enabled (default) but preserves the owner slot independently so a same-directory restart can reclaim leadership. Confirmed leader/follower teardown first persists an exact target/runtime-generation cleanup intent. The active leader attempts deletion under its current epoch; interruption preserves the intent so that leader or a successor can replay it under current authority, and confirmed deletion removes the binding plus intent in the same persisted state transition. If the graceful request is missed, stale heartbeat plus OS-confirmed absence of the exact registered PID may authorize the same cleanup while enabled; this action serializes ahead of replacement registration. Disabled cleanup, silence, heartbeat expiry alone, IPC/auth failure, and live or unknown process liveness remain non-destructive. Incomplete cleanup preserves durable intent for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.
|
|
413
413
|
- Explicit stale/deleted/offline observations invalidate reuse. Process absence affects reuse only through the enabled, exact-PID confirmed-dead cleanup path.
|
|
414
414
|
|
|
415
|
-
###
|
|
415
|
+
### Stale thread delivery
|
|
416
416
|
|
|
417
|
-
-
|
|
418
|
-
|
|
419
|
-
-
|
|
417
|
+
Local regression evidence covers continuity and cleanup authority; operator-coordinated live Restore verification remains tracked in [BACKLOG.md](../BACKLOG.md).
|
|
418
|
+
|
|
419
|
+
- Direct replies, menus, activity, target-aware edits, and multipart transport capture the request target and local authority before sending. Exact typed HTTP 400 stale-thread evidence stages invalidation of only the matching unchanged binding. The thread store rechecks leader/session/profile authority, binding identity, snapshot revisions, and destination path at the synchronous owner-fenced rename; no staged invalidation enters the live projection before durable commit, and a rejected commit preserves newer state.
|
|
420
|
+
- Shared recovery marks topic, target-binding, and transport freshness suspect and schedules a diagnostic snapshot. Accepted local work and its active-turn target remain unchanged; a failed send is not replayed or redirected, and recovery does not create a replacement thread or probe on every send. Errors without a proven request target do not authorize invalidation.
|
|
421
|
+
- Reconnect and explicit Restore remain separate authority-bearing operations. A stale API response proves target failure, not who deleted or closed the thread.
|
|
422
|
+
|
|
423
|
+
### Restore controls and cleanup
|
|
424
|
+
|
|
425
|
+
Operator-confirmed client flow: send ordinary text from the **All** tab; Telegram creates a new thread containing that text. Choose **Replace/restore thread…** and the existing Pi instance. Successful leader Restore binds and renames the new thread, dispatches the original message to the same Pi, and removes the old thread. The resulting prompt's thread-name label reflects the restored destination, not proof that the user typed in the old thread. A threadless `/start` is not equivalent to this ordinary-text flow.
|
|
426
|
+
|
|
427
|
+
The bounded pending reroute owns its original source target and the returned chooser message ID independently from remaining messages. Authenticated callbacks must match the stored chat/chooser and any supplied thread field. `InaccessibleMessage` may omit the thread field; a missing callback message, unknown chooser, conflicting identity, threadless source, or already-owned Restore source fails closed rather than forwarding to the old target. Restart or expiry without that pending identity does not authorize reconstruction from callback data alone. When the original input itself has no thread ID (including an All-tab `/start`), the chooser offers routing only, not Restore; old Restore callbacks explain that a plain message must first be sent in the destination thread. Client tab selection is not inferred from recent topic creation or from the chosen Pi instance.
|
|
428
|
+
|
|
429
|
+
After dispatch, cleanup retries retain source identity but never redispatch accepted messages. Confirmed typed HTTP 400 `message to delete not found` completes message deletion idempotently; permission and transient failures remain errors. Reroute cleanup rechecks current bindings, live targets, reservations, and pending provisions before close, before delete, and on retry. Other destructive cleanup origins use the shared synchronous target-protection policy: explicit retirement permits only its unchanged departing binding, persisted shutdown intent permits only its original pre-intent binding, and reservation/provision cleanup permits only the corresponding unchanged claim. Protection checks also guard post-API local invalidation, reservation, and disconnect completion. A newly protected target cancels remaining cleanup. Already-issued remote operations cannot be undone by a later local ownership change; checks prevent subsequent effects, not retroactive cancellation.
|
|
430
|
+
|
|
431
|
+
Follower Restore requires exact registration generation and expected old target both before and after awaited IPC acknowledgement/store loading. Completion also rechecks the restored target and generation after persistence before publishing status or acknowledging success. A mismatched, replaced, or same-target request cannot overwrite the current registration.
|
|
420
432
|
|
|
421
433
|
### Split brain
|
|
422
434
|
|
package/index.ts
CHANGED
|
@@ -400,6 +400,15 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
400
400
|
TelegramApi.createDefaultTelegramBridgeApiRuntime({
|
|
401
401
|
getBotToken: configStore.getBotToken,
|
|
402
402
|
recordRuntimeEvent,
|
|
403
|
+
captureRequestErrorHandler(body) {
|
|
404
|
+
return Sync.captureTelegramStaleTargetRequestRecovery(body, {
|
|
405
|
+
...staleTopicApiErrorRecoveryDeps,
|
|
406
|
+
getCurrentLeaderEpoch,
|
|
407
|
+
getSessionGeneration: telegramSessionContextStore.getGeneration,
|
|
408
|
+
getProfileName: configStore.getActiveProfileName,
|
|
409
|
+
onRecovered: runtimeDiagnostics.scheduleSnapshotPersist,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
403
412
|
});
|
|
404
413
|
const telegramBusFollowerClients =
|
|
405
414
|
BusFollower.createTelegramBusFollowerClientRuntime<
|
package/lib/bus-follower.ts
CHANGED
|
@@ -390,9 +390,8 @@ export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
|
|
|
390
390
|
>;
|
|
391
391
|
registrationState: Pick<
|
|
392
392
|
TelegramBusFollowerRegistrationState,
|
|
393
|
-
"getTarget" | "setRegistered"
|
|
394
|
-
|
|
395
|
-
Partial<Pick<TelegramBusFollowerRegistrationState, "getGeneration">>;
|
|
393
|
+
"getTarget" | "setRegistered" | "getGeneration"
|
|
394
|
+
>;
|
|
396
395
|
instanceId: string;
|
|
397
396
|
getManualFollowerProfileKey: () => string;
|
|
398
397
|
manualFollowerOwnerId: string;
|
|
@@ -471,6 +470,7 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<TContext> {
|
|
|
471
470
|
target: TelegramTarget & { threadId: number };
|
|
472
471
|
oldTarget?: TelegramTarget & { threadId: number };
|
|
473
472
|
reason: "thread-restore";
|
|
473
|
+
registrationGeneration: string;
|
|
474
474
|
},
|
|
475
475
|
ctx: TContext,
|
|
476
476
|
) => Promise<void> | void;
|
|
@@ -569,26 +569,35 @@ export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
|
|
|
569
569
|
> {
|
|
570
570
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
571
571
|
return async (input, ctx) => {
|
|
572
|
+
const assertCurrent = (expectedTarget = input.oldTarget): void => {
|
|
573
|
+
const target = deps.registrationState.getTarget();
|
|
574
|
+
if (!input.registrationGeneration ||
|
|
575
|
+
deps.registrationState.getGeneration() !== input.registrationGeneration ||
|
|
576
|
+
!input.oldTarget || !expectedTarget || target?.chatId !== expectedTarget.chatId ||
|
|
577
|
+
target.threadId !== expectedTarget.threadId ||
|
|
578
|
+
input.target.chatId !== input.oldTarget.chatId ||
|
|
579
|
+
input.target.threadId === input.oldTarget.threadId) {
|
|
580
|
+
throw new Error("Stale Telegram follower target replacement authority.");
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
assertCurrent();
|
|
572
584
|
await deps.topicTargetStore.load();
|
|
585
|
+
assertCurrent();
|
|
573
586
|
const nowMs = getNowMs();
|
|
574
587
|
const currentRecord = Threads.findCurrentTelegramInstanceThreadRecord({
|
|
575
588
|
records: deps.topicTargetStore.list(),
|
|
576
589
|
instanceId: deps.instanceId,
|
|
577
|
-
preferredTarget: input.oldTarget
|
|
590
|
+
preferredTarget: input.oldTarget,
|
|
578
591
|
});
|
|
579
|
-
if (input.oldTarget
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
"deleted",
|
|
583
|
-
"Follower thread was replaced by thread restore.",
|
|
584
|
-
);
|
|
585
|
-
} else if (currentRecord) {
|
|
586
|
-
deps.topicTargetStore.markStaleByTarget(
|
|
587
|
-
currentRecord.target,
|
|
588
|
-
"deleted",
|
|
589
|
-
"Follower thread was replaced by thread restore.",
|
|
590
|
-
);
|
|
592
|
+
if (currentRecord && (currentRecord.target.chatId !== input.oldTarget!.chatId ||
|
|
593
|
+
currentRecord.target.threadId !== input.oldTarget!.threadId)) {
|
|
594
|
+
throw new Error("Stale Telegram follower binding replacement target.");
|
|
591
595
|
}
|
|
596
|
+
deps.topicTargetStore.markStaleByTarget(
|
|
597
|
+
input.oldTarget!,
|
|
598
|
+
"deleted",
|
|
599
|
+
"Follower thread was replaced by thread restore.",
|
|
600
|
+
);
|
|
592
601
|
const profileKey =
|
|
593
602
|
currentRecord?.profileKey ?? deps.getManualFollowerProfileKey();
|
|
594
603
|
deps.topicTargetStore.upsert({
|
|
@@ -612,15 +621,16 @@ export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
|
|
|
612
621
|
deps.registrationState.setRegistered(true, input.target, {
|
|
613
622
|
slot: currentRecord?.slot,
|
|
614
623
|
threadName: currentRecord?.threadName,
|
|
615
|
-
generation:
|
|
624
|
+
generation: input.registrationGeneration,
|
|
616
625
|
});
|
|
626
|
+
await deps.topicTargetStore.persist();
|
|
627
|
+
assertCurrent(input.target);
|
|
617
628
|
deps.setSyncState(
|
|
618
629
|
Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
619
630
|
nowMs,
|
|
620
631
|
action: "follower-thread-restore",
|
|
621
632
|
}),
|
|
622
633
|
);
|
|
623
|
-
await deps.topicTargetStore.persist();
|
|
624
634
|
deps.updateStatus(ctx);
|
|
625
635
|
deps.recordRuntimeEvent?.(
|
|
626
636
|
"bus",
|
|
@@ -1992,6 +2002,7 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<TContext>(
|
|
|
1992
2002
|
target: envelope.target,
|
|
1993
2003
|
...(envelope.oldTarget ? { oldTarget: envelope.oldTarget } : {}),
|
|
1994
2004
|
reason: envelope.reason,
|
|
2005
|
+
registrationGeneration,
|
|
1995
2006
|
},
|
|
1996
2007
|
ctx,
|
|
1997
2008
|
);
|
package/lib/bus-leader.ts
CHANGED
|
@@ -135,6 +135,7 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
|
|
|
135
135
|
export interface TelegramBusFollowerDisconnectHandlerDeps {
|
|
136
136
|
topicTargetStore: Pick<
|
|
137
137
|
Threads.TelegramTopicTargetStore,
|
|
138
|
+
| "list"
|
|
138
139
|
| "markStaleByTarget"
|
|
139
140
|
| "persist"
|
|
140
141
|
| "upsertPendingCleanup"
|
|
@@ -726,6 +727,7 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
726
727
|
],
|
|
727
728
|
}),
|
|
728
729
|
{
|
|
730
|
+
isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.topicTargetStore),
|
|
729
731
|
callApi: deps.callApi,
|
|
730
732
|
markStaleByTarget(target, syncStatus, lastSyncError) {
|
|
731
733
|
return deps.topicTargetStore.markStaleByTarget(
|
|
@@ -805,16 +807,21 @@ function createTelegramBusFollowerCleanupHandler(
|
|
|
805
807
|
target: { chatId: target.chatId, threadId: target.threadId },
|
|
806
808
|
requestedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
807
809
|
};
|
|
810
|
+
const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === follower.instanceId &&
|
|
811
|
+
record.target.chatId === target.chatId && record.target.threadId === target.threadId);
|
|
812
|
+
const isCleanupTargetProtected = Threads.createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
|
|
808
813
|
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
809
814
|
await deps.topicTargetStore.persist();
|
|
815
|
+
const cleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
816
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
817
|
+
currentLeaderEpoch: leaderEpoch,
|
|
818
|
+
records: [],
|
|
819
|
+
pendingCleanups: [intent],
|
|
820
|
+
});
|
|
810
821
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
811
|
-
|
|
812
|
-
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
813
|
-
currentLeaderEpoch: leaderEpoch,
|
|
814
|
-
records: [],
|
|
815
|
-
pendingCleanups: [intent],
|
|
816
|
-
}),
|
|
822
|
+
cleanupPlan,
|
|
817
823
|
{
|
|
824
|
+
isCleanupTargetProtected,
|
|
818
825
|
callApi: deps.callApi,
|
|
819
826
|
markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
|
|
820
827
|
removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
|
|
@@ -823,6 +830,7 @@ function createTelegramBusFollowerCleanupHandler(
|
|
|
823
830
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
824
831
|
},
|
|
825
832
|
);
|
|
833
|
+
if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) return;
|
|
826
834
|
if (cleanup.incompleteActions?.length) {
|
|
827
835
|
throw new Error(
|
|
828
836
|
"Telegram follower thread deletion was not confirmed; reconnect the leader to retry cleanup.",
|
|
@@ -916,6 +924,7 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
|
|
|
916
924
|
});
|
|
917
925
|
deps.recordThreadReconciliationPlan?.(pendingCleanupPlan);
|
|
918
926
|
const cleanupPorts = {
|
|
927
|
+
isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.topicTargetStore),
|
|
919
928
|
callApi: deps.callApi,
|
|
920
929
|
markStaleByTarget: deps.topicTargetStore.markStaleByTarget,
|
|
921
930
|
removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
|
package/lib/bus.ts
CHANGED
|
@@ -1450,18 +1450,25 @@ export function createTelegramBusFollowerThreadRestoreHandler(
|
|
|
1450
1450
|
return async ({ record, target, oldTarget }) => {
|
|
1451
1451
|
if (!record.instanceId) return false;
|
|
1452
1452
|
const follower = deps.followerRegistry.get(record.instanceId);
|
|
1453
|
-
if (!follower
|
|
1453
|
+
if (!follower?.registrationGeneration || !oldTarget ||
|
|
1454
|
+
follower.target?.chatId !== oldTarget.chatId ||
|
|
1455
|
+
follower.target.threadId !== oldTarget.threadId ||
|
|
1456
|
+
target.chatId !== oldTarget.chatId || target.threadId === oldTarget.threadId) return false;
|
|
1454
1457
|
const replaced = await deps.followerTargetController.replaceTarget({
|
|
1455
1458
|
follower,
|
|
1456
1459
|
target,
|
|
1457
1460
|
oldTarget,
|
|
1458
1461
|
reason: "thread-restore",
|
|
1459
1462
|
});
|
|
1460
|
-
|
|
1463
|
+
const current = deps.followerRegistry.get(record.instanceId);
|
|
1464
|
+
if (!replaced || !current ||
|
|
1465
|
+
current.registrationGeneration !== follower.registrationGeneration ||
|
|
1466
|
+
current.target?.chatId !== oldTarget.chatId ||
|
|
1467
|
+
current.target.threadId !== oldTarget.threadId) return false;
|
|
1461
1468
|
deps.followerRegistry.register({
|
|
1462
|
-
...
|
|
1469
|
+
...current,
|
|
1463
1470
|
target,
|
|
1464
|
-
connectedAtMs:
|
|
1471
|
+
connectedAtMs: current.connectedAtMs,
|
|
1465
1472
|
});
|
|
1466
1473
|
deps.onRestored?.();
|
|
1467
1474
|
return true;
|
package/lib/routing.ts
CHANGED
|
@@ -281,19 +281,17 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
|
|
|
281
281
|
"",
|
|
282
282
|
`You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.`,
|
|
283
283
|
"Select the Pi thread that should handle it:",
|
|
284
|
+
"To restore into a new thread, send a plain message in that destination thread first.",
|
|
284
285
|
].join("\n");
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
function buildTelegramUnboundRerouteChooserMarkup(
|
|
288
289
|
rerouteId: string,
|
|
289
290
|
records: readonly Threads.TelegramTopicTargetRecord[],
|
|
290
|
-
|
|
291
|
-
currentLeaderProfileKey?: string;
|
|
292
|
-
currentInstanceId?: string;
|
|
293
|
-
} = {},
|
|
291
|
+
options: { canRestore: boolean },
|
|
294
292
|
): Menu.TelegramReplyMarkup {
|
|
295
293
|
const activeRecords = records.filter((record) => record.status === "active");
|
|
296
|
-
const canRestoreAnyLiveThread = activeRecords.length > 0;
|
|
294
|
+
const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
|
|
297
295
|
const rows = activeRecords.map((record) => [
|
|
298
296
|
{
|
|
299
297
|
text: getTelegramRouteThreadButtonLabel(record),
|
|
@@ -433,6 +431,7 @@ async function deleteReservedTelegramTopicThroughReconciler(
|
|
|
433
431
|
});
|
|
434
432
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
435
433
|
await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
434
|
+
isCleanupTargetProtected: Threads.createTelegramCleanupTargetProtection(deps.threadStore),
|
|
436
435
|
callApi: deps.callApi,
|
|
437
436
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
438
437
|
deps.threadStore?.markStaleByTarget(
|
|
@@ -734,6 +733,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
734
733
|
instanceId?: string;
|
|
735
734
|
};
|
|
736
735
|
type PendingUnboundReroute = {
|
|
736
|
+
sourceTarget: Queue.TelegramQueueTarget;
|
|
737
|
+
chooserMessageId?: number;
|
|
737
738
|
messages: TMessage[];
|
|
738
739
|
createdAtMs: number;
|
|
739
740
|
dispatchKind: "prompt" | "command";
|
|
@@ -842,12 +843,33 @@ export function createTelegramInboundRouteRuntime<
|
|
|
842
843
|
nextUnboundRerouteId += 1;
|
|
843
844
|
const id = nextUnboundRerouteId.toString(36);
|
|
844
845
|
pendingUnboundReroutes.set(id, {
|
|
846
|
+
sourceTarget: {
|
|
847
|
+
chatId: messages[0]!.chat.id,
|
|
848
|
+
...(typeof messages[0]!.message_thread_id === "number"
|
|
849
|
+
? { threadId: messages[0]!.message_thread_id }
|
|
850
|
+
: {}),
|
|
851
|
+
},
|
|
845
852
|
messages,
|
|
846
853
|
createdAtMs: Date.now(),
|
|
847
854
|
dispatchKind,
|
|
848
855
|
});
|
|
849
856
|
return id;
|
|
850
857
|
};
|
|
858
|
+
const rememberRerouteChooser = (id: string, messageId: number | undefined): void => {
|
|
859
|
+
const pending = pendingUnboundReroutes.get(id);
|
|
860
|
+
if (pending) pending.chooserMessageId = messageId;
|
|
861
|
+
};
|
|
862
|
+
const matchesRerouteChooser = (
|
|
863
|
+
pending: PendingUnboundReroute,
|
|
864
|
+
query: TCallbackQuery,
|
|
865
|
+
): boolean => {
|
|
866
|
+
const message = query.message;
|
|
867
|
+
return !!message && pending.chooserMessageId !== undefined &&
|
|
868
|
+
message.message_id === pending.chooserMessageId &&
|
|
869
|
+
message.chat.id === pending.sourceTarget.chatId &&
|
|
870
|
+
(message.message_thread_id === undefined ||
|
|
871
|
+
message.message_thread_id === pending.sourceTarget.threadId);
|
|
872
|
+
};
|
|
851
873
|
const pendingUnboundRerouteMediaGroups = new Map<
|
|
852
874
|
string,
|
|
853
875
|
{
|
|
@@ -951,6 +973,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
951
973
|
assertExecutionCurrent?.();
|
|
952
974
|
deps.recordThreadReconciliationPlan?.(plan);
|
|
953
975
|
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
976
|
+
isCleanupTargetProtected(target) {
|
|
977
|
+
assertExecutionCurrent?.();
|
|
978
|
+
return isRerouteTargetProtected(target);
|
|
979
|
+
},
|
|
954
980
|
callApi: deps.callApi,
|
|
955
981
|
markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
|
|
956
982
|
deps.threadStore?.markStaleByTarget(
|
|
@@ -967,6 +993,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
967
993
|
assertExecutionCurrent?.();
|
|
968
994
|
return (result.incompleteActions?.length ?? 0) === 0;
|
|
969
995
|
};
|
|
996
|
+
const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget): boolean => {
|
|
997
|
+
const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
|
|
998
|
+
candidate.chatId === target.chatId && candidate.threadId === target.threadId;
|
|
999
|
+
return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
|
|
1000
|
+
(deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
|
|
1001
|
+
(deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
|
|
1002
|
+
(deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
|
|
1003
|
+
};
|
|
970
1004
|
const dismissRerouteChooserMessage = async (
|
|
971
1005
|
query: TCallbackQuery,
|
|
972
1006
|
assertExecutionCurrent?: () => void,
|
|
@@ -986,6 +1020,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
986
1020
|
assertExecutionCurrent?.();
|
|
987
1021
|
return true;
|
|
988
1022
|
} catch (error) {
|
|
1023
|
+
assertExecutionCurrent?.();
|
|
989
1024
|
deps.recordRuntimeEvent?.("telegram", error, {
|
|
990
1025
|
phase: "reroute-chooser-delete",
|
|
991
1026
|
chatId,
|
|
@@ -1103,12 +1138,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1103
1138
|
pending: PendingUnboundReroute,
|
|
1104
1139
|
query: TCallbackQuery,
|
|
1105
1140
|
successMessage: string,
|
|
1106
|
-
assertExecutionCurrent
|
|
1141
|
+
assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query),
|
|
1107
1142
|
): Promise<void> => {
|
|
1143
|
+
assertExecutionCurrent();
|
|
1108
1144
|
const dismissed = await dismissRerouteChooserMessage(
|
|
1109
1145
|
query,
|
|
1110
1146
|
assertExecutionCurrent,
|
|
1111
1147
|
);
|
|
1148
|
+
assertExecutionCurrent();
|
|
1112
1149
|
if (dismissed) {
|
|
1113
1150
|
pendingUnboundReroutes.delete(rerouteId);
|
|
1114
1151
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
@@ -1169,6 +1206,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1169
1206
|
query.data,
|
|
1170
1207
|
);
|
|
1171
1208
|
if (!parsed) return false;
|
|
1209
|
+
const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
|
|
1210
|
+
assertExecutionCurrent();
|
|
1172
1211
|
const chatId = query.message?.chat?.id;
|
|
1173
1212
|
const messageId = query.message?.message_id;
|
|
1174
1213
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
@@ -1176,12 +1215,18 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1176
1215
|
typeof chatId !== "number" ||
|
|
1177
1216
|
typeof messageId !== "number" ||
|
|
1178
1217
|
!deps.threadStore ||
|
|
1179
|
-
!pending
|
|
1218
|
+
!pending ||
|
|
1219
|
+
!matchesRerouteChooser(pending, query)
|
|
1180
1220
|
) {
|
|
1181
1221
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1182
1222
|
return true;
|
|
1183
1223
|
}
|
|
1224
|
+
if (pending.sourceTarget.threadId === undefined) {
|
|
1225
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1226
|
+
return true;
|
|
1227
|
+
}
|
|
1184
1228
|
await deps.threadStore.load();
|
|
1229
|
+
assertExecutionCurrent();
|
|
1185
1230
|
const activeRecords = getTelegramRoutableThreadRecords(
|
|
1186
1231
|
deps.threadStore.list(),
|
|
1187
1232
|
deps.getLiveThreadTargets?.(),
|
|
@@ -1199,19 +1244,17 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1199
1244
|
replyMarkup,
|
|
1200
1245
|
);
|
|
1201
1246
|
} else if (deps.sendInteractiveMessage) {
|
|
1202
|
-
await deps.sendInteractiveMessage(
|
|
1247
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
1203
1248
|
chatId,
|
|
1204
1249
|
formatTelegramUnboundRerouteRestoreChooserText(),
|
|
1205
1250
|
"html",
|
|
1206
1251
|
replyMarkup,
|
|
1207
|
-
|
|
1208
|
-
? {
|
|
1209
|
-
target: { chatId, threadId: query.message.message_thread_id },
|
|
1210
|
-
replyToMessageId: messageId,
|
|
1211
|
-
}
|
|
1212
|
-
: undefined,
|
|
1252
|
+
{ target: pending.sourceTarget, replyToMessageId: messageId },
|
|
1213
1253
|
);
|
|
1254
|
+
assertExecutionCurrent();
|
|
1255
|
+
rememberRerouteChooser(parsed.rerouteId, chooserId);
|
|
1214
1256
|
}
|
|
1257
|
+
assertExecutionCurrent();
|
|
1215
1258
|
await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
|
|
1216
1259
|
return true;
|
|
1217
1260
|
};
|
|
@@ -1226,7 +1269,8 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1226
1269
|
assertExecutionCurrent();
|
|
1227
1270
|
const chatId = query.message?.chat?.id;
|
|
1228
1271
|
const pending = pendingUnboundReroutes.get(parsed.rerouteId);
|
|
1229
|
-
if (typeof chatId !== "number" || !deps.threadStore || !pending
|
|
1272
|
+
if (typeof chatId !== "number" || !deps.threadStore || !pending ||
|
|
1273
|
+
!matchesRerouteChooser(pending, query)) {
|
|
1230
1274
|
await deps.answerCallbackQuery(query.id, "Message route expired.");
|
|
1231
1275
|
return true;
|
|
1232
1276
|
}
|
|
@@ -1300,10 +1344,23 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1300
1344
|
parsed.threadId,
|
|
1301
1345
|
);
|
|
1302
1346
|
const sourceTarget =
|
|
1303
|
-
typeof
|
|
1304
|
-
? { chatId, threadId:
|
|
1347
|
+
typeof pending.sourceTarget.threadId === "number"
|
|
1348
|
+
? { chatId, threadId: pending.sourceTarget.threadId }
|
|
1305
1349
|
: undefined;
|
|
1306
|
-
const sourceMessageId =
|
|
1350
|
+
const sourceMessageId = pending.chooserMessageId;
|
|
1351
|
+
if (parsed.useNewSlot && !sourceTarget) {
|
|
1352
|
+
await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
|
|
1353
|
+
return true;
|
|
1354
|
+
}
|
|
1355
|
+
if (parsed.useNewSlot && sourceTarget && record.target.chatId === sourceTarget.chatId &&
|
|
1356
|
+
record.target.threadId === sourceTarget.threadId) {
|
|
1357
|
+
await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
|
|
1361
|
+
await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1307
1364
|
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1308
1365
|
const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
|
|
1309
1366
|
const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
|
|
@@ -1507,6 +1564,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1507
1564
|
rerouteConfirmedAtMs: nowMs,
|
|
1508
1565
|
});
|
|
1509
1566
|
await deps.threadStore.persist();
|
|
1567
|
+
assertExecutionCurrent();
|
|
1510
1568
|
deps.setCurrentLeaderIdentity?.({
|
|
1511
1569
|
target: sourceTarget,
|
|
1512
1570
|
slot,
|
|
@@ -1944,7 +2002,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1944
2002
|
};
|
|
1945
2003
|
const sendUnboundRerouteChooserNow = async (
|
|
1946
2004
|
messages: TMessage[],
|
|
1947
|
-
|
|
2005
|
+
_ctx: TContext,
|
|
1948
2006
|
reportDeferred = true,
|
|
1949
2007
|
): Promise<void> => {
|
|
1950
2008
|
const message = messages[0];
|
|
@@ -1988,20 +2046,13 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1988
2046
|
const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
|
|
1989
2047
|
includeGuidance,
|
|
1990
2048
|
});
|
|
1991
|
-
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1992
2049
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
1993
2050
|
rerouteId,
|
|
1994
2051
|
activeRecords,
|
|
1995
|
-
{
|
|
1996
|
-
currentLeaderProfileKey: getLeaderTopicProfileKey(
|
|
1997
|
-
ctx,
|
|
1998
|
-
currentInstanceId,
|
|
1999
|
-
),
|
|
2000
|
-
currentInstanceId,
|
|
2001
|
-
},
|
|
2052
|
+
{ canRestore: sourceTarget !== undefined },
|
|
2002
2053
|
);
|
|
2003
2054
|
if (deps.sendInteractiveMessage) {
|
|
2004
|
-
await deps.sendInteractiveMessage(
|
|
2055
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
2005
2056
|
message.chat.id,
|
|
2006
2057
|
text,
|
|
2007
2058
|
"html",
|
|
@@ -2010,12 +2061,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2010
2061
|
? { target: sourceTarget, replyToMessageId: message.message_id }
|
|
2011
2062
|
: { replyToMessageId: message.message_id },
|
|
2012
2063
|
);
|
|
2064
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2013
2065
|
return;
|
|
2014
2066
|
}
|
|
2015
|
-
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2067
|
+
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2016
2068
|
parseMode: "HTML",
|
|
2017
2069
|
target: sourceTarget,
|
|
2018
2070
|
});
|
|
2071
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2019
2072
|
};
|
|
2020
2073
|
const sendUnboundRerouteChooser = async (
|
|
2021
2074
|
message: TMessage,
|
|
@@ -2080,9 +2133,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2080
2133
|
const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
|
|
2081
2134
|
rerouteId,
|
|
2082
2135
|
activeRecords,
|
|
2136
|
+
{ canRestore: typeof message.message_thread_id === "number" },
|
|
2083
2137
|
);
|
|
2084
2138
|
if (deps.sendInteractiveMessage) {
|
|
2085
|
-
await deps.sendInteractiveMessage(
|
|
2139
|
+
const chooserId = await deps.sendInteractiveMessage(
|
|
2086
2140
|
message.chat.id,
|
|
2087
2141
|
text,
|
|
2088
2142
|
"html",
|
|
@@ -2096,10 +2150,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2096
2150
|
}
|
|
2097
2151
|
: undefined,
|
|
2098
2152
|
);
|
|
2153
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2099
2154
|
return true;
|
|
2100
2155
|
}
|
|
2101
2156
|
if (deps.callApi) {
|
|
2102
|
-
await deps.callApi("sendMessage", {
|
|
2157
|
+
const chooser = await deps.callApi<{ message_id?: number }>("sendMessage", {
|
|
2103
2158
|
chat_id: message.chat.id,
|
|
2104
2159
|
text,
|
|
2105
2160
|
parse_mode: "HTML",
|
|
@@ -2116,12 +2171,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2116
2171
|
}
|
|
2117
2172
|
: {}),
|
|
2118
2173
|
});
|
|
2174
|
+
rememberRerouteChooser(rerouteId, chooser?.message_id);
|
|
2119
2175
|
return true;
|
|
2120
2176
|
}
|
|
2121
|
-
await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2177
|
+
const chooserId = await deps.sendTextReply(message.chat.id, message.message_id, text, {
|
|
2122
2178
|
parseMode: "HTML",
|
|
2123
2179
|
target: options.target,
|
|
2124
2180
|
});
|
|
2181
|
+
rememberRerouteChooser(rerouteId, chooserId);
|
|
2125
2182
|
return true;
|
|
2126
2183
|
};
|
|
2127
2184
|
const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
|
package/lib/sync.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { getTelegramApiErrorRequestTarget } from "./telegram-api.ts";
|
|
7
|
+
import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
|
|
8
8
|
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
|
|
9
9
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
10
10
|
import {
|
|
11
|
+
createTelegramCleanupTargetProtection,
|
|
11
12
|
getTelegramTargetFromApiBody,
|
|
12
13
|
isTelegramTopicTargetStaleError,
|
|
13
14
|
provisionOwnBusTopic,
|
|
@@ -121,6 +122,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
|
|
|
121
122
|
| undefined;
|
|
122
123
|
topicTargetStore: Pick<
|
|
123
124
|
TelegramTopicTargetStore,
|
|
125
|
+
| "list"
|
|
124
126
|
| "markStaleByTarget"
|
|
125
127
|
| "persist"
|
|
126
128
|
| "upsertPendingCleanup"
|
|
@@ -247,16 +249,21 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
247
249
|
target,
|
|
248
250
|
requestedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
249
251
|
};
|
|
252
|
+
const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
|
|
253
|
+
record.target.chatId === target.chatId && record.target.threadId === target.threadId);
|
|
254
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
|
|
250
255
|
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
251
256
|
await deps.topicTargetStore.persist();
|
|
257
|
+
const cleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
258
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
259
|
+
currentLeaderEpoch: leaderEpoch,
|
|
260
|
+
records: [],
|
|
261
|
+
pendingCleanups: [intent],
|
|
262
|
+
});
|
|
252
263
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
253
|
-
|
|
254
|
-
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
255
|
-
currentLeaderEpoch: leaderEpoch,
|
|
256
|
-
records: [],
|
|
257
|
-
pendingCleanups: [intent],
|
|
258
|
-
}),
|
|
264
|
+
cleanupPlan,
|
|
259
265
|
{
|
|
266
|
+
isCleanupTargetProtected,
|
|
260
267
|
callApi(method, body) {
|
|
261
268
|
return deps.callApi(method, body);
|
|
262
269
|
},
|
|
@@ -277,6 +284,9 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
277
284
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
278
285
|
},
|
|
279
286
|
);
|
|
287
|
+
if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
|
|
288
|
+
return "Thread disconnect superseded by a new binding.";
|
|
289
|
+
}
|
|
280
290
|
cleanupPending = Boolean(cleanup.incompleteActions?.length);
|
|
281
291
|
}
|
|
282
292
|
const leaderTarget = deps.getLeaderTarget();
|
|
@@ -404,7 +414,7 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
|
|
|
404
414
|
topicTargetStore: Pick<
|
|
405
415
|
TelegramTopicTargetStore,
|
|
406
416
|
"load" | "markStaleByTarget" | "persist"
|
|
407
|
-
|
|
417
|
+
> & Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
|
|
408
418
|
getSyncState: () => TSyncState;
|
|
409
419
|
setSyncState: (state: TSyncState) => void;
|
|
410
420
|
recordEvent: (
|
|
@@ -413,6 +423,46 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
|
|
|
413
423
|
details?: Record<string, unknown>,
|
|
414
424
|
) => void;
|
|
415
425
|
getNowMs?: () => number;
|
|
426
|
+
isCurrent?: () => boolean;
|
|
427
|
+
isAuthorityCurrent?: () => boolean;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function captureTelegramStaleTargetRequestRecovery<TSyncState extends TelegramSyncState>(
|
|
431
|
+
body: Record<string, unknown>,
|
|
432
|
+
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> & {
|
|
433
|
+
topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget">;
|
|
434
|
+
getCurrentLeaderEpoch: () => number | string | undefined;
|
|
435
|
+
getSessionGeneration: () => number;
|
|
436
|
+
getProfileName: () => string | undefined;
|
|
437
|
+
onRecovered: () => void;
|
|
438
|
+
},
|
|
439
|
+
): ((error: unknown) => Promise<void>) | undefined {
|
|
440
|
+
const target = getTelegramTargetFromApiBody(body);
|
|
441
|
+
const epoch = deps.getCurrentLeaderEpoch();
|
|
442
|
+
if (!target || epoch === undefined) return undefined;
|
|
443
|
+
const key = getTelegramTargetKey(target);
|
|
444
|
+
const record = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
445
|
+
if (!record) return undefined;
|
|
446
|
+
const generation = deps.getSessionGeneration();
|
|
447
|
+
const profile = deps.getProfileName();
|
|
448
|
+
const isAuthorityCurrent = (): boolean => deps.getCurrentLeaderEpoch() === epoch &&
|
|
449
|
+
deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
|
|
450
|
+
const isCurrent = (): boolean => {
|
|
451
|
+
const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
452
|
+
return isAuthorityCurrent() &&
|
|
453
|
+
current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
|
|
454
|
+
current?.updatedAtMs === record.updatedAtMs && current?.createdAtMs === record.createdAtMs;
|
|
455
|
+
};
|
|
456
|
+
return async (error) => {
|
|
457
|
+
const requestTarget = getTelegramApiErrorRequestTarget(error);
|
|
458
|
+
if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
|
|
459
|
+
getTelegramTargetKey(requestTarget) !== key || !isCurrent()) return;
|
|
460
|
+
if (await recoverStaleTelegramTopicApiError(
|
|
461
|
+
{ chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent },
|
|
462
|
+
)) {
|
|
463
|
+
deps.onRecovered();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
416
466
|
}
|
|
417
467
|
|
|
418
468
|
export function createTelegramStaleTopicApiErrorRecoveryRuntime<
|
|
@@ -448,12 +498,16 @@ export async function recoverStaleTelegramTopicApiError<
|
|
|
448
498
|
deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState>,
|
|
449
499
|
): Promise<boolean> {
|
|
450
500
|
const target = getTelegramTargetFromApiBody(apiBody);
|
|
451
|
-
if (!target || !isTelegramTopicTargetStaleError(error)) return false;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return false;
|
|
501
|
+
if (!target || !isTelegramTopicTargetStaleError(error) || deps.isCurrent?.() === false) return false;
|
|
502
|
+
if (deps.isCurrent) {
|
|
503
|
+
if (!deps.topicTargetStore.invalidateTarget || !await deps.topicTargetStore.invalidateTarget(
|
|
504
|
+
target, deps.isCurrent, String(error),
|
|
505
|
+
)) return false;
|
|
506
|
+
if (deps.isAuthorityCurrent?.() === false) return false;
|
|
507
|
+
} else {
|
|
508
|
+
await deps.topicTargetStore.load();
|
|
509
|
+
if (!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error))) return false;
|
|
510
|
+
await deps.topicTargetStore.persist();
|
|
457
511
|
}
|
|
458
512
|
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
459
513
|
let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
@@ -466,8 +520,12 @@ export async function recoverStaleTelegramTopicApiError<
|
|
|
466
520
|
reason: "stale-api-error",
|
|
467
521
|
action: "topic-target-stale",
|
|
468
522
|
}) as TSyncState;
|
|
523
|
+
state = markTelegramSyncSliceSuspect(state, "target-bindings", {
|
|
524
|
+
nowMs,
|
|
525
|
+
reason: "stale-api-error",
|
|
526
|
+
action: "topic-target-stale",
|
|
527
|
+
}) as TSyncState;
|
|
469
528
|
deps.setSyncState(state);
|
|
470
|
-
await deps.topicTargetStore.persist();
|
|
471
529
|
deps.recordEvent("bus", error, {
|
|
472
530
|
phase: "topic-target-stale",
|
|
473
531
|
chatId: target.chatId,
|
|
@@ -606,6 +664,7 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
606
664
|
});
|
|
607
665
|
deps.recordThreadReconciliationPlan?.(replacementPlan);
|
|
608
666
|
await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
|
|
667
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
|
|
609
668
|
callApi: deps.callApi,
|
|
610
669
|
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
611
670
|
deps.topicTargetStore.markStaleByTarget(
|
package/lib/telegram-api.ts
CHANGED
|
@@ -466,6 +466,8 @@ export interface TelegramApiClient {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
export interface TelegramBridgeApiRuntimeDeps {
|
|
469
|
+
captureRequestErrorHandler?: (body: Record<string, unknown>) =>
|
|
470
|
+
((error: unknown) => Promise<void>) | undefined;
|
|
469
471
|
client: TelegramApiClient;
|
|
470
472
|
tempDir: string;
|
|
471
473
|
maxFileSizeBytes: number;
|
|
@@ -634,6 +636,11 @@ export function getTelegramApiErrorRequestTarget(
|
|
|
634
636
|
return target ? { ...target } : undefined;
|
|
635
637
|
}
|
|
636
638
|
|
|
639
|
+
export function isTelegramStaleTargetHttpError(error: unknown): boolean {
|
|
640
|
+
if (!(error instanceof TelegramApiHttpError) || error.status !== 400) return false;
|
|
641
|
+
return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
|
|
642
|
+
}
|
|
643
|
+
|
|
637
644
|
export function isTelegramMessageNotModifiedError(error: unknown): boolean {
|
|
638
645
|
return (
|
|
639
646
|
error instanceof Error && error.message.includes("message is not modified")
|
|
@@ -1438,6 +1445,7 @@ export function createTelegramAssistantDraftSender(deps: {
|
|
|
1438
1445
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
1439
1446
|
getBotToken: () => string | undefined;
|
|
1440
1447
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
1448
|
+
captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
|
|
1441
1449
|
}): TelegramBridgeApiRuntime {
|
|
1442
1450
|
return createTelegramBridgeApiRuntime({
|
|
1443
1451
|
client: createTelegramApiClient(deps.getBotToken, {
|
|
@@ -1447,12 +1455,23 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1447
1455
|
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
|
1448
1456
|
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
|
1449
1457
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1458
|
+
captureRequestErrorHandler: deps.captureRequestErrorHandler,
|
|
1450
1459
|
});
|
|
1451
1460
|
}
|
|
1452
1461
|
|
|
1453
1462
|
export function createTelegramBridgeApiRuntime(
|
|
1454
1463
|
deps: TelegramBridgeApiRuntimeDeps,
|
|
1455
1464
|
): TelegramBridgeApiRuntime {
|
|
1465
|
+
const recoverRequestError = async (
|
|
1466
|
+
handler: ((error: unknown) => Promise<void>) | undefined,
|
|
1467
|
+
error: unknown,
|
|
1468
|
+
): Promise<void> => {
|
|
1469
|
+
try {
|
|
1470
|
+
await handler?.(error);
|
|
1471
|
+
} catch (recoveryError) {
|
|
1472
|
+
deps.recordRuntimeEvent("api", recoveryError, { phase: "stale-target-recovery" });
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1456
1475
|
const now = deps.now ?? Date.now;
|
|
1457
1476
|
const chatActionMinIntervalMs = Math.max(
|
|
1458
1477
|
0,
|
|
@@ -1488,6 +1507,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1488
1507
|
body: Record<string, unknown>,
|
|
1489
1508
|
options?: TelegramApiCallOptions,
|
|
1490
1509
|
): Promise<TResponse> => {
|
|
1510
|
+
const recoverError = deps.captureRequestErrorHandler?.(body);
|
|
1491
1511
|
const chatActionKey = getChatActionKey(method, body);
|
|
1492
1512
|
if (chatActionKey) {
|
|
1493
1513
|
const nowMs = now();
|
|
@@ -1516,7 +1536,8 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1516
1536
|
gate.notBeforeMs = now() + chatActionMinIntervalMs;
|
|
1517
1537
|
return result;
|
|
1518
1538
|
})
|
|
1519
|
-
.catch((error: unknown) => {
|
|
1539
|
+
.catch(async (error: unknown) => {
|
|
1540
|
+
await recoverRequestError(recoverError, error);
|
|
1520
1541
|
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
1521
1542
|
const retryAfterMs = Math.max(
|
|
1522
1543
|
chatActionMinIntervalMs,
|
|
@@ -1550,6 +1571,12 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1550
1571
|
try {
|
|
1551
1572
|
return await deps.client.call<TResponse>(method, body, options);
|
|
1552
1573
|
} catch (error) {
|
|
1574
|
+
await recoverRequestError(recoverError, error);
|
|
1575
|
+
if (method === "deleteMessage" && error instanceof TelegramApiHttpError &&
|
|
1576
|
+
error.status === 400 && error.message ===
|
|
1577
|
+
"Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
|
|
1578
|
+
return true as TResponse;
|
|
1579
|
+
}
|
|
1553
1580
|
deps.recordRuntimeEvent(
|
|
1554
1581
|
"api",
|
|
1555
1582
|
error,
|
|
@@ -1574,6 +1601,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1574
1601
|
fileName,
|
|
1575
1602
|
options,
|
|
1576
1603
|
) => {
|
|
1604
|
+
const recoverError = deps.captureRequestErrorHandler?.(fields);
|
|
1577
1605
|
try {
|
|
1578
1606
|
return await deps.client.callMultipart(
|
|
1579
1607
|
method,
|
|
@@ -1584,6 +1612,7 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1584
1612
|
options,
|
|
1585
1613
|
);
|
|
1586
1614
|
} catch (error) {
|
|
1615
|
+
await recoverRequestError(recoverError, error);
|
|
1587
1616
|
deps.recordRuntimeEvent(
|
|
1588
1617
|
"multipart",
|
|
1589
1618
|
error,
|
|
@@ -1676,11 +1705,13 @@ export function createTelegramBridgeApiRuntime(
|
|
|
1676
1705
|
sendRichMessageDraft: (body) =>
|
|
1677
1706
|
callRecorded<boolean>("sendRichMessageDraft", body),
|
|
1678
1707
|
editMessageText: async (body) => {
|
|
1708
|
+
const recoverError = deps.captureRequestErrorHandler?.(body);
|
|
1679
1709
|
try {
|
|
1680
1710
|
await deps.client.call("editMessageText", body);
|
|
1681
1711
|
return "edited";
|
|
1682
1712
|
} catch (error) {
|
|
1683
1713
|
if (isTelegramMessageNotModifiedError(error)) return "unchanged";
|
|
1714
|
+
await recoverRequestError(recoverError, error);
|
|
1684
1715
|
deps.recordRuntimeEvent(
|
|
1685
1716
|
"api",
|
|
1686
1717
|
error,
|
package/lib/thread-reconciler.ts
CHANGED
|
@@ -202,6 +202,7 @@ export interface ThreadReconciliationApplyResult {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export interface ThreadReconciliationApplyPorts {
|
|
205
|
+
isCleanupTargetProtected?: (target: ThreadTarget, action: ThreadReconciliationAction) => boolean;
|
|
205
206
|
callApi?: <TResponse>(
|
|
206
207
|
method: string,
|
|
207
208
|
body: Record<string, unknown>,
|
|
@@ -576,6 +577,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
576
577
|
incompleteActions.push(action);
|
|
577
578
|
continue;
|
|
578
579
|
}
|
|
580
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
|
|
579
581
|
let closeConfirmed = false;
|
|
580
582
|
try {
|
|
581
583
|
await ports.callApi("closeForumTopic", {
|
|
@@ -598,6 +600,7 @@ export async function applyThreadReconciliationPlan(
|
|
|
598
600
|
incompleteActions.push(action);
|
|
599
601
|
continue;
|
|
600
602
|
}
|
|
603
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) continue;
|
|
601
604
|
const changed =
|
|
602
605
|
ports.markStaleByTarget?.(action.target, "closed") ?? false;
|
|
603
606
|
if (changed) persistFences.push(action);
|
|
@@ -632,7 +635,12 @@ export async function applyThreadReconciliationPlan(
|
|
|
632
635
|
continue;
|
|
633
636
|
}
|
|
634
637
|
let deleteConfirmed = false;
|
|
638
|
+
let superseded = false;
|
|
635
639
|
for (const method of ["closeForumTopic", "deleteForumTopic"]) {
|
|
640
|
+
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
641
|
+
superseded = true;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
636
644
|
if (shouldSkipForStaleLeaderEpoch(action, ports)) break;
|
|
637
645
|
try {
|
|
638
646
|
await ports.callApi(method, {
|
|
@@ -660,6 +668,15 @@ export async function applyThreadReconciliationPlan(
|
|
|
660
668
|
incompleteActions.push(action);
|
|
661
669
|
continue;
|
|
662
670
|
}
|
|
671
|
+
if (superseded || ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
672
|
+
ports.recordRuntimeEvent?.("telegram", "Cancelled cleanup of a protected Telegram target", {
|
|
673
|
+
phase: "thread-reconciler-cleanup-target-reused",
|
|
674
|
+
action: action.kind,
|
|
675
|
+
chatId: action.target.chatId,
|
|
676
|
+
threadId: action.target.threadId,
|
|
677
|
+
});
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
663
680
|
if (!deleteConfirmed) {
|
|
664
681
|
ports.recordRuntimeEvent?.(
|
|
665
682
|
"telegram",
|
package/lib/threads.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
writeFile,
|
|
22
22
|
} from "node:fs/promises";
|
|
23
23
|
import { dirname } from "node:path";
|
|
24
|
+
import { isDeepStrictEqual } from "node:util";
|
|
24
25
|
|
|
25
26
|
import {
|
|
26
27
|
isTelegramApiCommitUnknownError,
|
|
@@ -229,6 +230,11 @@ export interface TelegramTopicTargetStore {
|
|
|
229
230
|
/** Discard process-local projections and reload owner-published state. */
|
|
230
231
|
refresh?: () => Promise<void>;
|
|
231
232
|
persist: () => Promise<void>;
|
|
233
|
+
invalidateTarget: (
|
|
234
|
+
target: TelegramTarget,
|
|
235
|
+
isCurrent: () => boolean,
|
|
236
|
+
lastSyncError: string,
|
|
237
|
+
) => Promise<boolean>;
|
|
232
238
|
list: () => TelegramTopicTargetRecord[];
|
|
233
239
|
getFollowerRecoveryHintByTarget?: (
|
|
234
240
|
target: TelegramTarget,
|
|
@@ -316,6 +322,53 @@ export function reconcileTelegramFreshAllocationCursor(
|
|
|
316
322
|
return true;
|
|
317
323
|
}
|
|
318
324
|
|
|
325
|
+
export function createTelegramCleanupTargetProtection(
|
|
326
|
+
store: Pick<TelegramTopicTargetStore, "list"> & Partial<Pick<TelegramTopicTargetStore, "listReservations" | "listPendingProvisions" | "listPendingCleanups">>,
|
|
327
|
+
departingRecord?: TelegramTopicTargetRecord,
|
|
328
|
+
): NonNullable<ThreadReconciler.ThreadReconciliationApplyPorts["isCleanupTargetProtected"]> {
|
|
329
|
+
const records = store.list();
|
|
330
|
+
const reservations = store.listReservations?.() ?? [];
|
|
331
|
+
const provisions = store.listPendingProvisions?.() ?? [];
|
|
332
|
+
const intents = store.listPendingCleanups?.() ?? [];
|
|
333
|
+
// Persistence may reconstruct keys in another order and omit undefined
|
|
334
|
+
// optional fields; neither changes the authority represented by a snapshot.
|
|
335
|
+
const sameSnapshot = (left: unknown, right: unknown): boolean =>
|
|
336
|
+
isDeepStrictEqual(JSON.parse(JSON.stringify(left)), JSON.parse(JSON.stringify(right)));
|
|
337
|
+
return (target, action) => {
|
|
338
|
+
for (const record of store.list()) {
|
|
339
|
+
if (!targetMatches(record.target, target)) continue;
|
|
340
|
+
// A persisted shutdown intent may retire only its original pre-intent
|
|
341
|
+
// binding. Registration/rebinding after that intent supersedes it.
|
|
342
|
+
const intent = action.kind === "close-delete-graceful-shutdown-topic"
|
|
343
|
+
? intents.find((candidate) => candidate.id === action.cleanupIntentId && candidate.runtimeGeneration === action.runtimeGeneration)
|
|
344
|
+
: undefined;
|
|
345
|
+
const expectedDeparting = departingRecord ?? (intent && records.find((candidate) =>
|
|
346
|
+
candidate.instanceId === intent.instanceId && targetMatches(candidate.target, intent.target) &&
|
|
347
|
+
candidate.updatedAtMs <= intent.requestedAtMs));
|
|
348
|
+
if (expectedDeparting && "instanceId" in action &&
|
|
349
|
+
(action.kind === "close-delete-previous-leader-topic" || action.instanceId === expectedDeparting.instanceId) &&
|
|
350
|
+
sameSnapshot(record, expectedDeparting)) {
|
|
351
|
+
if (action.kind === "close-delete-previous-leader-topic" || action.kind === "close-stale-replaced-topic") continue;
|
|
352
|
+
if (action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
353
|
+
store.listPendingCleanups?.().some((intent) => intent.id === action.cleanupIntentId &&
|
|
354
|
+
intent.instanceId === action.instanceId && intent.runtimeGeneration === action.runtimeGeneration &&
|
|
355
|
+
targetMatches(intent.target, target))) continue;
|
|
356
|
+
}
|
|
357
|
+
if (record.status === "active" || record.status === "starting" || record.status === "pending" || record.status === "probe-required") return true;
|
|
358
|
+
}
|
|
359
|
+
for (const reservation of store.listReservations?.() ?? []) {
|
|
360
|
+
if (!targetMatches(reservation.target, target)) continue;
|
|
361
|
+
if (action.kind !== "close-delete-reserved-topic" || !reservations.some((initial) => sameSnapshot(initial, reservation))) return true;
|
|
362
|
+
}
|
|
363
|
+
for (const provision of store.listPendingProvisions?.() ?? []) {
|
|
364
|
+
if (!provision.target || !targetMatches(provision.target, target)) continue;
|
|
365
|
+
if (action.kind !== "close-delete-expired-pending-provision-topic" || provision.id !== action.pendingProvisionId ||
|
|
366
|
+
!provisions.some((initial) => sameSnapshot(initial, provision))) return true;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
319
372
|
export interface TelegramTopicTargetStoreOptions {
|
|
320
373
|
path: string | (() => string);
|
|
321
374
|
getNowMs?: () => number;
|
|
@@ -1230,7 +1283,11 @@ export function createTelegramTopicTargetStore(
|
|
|
1230
1283
|
loaded = true;
|
|
1231
1284
|
return;
|
|
1232
1285
|
}
|
|
1286
|
+
const revision = mutationRevision;
|
|
1233
1287
|
const content = await readFile(path, "utf8");
|
|
1288
|
+
// A read begun before a local mutation must not replace the newly admitted
|
|
1289
|
+
// binding/cleanup state with its older disk snapshot.
|
|
1290
|
+
if (mutationRevision !== revision || getPath() !== path) return;
|
|
1234
1291
|
const rawFile: unknown = JSON.parse(content);
|
|
1235
1292
|
const file = parseTopicTargetFile(rawFile);
|
|
1236
1293
|
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
@@ -1293,25 +1350,20 @@ export function createTelegramTopicTargetStore(
|
|
|
1293
1350
|
mutationRevision += 1;
|
|
1294
1351
|
};
|
|
1295
1352
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
refresh() {
|
|
1302
|
-
const refresh = persistQueue.then(loadFromDisk);
|
|
1303
|
-
persistQueue = refresh.catch(() => undefined);
|
|
1304
|
-
return refresh;
|
|
1305
|
-
},
|
|
1306
|
-
persist() {
|
|
1353
|
+
const persistSnapshot = (invalidation?: {
|
|
1354
|
+
target: TelegramTarget;
|
|
1355
|
+
isCurrent: () => boolean;
|
|
1356
|
+
lastSyncError: string;
|
|
1357
|
+
}): Promise<boolean> => {
|
|
1307
1358
|
const persist = persistQueue.then(async () => {
|
|
1308
1359
|
const path = getPath();
|
|
1309
1360
|
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
1310
1361
|
if (options.canPersist && !options.canPersist()) {
|
|
1311
|
-
await loadFromDisk();
|
|
1312
|
-
return;
|
|
1362
|
+
if (!invalidation) await loadFromDisk();
|
|
1363
|
+
return false;
|
|
1313
1364
|
}
|
|
1314
1365
|
if (!dirty || !loaded) await loadFromDisk();
|
|
1366
|
+
if (invalidation && !invalidation.isCurrent()) return false;
|
|
1315
1367
|
await mkdir(dirname(path), { recursive: true });
|
|
1316
1368
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1317
1369
|
const nowMs = getNowMs();
|
|
@@ -1359,6 +1411,21 @@ export function createTelegramTopicTargetStore(
|
|
|
1359
1411
|
return serialized;
|
|
1360
1412
|
}),
|
|
1361
1413
|
};
|
|
1414
|
+
if (invalidation) {
|
|
1415
|
+
const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
|
|
1416
|
+
if (!record) return false;
|
|
1417
|
+
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
1418
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
1419
|
+
file.syncObservations.push({
|
|
1420
|
+
target: { ...record.target },
|
|
1421
|
+
syncStatus: "deleted",
|
|
1422
|
+
observedAtMs: nowMs,
|
|
1423
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
1424
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1425
|
+
lastSyncError: invalidation.lastSyncError,
|
|
1426
|
+
lastReconcileAction: "mark-stale",
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1362
1429
|
let persistedSemanticSnapshot: string | undefined;
|
|
1363
1430
|
try {
|
|
1364
1431
|
persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot(
|
|
@@ -1375,7 +1442,7 @@ export function createTelegramTopicTargetStore(
|
|
|
1375
1442
|
) {
|
|
1376
1443
|
loaded = true;
|
|
1377
1444
|
dirty = false;
|
|
1378
|
-
return;
|
|
1445
|
+
return true;
|
|
1379
1446
|
}
|
|
1380
1447
|
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1381
1448
|
encoding: "utf8",
|
|
@@ -1383,6 +1450,25 @@ export function createTelegramTopicTargetStore(
|
|
|
1383
1450
|
});
|
|
1384
1451
|
await chmod(tempPath, 0o600);
|
|
1385
1452
|
try {
|
|
1453
|
+
if (invalidation) {
|
|
1454
|
+
let applied = false;
|
|
1455
|
+
const commit = () => {
|
|
1456
|
+
if (getPath() !== path || mutationRevision !== persistedRevision ||
|
|
1457
|
+
statusRevision !== persistedStatusRevision || !invalidation.isCurrent()) return;
|
|
1458
|
+
// Fence and rename share one synchronous commit boundary. No stale
|
|
1459
|
+
// invalidation enters the live projection before durable commit.
|
|
1460
|
+
renameSync(tempPath, path);
|
|
1461
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
|
|
1462
|
+
syncObservations = file.syncObservations;
|
|
1463
|
+
mutationRevision += 1;
|
|
1464
|
+
dirty = false;
|
|
1465
|
+
applied = true;
|
|
1466
|
+
};
|
|
1467
|
+
if (options.commitPersist) options.commitPersist(commit);
|
|
1468
|
+
else if (!options.canPersist || options.canPersist()) commit();
|
|
1469
|
+
if (!applied) await unlink(tempPath).catch(() => undefined);
|
|
1470
|
+
return applied;
|
|
1471
|
+
}
|
|
1386
1472
|
if (options.commitPersist) {
|
|
1387
1473
|
const committed = options.commitPersist(() => {
|
|
1388
1474
|
renameSync(tempPath, path);
|
|
@@ -1404,9 +1490,27 @@ export function createTelegramTopicTargetStore(
|
|
|
1404
1490
|
}
|
|
1405
1491
|
loaded = true;
|
|
1406
1492
|
if (mutationRevision === persistedRevision) dirty = false;
|
|
1493
|
+
return true;
|
|
1407
1494
|
});
|
|
1408
|
-
persistQueue = persist.
|
|
1495
|
+
persistQueue = persist.then(() => undefined, () => undefined);
|
|
1409
1496
|
return persist;
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
return {
|
|
1500
|
+
async load() {
|
|
1501
|
+
if (dirty) return;
|
|
1502
|
+
await loadFromDisk();
|
|
1503
|
+
},
|
|
1504
|
+
refresh() {
|
|
1505
|
+
const refresh = persistQueue.then(loadFromDisk);
|
|
1506
|
+
persistQueue = refresh.catch(() => undefined);
|
|
1507
|
+
return refresh;
|
|
1508
|
+
},
|
|
1509
|
+
async persist() {
|
|
1510
|
+
await persistSnapshot();
|
|
1511
|
+
},
|
|
1512
|
+
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
1513
|
+
return persistSnapshot({ target, isCurrent, lastSyncError });
|
|
1410
1514
|
},
|
|
1411
1515
|
list() {
|
|
1412
1516
|
return Array.from(records.values()).map(cloneRecord);
|
|
@@ -2100,6 +2204,7 @@ export async function provisionOwnBusTopic(
|
|
|
2100
2204
|
if (typeof chatId !== "number") return undefined;
|
|
2101
2205
|
await deps.store.load();
|
|
2102
2206
|
const reservationCleanupPorts = {
|
|
2207
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.store),
|
|
2103
2208
|
callApi: deps.callApi,
|
|
2104
2209
|
markStaleByTarget: (
|
|
2105
2210
|
target: TelegramTarget & { threadId: number },
|
|
@@ -2253,9 +2358,11 @@ export async function provisionOwnBusTopic(
|
|
|
2253
2358
|
continue;
|
|
2254
2359
|
}
|
|
2255
2360
|
const previousLeaderCleanupStartedAtMs = Date.now();
|
|
2361
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.store, record);
|
|
2256
2362
|
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
2257
2363
|
{ actions: [action] },
|
|
2258
2364
|
{
|
|
2365
|
+
isCleanupTargetProtected,
|
|
2259
2366
|
callApi: deps.callApi,
|
|
2260
2367
|
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
2261
2368
|
deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
@@ -2304,6 +2411,7 @@ export async function provisionOwnBusTopic(
|
|
|
2304
2411
|
"Previous Telegram leader topic deletion was not confirmed.",
|
|
2305
2412
|
);
|
|
2306
2413
|
}
|
|
2414
|
+
if (isCleanupTargetProtected(action.target, action)) continue;
|
|
2307
2415
|
deps.store.markStaleByTarget(record.target);
|
|
2308
2416
|
deps.store.reserveThread({
|
|
2309
2417
|
target: record.target,
|