@llblab/pi-telegram 0.20.3 → 0.20.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/AGENTS.md +3 -3
- package/CHANGELOG.md +8 -0
- package/docs/architecture.md +1 -1
- package/docs/multi-instance-bus.md +3 -3
- package/index.ts +50 -21
- package/lib/bus-follower.ts +11 -0
- package/lib/bus-leader.ts +74 -1
- package/lib/bus.ts +4 -0
- package/lib/threads.ts +113 -2
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -79,12 +79,12 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
79
79
|
- In Threaded Mode, exactly one live leader owns `getUpdates` and direct Telegram Bot API calls for one bot token. `/telegram-connect` means join the local Telegram organism: if no live leader exists the instance becomes leader; if a live leader exists the terminal-visible instance registers as follower; ordinary connect must not offer manual takeover while the leader heartbeat is live. Leadership is an ephemeral transport role, not permanent ownership by the first terminal-visible Pi instance. Followers must be started by the operator as real Pi processes, register over the local bus, heartbeat, receive only their routed inbound updates, and send outbound replies/previews/files/chat-actions through the leader transport; Telegram must not spawn hidden Pi processes, launch follower subprocesses, or expose a slash command that creates a new local Pi instance. If a leader reloads while followers stay alive, followers must treat rejected/unknown/unauthorized heartbeats as a registration-loss signal and optimistically re-register with the currently active leader before considering promotion; after a short grace window without successful re-registration, a live follower may promote itself and force-acquire transport leadership so the organism degrades forward instead of staying split-brain. Terminal status must not remain `follower` while the leader registry can no longer route that follower's thread. Heartbeat pruning is liveness bookkeeping, not permission to delete, mark offline, or send a Telegram-visible disconnected notice for a follower's thread; preserve active follower tabs/bindings across transient reload/reconnect incidents and reserve destructive teardown/offline transitions for explicit disconnect or confirmed reconciliation cases. If the leader dies, a live follower should eventually be able to take over through stale-leader election/recovery so the local multi-instance organism keeps running. Explicit `/telegram-disconnect` is a manual thread teardown: a leader deletes its own bound thread before releasing transport ownership, while a follower asks the leader to delete its assigned thread through scoped bus API before unregistering. Status is reactive to the effective runtime role and bot capability: classic/non-thread mode shows ordinary `telegram connected`, active bus leadership shows `telegram leader`, and registered followers show `telegram follower`; stable multi-instance truth belongs in the bus role (`leader`/`follower`), while lifecycle is reserved for transient recovery state such as `electing` and must not duplicate stable roles with labels like `leader-active`. When leadership or Threaded Mode availability changes at runtime, the status line and `/telegram-status` must converge promptly instead of preserving a stale role label.
|
|
80
80
|
- Runtime routing binds to a live `instanceId`; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Compatibility keys may be derived internally, but stale session records must not be treated as live owners. A live instance owns at most one active bound thread; provisioning a new target for the same `instanceId` must remove older current-state bindings and close duplicate Telegram threads when possible. A reload creates a new runtime `instanceId`; if the previous leader slot is still known/alive, keep it occupied in reservations and assign the new leader the next free slot instead of creating a duplicate same-letter tab or blocking startup on Telegram UI convergence.
|
|
81
81
|
- Follower registration readiness should stay on the smallest critical path that makes the follower routable: create or reuse the Telegram target, persist the active binding, mark target-bindings fresh, and return the target to the follower. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; do not key durable follower ownership only by the short-lived Pi process id. Telegram-visible connected notices, duplicate/replaced thread cleanup, and other reconciliation work are important but non-critical; run them after registration and record slow/failing background steps as runtime events rather than delaying follower usability.
|
|
82
|
-
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings may survive through a bounded leader-reload re-registration grace window. Successful target reuse must refresh the binding timestamp, and post-reload compaction must preserve a recently refreshed binding across the brief registry gap caused by follower session replacement; older records without a live owner leave current `state.json` without implying destructive Telegram thread deletion. When post-grace follower compaction removes the binding represented by `bot.lastSlot`, realign the cursor to the newest-created remaining live binding in the same reconciliation pass; preserve a cursor that still belongs to a live binding and continue skipping unexpired pending/reserved slots. Do not reinterpret an otherwise orphaned cursor outside compaction because normal thread deletion may intentionally preserve ring sequence. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
83
|
-
- Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status
|
|
82
|
+
- Instance slots are extension-owned ring-order metadata. Allocate fresh slots from the `bot.lastSlot` cursor as `A` → `B` → ... → `Z` → `A`, allowing the cursor to move to lower letters after wraparound. Only current live/recovering bindings plus unexpired pending provisions and explicit reservations occupy slots; historical records must not block allocation indefinitely. Preserve a slot on ordinary current binding/thread reuse without advancing or rewinding the fresh-allocation cursor. Follower bindings may survive through a bounded leader-reload re-registration grace window. Successful target reuse must refresh the binding timestamp, and post-reload compaction must preserve a recently refreshed binding across the brief registry gap caused by follower session replacement; older records without a live owner leave current `state.json` without implying destructive Telegram thread deletion. An authenticated live follower whose carried exact target is missing from current persisted bindings should recover that target instead of creating another Telegram thread; preserve a carried slot only when it does not collide with another current record. When post-grace follower compaction removes the binding represented by `bot.lastSlot`, realign the cursor to the newest-created remaining live binding in the same reconciliation pass; preserve a cursor that still belongs to a live binding and continue skipping unexpired pending/reserved slots. Do not reinterpret an otherwise orphaned cursor outside compaction because normal thread deletion may intentionally preserve ring sequence. The alphabet cap is a feature — compact single-letter tabs are scannable.
|
|
83
|
+
- Instance thread names are provisioner-authored identity metadata, not model-authored output. Fresh Threaded Mode threads select one of five curated compact 4-6 letter Latin names for the assigned slot letter using provisioning timestamp entropy, then create the Telegram thread with that thread name immediately. The title is the thread name itself, not `{slot} {threadName}` or `{slot} — {threadName}`. Do not register or reintroduce an agent-facing thread rename tool; removing the extra rename prompt/tool turn is an intentional simplicity constraint. Thread names must mirror into terminal status, live diagnostics, and `[telegram|thread:name]` through one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with the record used only as fallback; do not hide a valid baked/custom thread name behind old slot-prefix validation. A follower that later becomes leader keeps its existing name; leadership changes are transport role changes, not identity resets.
|
|
84
84
|
- Telegram private-chat Threaded Mode treats `All` as an aggregate/control surface, not a durable `General` thread and not a process launcher. Leader activation creates/reuses a leader thread in the private bot DM (chat = allowedUserId) proactively. If all threads are closed and the owner writes from `All`, a first new unbound thread should be reclaimed by the leader when the leader lacks an active bound thread. Later unknown threads require explicit routing or restore; creating another Pi instance is a manual terminal action followed by `/telegram-connect`.
|
|
85
85
|
- Unbound thread detection: when a message arrives from the owner in an unknown `message_thread_id` while Threaded Mode is active, the default runtime first reclaims it for the leader if the leader has no active bound thread and routes the prompt locally. Later unknown threads are preserved by default and receive an in-thread reroute chooser that can send the captured prompt to a live thread or restore a stale leader/follower binding through explicit restore actions; destructive cleanup happens only after explicit user confirmation and through `thread-reconciler`. If the operator wants another Pi instance, they start Pi in a terminal and run `/telegram-connect`; Telegram-originated hidden auto-spawn and `/thread`-style process launch flows are intentionally absent. If Threaded Mode is unavailable, the message is processed normally through classic routing.
|
|
86
86
|
- Telegram extension work should not hold Pi's core agent lifecycle hostage once Pi has produced the semantic result. For Telegram-originated turns, final Telegram delivery, attachment upload, and transport cleanup are extension-owned side effects; schedule them off the critical `agent_end` path when ordering can still be preserved, record failures as runtime events, and keep dispatch of the next Telegram turn behind the delivery task when needed to avoid interleaving previews/finals.
|
|
87
|
-
- Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
|
|
87
|
+
- Telegram runtime state should be treated as event-driven reconciliation of extension assumptions against observable Telegram signals, not as a full Telegram bot read-model and not as a reason to query Telegram on every action. Keep a local assumption model for bot identity/capabilities, pairing assumptions, thread support, known thread lifecycle, bound instance targets, reservations, and transport health. Invalidate and reconcile the relevant slice on meaningful events: startup/reload, lifecycle service messages, stale-send/API errors, setup/pairing changes, follower register/prune, explicit status/debug requests, and bounded low-frequency leader health ticks. `lib/sync.ts` owns sync slices, observation intake, invalidation triggers, status/debug freshness, and reconciliation scheduling; it must not promise complete bot-state mirroring because Bot API lacks a complete topic listing surface. `lib/thread-reconciler.ts` owns pure Threaded Mode lifecycle planning and should become the only policy authority for destructive thread cleanup decisions. `threads` owns current Telegram UI thread/tab binding primitives and thread-name helpers mapped to Bot API topic transport, `status` renders projections, and `index.ts` only wires ports. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging and resets on extension start / runtime scope changes; it is diagnostics only, not routing/provisioning authority. `state.json` should be an observable/debug snapshot aligned with `/telegram-status` (extension runtime, bot capabilities including `bot.lastSlot`, runtime role, live roster projection, reservations, diagnostics), not routing/provisioning authority. Because every process on one Telegram profile shares this file, only the active transport lock owner may persist it; followers read current state and gain write authority only after promotion. Status-only writes must refresh disk-backed bindings before serialization so a stale loaded snapshot cannot erase newer leader state. Live bus/runtime state is authoritative; file reservations and fresh capability observations may be startup hints/collision guards only; stale snapshots must re-probe before suppressing bus/topic behavior. Do not persist stale/offline/failed thread target history as source of truth.
|
|
88
88
|
- Thread bindings are bidirectional. From the Pi-instance side, an instance knows and preserves its target, slot, thread name, lifecycle state, and direct-delivery defaults. From the Telegram-client/bot side, the bridge observes thread creation/close/reopen/stale-send signals and reconciles them into instance binding state. Lifecycle transitions that affect operator understanding should be Telegram-visible when a live transport remains: every successful follower registration/re-registration gets a compact connected notice in the assigned thread, while heartbeat pruning stays silent because it is only liveness suspicion. These notices should use the instance thread name when known and fall back to the slot letter only while the thread is still unnamed. New unknown threads (owner writing in `All`) are preserved until the user explicitly chooses a reroute/restore/new-instance action that makes cleanup eligible; any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
|
|
89
89
|
- The multi-instance bus uses private bot DMs with Telegram private-chat Threaded Mode enabled for the bot. No supergroup, group, or forum configuration is needed; the thread chat is always the private chat with the paired owner. Threaded capability checks must use bot/private-chat evidence such as `getMe.has_topics_enabled`, incoming `message_thread_id`, and topic operation success/failure; do not use group chat metadata as the control-plane truth for Telegram private-chat Threaded Mode.
|
|
90
90
|
- Target-scoped behavior must preserve `{ chatId, threadId? }` through inbound messages/edits/callbacks/reactions, thread lifecycle service messages, media and split-text grouping, queue mutations, active-turn cleanup, previews, reply deduplication, menus, sections, generated buttons, voice, attachments, and direct delivery. Threaded Mode replies must keep Telegram's reply affordance to the triggering message just like classic DM mode. In Telegram private-chat Threaded Mode, mobile Telegram has been live-verified to render `sendMessage` replies that include top-level `message_thread_id` plus same-chat `reply_parameters.message_id`; Telegram Desktop may fail to show the visual reply header for the same correct payload, so do not treat Desktop-only absence as bridge failure without mobile/payload evidence.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.4: Thread State Ownership Hotfix
|
|
4
|
+
|
|
5
|
+
- `[State Ownership]` Made the active transport lock owner the only process allowed to persist the profile-shared `state.json`; followers remain readers and acquire write authority only after promotion. Status-only persistence now refreshes disk-backed bindings before serialization. Impact: a stale follower diagnostics snapshot cannot erase newer leader-owned bindings, produce duplicate slot occupancy, or make a live follower disappear from current thread state.
|
|
6
|
+
- `[Follower Recovery]` Followers now carry target, slot, and thread name during re-registration. When an authenticated live follower carries an exact target missing from persisted bindings, the leader recovers that target without creating another Telegram thread and restores its slot only when unoccupied; a matching previous live-roster observation can recover the name for an older follower runtime. Impact: damaged local state converges around the surviving Telegram tab instead of replacing it or preserving a duplicate letter.
|
|
7
|
+
- `[Thread Identity]` Unified terminal status and `[telegram|thread:name]` behind one target-aware current-instance identity resolver. Registered follower/leader metadata takes precedence over a stale shared record for the same target, with persisted state used only as fallback. Impact: when the prompt tag correctly identifies `Beacon`, terminal status can no longer regress to a previous thread name from stale state.
|
|
8
|
+
- `[Live Linux]` A clean same-directory bootstrap produced one leader and two followers with exact unique bindings `A / Aster / 524667`, `B / Beacon / 524669`, and `C / Cinder / 524671`; the live roster and persisted targets agreed without duplicate slots. Impact: multiple processes launched from the same cwd no longer expose the state-writer collision that originally duplicated slot `E`.
|
|
9
|
+
- `[Validation]` Added deterministic coverage for stale status writers, denied follower writes, promotion-time write authority, carried identity metadata, exact live-target recovery, collision-safe slot recovery, and status/prompt identity convergence. The full release validation passes with one platform-only named-pipe skip. Impact: the original corruption and cross-surface mismatch scenarios are release-gated.
|
|
10
|
+
|
|
3
11
|
## 0.20.3: Persistent Threads And Activity
|
|
4
12
|
|
|
5
13
|
- `[Follower Sessions]` Registered followers now snapshot their target, slot, and thread name before same-process Pi session replacement, stop the old receiver/heartbeat context, and automatically re-register the new session through the live leader. Impact: `/new` and `/reload` no longer intentionally leave a healthy follower disconnected or require another manual `/telegram-connect`.
|
package/docs/architecture.md
CHANGED
|
@@ -124,7 +124,7 @@ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile ch
|
|
|
124
124
|
|
|
125
125
|
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; profile bot/session fields and observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup is limited to stale scratch files and cannot redirect live traffic.
|
|
126
126
|
|
|
127
|
-
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp; post-leader-reload compaction preserves that recently refreshed binding across a brief follower session-replacement registry gap, while genuinely historical records still leave current state after grace. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected.
|
|
127
|
+
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping: it preserves the follower thread binding and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Successful follower target reuse refreshes the binding's recovery timestamp; post-leader-reload compaction preserves that recently refreshed binding across a brief follower session-replacement registry gap, while genuinely historical records still leave current state after grace. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers that target without creating a replacement Telegram thread; a carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
128
128
|
|
|
129
129
|
Follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then does that process register as a follower with an instance-scoped internal binding identity and cause the leader to create/reuse a thread for it. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection.
|
|
130
130
|
|
|
@@ -155,7 +155,7 @@ Leader election is heartbeat-gated and lock-backed:
|
|
|
155
155
|
4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
|
|
156
156
|
5. If several followers detect stale leadership, atomic compare/write lock acquisition ensures only one becomes leader.
|
|
157
157
|
|
|
158
|
-
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target so the new leader can reuse the same binding, then promote only after the grace window expires. Every successful reuse refreshes the binding timestamp. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes older follower records that still lack a live bus owner from current `state.json`, while preserving a recently refreshed binding across the brief registry gap caused by follower session replacement and never deleting Telegram tabs. This preserves real thread bindings through transient reload gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
158
|
+
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding, then promote only after the grace window expires. If the exact live target is absent from persisted bindings, the leader recovers it instead of creating another Telegram thread and keeps its carried slot only when that slot is free. Every successful reuse refreshes the binding timestamp. The leader does not restore persisted followers into the live registry speculatively: after a bounded re-registration grace window it removes older follower records that still lack a live bus owner from current `state.json`, while preserving a recently refreshed binding across the brief registry gap caused by follower session replacement and never deleting Telegram tabs. This preserves real thread bindings through transient reload gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
159
159
|
|
|
160
160
|
## Leader/Follower Communication
|
|
161
161
|
|
|
@@ -346,10 +346,10 @@ Rules:
|
|
|
346
346
|
Current state under the agent dir:
|
|
347
347
|
|
|
348
348
|
- `locks.json`: current bus leader identity, capability secret, heartbeat, and cleanup fencing epoch. The local bus endpoint is derived from the agent directory by default; legacy `busSocketPath` entries are tolerated but are not required.
|
|
349
|
-
- `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
|
|
349
|
+
- `tmp/telegram/state.json`: volatile extension+bot observable/debug snapshot, not routing authority. It writes `source: "snapshot"` and `writtenAtMs` so consumers do not confuse it with an authoritative database. Every process on one Telegram profile reads this shared path, but only the active transport lock owner may persist it; followers become writers only after promotion. Status-only persistence refreshes disk-backed bindings before serialization so an already-loaded stale view cannot erase newer leader records. It mirrors `/telegram-status`-style projections: top-level `bot` stores bot-wide capability state such as `threadMode: "unknown" | "enabled" | "disabled"`, `runtime` identifies leader/follower role and process status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors status/debug signals, `threads` stores current routeable bindings, `bot.lastSlot` stores the compact slot cursor used when all current threads are gone, and `reservations` records short-lived slot collision guards.
|
|
350
350
|
- Local bus endpoints: Unix-like platforms use `tmp/telegram/bus.sock` and `tmp/telegram/followers/*`; native Windows uses deterministic named pipes under `\\.\pipe\pi-telegram-...`. These are transient IPC endpoints, not durable routing state.
|
|
351
351
|
|
|
352
|
-
The bridge must not keep a durable `telegram-targets.json` history. Stale/offline/failed thread entries are reconciliation observations, not reusable source-of-truth state; persisting them increases collision risk. `sync` is event-driven assumption reconciliation, not full Telegram bot-state mirroring, because Bot API does not expose a complete topic/thread listing surface. `state.json` therefore exists for extension+bot observability, diagnostics, startup hints, and explaining reconciliation decisions; live bus/runtime state remains authoritative for routing and provisioning. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
|
|
352
|
+
The bridge must not keep a durable `telegram-targets.json` history. Stale/offline/failed thread entries are reconciliation observations, not reusable source-of-truth state; persisting them increases collision risk. `sync` is event-driven assumption reconciliation, not full Telegram bot-state mirroring, because Bot API does not expose a complete topic/thread listing surface. `state.json` therefore exists for extension+bot observability, diagnostics, startup hints, and explaining reconciliation decisions; live bus/runtime state remains authoritative for routing and provisioning. Non-current routeable thread bindings are pruned during load/persist; old session records must not be retained just to compute the next slot because `bot.lastSlot` is the only durable cursor. Previous-process leader bindings are treated as occupied TTL-bounded reservations until Telegram confirms deletion: reload/startup may close/delete/probe the old thread, known reservations are retried proactively on leader startup, and if Telegram still accepts the old thread id, the new leader should provision the next free slot (`B`, `C`, …) rather than creating a duplicate same-letter tab or blocking startup on Telegram UI convergence. Routing must use live current threads/follower registry, never reservations. The bus leader provisions its own thread during bus startup/connect and provisions follower threads on `follower.register`; registered followers also live in the leader's in-memory registry and communicate over the local bus socket. The live follower registry can resolve a follower by exact `{ chatId, threadId? }`; the leader uses that target ownership to forward message and edited-message updates to followers, and the follower receiver accepts those updates in addition to callbacks and reactions. Terminal status and `[telegram|thread:name]` resolve the matching current-instance identity through the same target-aware path, preferring registered local metadata over stale shared bindings. Media album grouping and split-text coalescing keys include the thread target, queue reaction mutations can scope by chat/thread to avoid cross-target message-id collisions, active-turn target is exposed for lifecycle cleanup and local direct-tool defaults, transport reply dedup is chat/thread-scoped, stored menu state is keyed by chat/message so callback state lookup cannot collide across chats, and generated button turns plus section prompt/open actions preserve the callback thread target. `telegram_message` and immediate `telegram_attach` delivery can also carry an explicit `thread_id` with `chat_id`; when a follower is registered, their default direct-tool target is the assigned thread target and the bus-aware API runtime routes the send through the leader instead of calling Bot API transport locally.
|
|
353
353
|
|
|
354
354
|
All files containing routing, chat ids, thread ids, or process details use private permissions and represent current state rather than historical target caches.
|
|
355
355
|
|
package/index.ts
CHANGED
|
@@ -142,6 +142,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
142
142
|
};
|
|
143
143
|
Config.bindGlobalTelegramConfigRuntime(configStore);
|
|
144
144
|
const configControls = Config.createTelegramConfigControls(configStore);
|
|
145
|
+
let canPersistThreadState = function (): boolean {
|
|
146
|
+
return false;
|
|
147
|
+
};
|
|
145
148
|
const threadStore = Threads.createTelegramTopicTargetStore({
|
|
146
149
|
path: function () {
|
|
147
150
|
return Threads.getTelegramTopicTargetsPath(
|
|
@@ -149,6 +152,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
149
152
|
configStore.getActiveProfileName(),
|
|
150
153
|
);
|
|
151
154
|
},
|
|
155
|
+
canPersist: function () {
|
|
156
|
+
return canPersistThreadState();
|
|
157
|
+
},
|
|
152
158
|
});
|
|
153
159
|
const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>({
|
|
154
160
|
key: Locks.createTelegramLockKeyResolver(configStore),
|
|
@@ -156,6 +162,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
156
162
|
busSecret: telegramBusAuthSecret,
|
|
157
163
|
staleHeartbeatMs: Locks.TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
|
|
158
164
|
});
|
|
165
|
+
canPersistThreadState = function (): boolean {
|
|
166
|
+
return lockRuntime.getState().kind === "active-here";
|
|
167
|
+
};
|
|
159
168
|
const lockOwnershipGuard =
|
|
160
169
|
Locks.createTelegramLockOwnershipGuard(lockRuntime);
|
|
161
170
|
const getCurrentLeaderEpoch = function (): number | undefined {
|
|
@@ -262,6 +271,38 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
262
271
|
}
|
|
263
272
|
return record;
|
|
264
273
|
};
|
|
274
|
+
const getCurrentInstanceThreadIdentity = function (
|
|
275
|
+
target?: Queue.TelegramQueueTarget,
|
|
276
|
+
): Threads.TelegramInstanceThreadIdentityCandidate {
|
|
277
|
+
const followerTarget = telegramBusFollowerRegistrationState.getTarget();
|
|
278
|
+
const record = target
|
|
279
|
+
? Threads.findCurrentTelegramInstanceThreadRecord({
|
|
280
|
+
records: threadStore.list(),
|
|
281
|
+
instanceId: telegramInstanceId,
|
|
282
|
+
preferredTarget: target,
|
|
283
|
+
})
|
|
284
|
+
: getCurrentThreadRecord();
|
|
285
|
+
return Threads.resolveTelegramInstanceThreadIdentity({
|
|
286
|
+
target,
|
|
287
|
+
follower:
|
|
288
|
+
telegramBusFollowerRegistrationState.isRegistered() && followerTarget
|
|
289
|
+
? {
|
|
290
|
+
target: followerTarget,
|
|
291
|
+
slot: telegramBusFollowerRegistrationState.getSlot(),
|
|
292
|
+
threadName:
|
|
293
|
+
telegramBusFollowerRegistrationState.getThreadName(),
|
|
294
|
+
}
|
|
295
|
+
: undefined,
|
|
296
|
+
leader: telegramBusLeaderTarget
|
|
297
|
+
? {
|
|
298
|
+
target: telegramBusLeaderTarget,
|
|
299
|
+
slot: telegramBusLeaderSlot,
|
|
300
|
+
threadName: telegramBusLeaderThreadName,
|
|
301
|
+
}
|
|
302
|
+
: undefined,
|
|
303
|
+
record,
|
|
304
|
+
});
|
|
305
|
+
};
|
|
265
306
|
const statusRuntime = Status.createTelegramBridgeStatusRuntime<
|
|
266
307
|
Pi.ExtensionContext,
|
|
267
308
|
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
|
@@ -337,19 +378,11 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
337
378
|
},
|
|
338
379
|
getInstanceSlot() {
|
|
339
380
|
if (threadStore.getBotState().threadMode === "disabled") return undefined;
|
|
340
|
-
return (
|
|
341
|
-
getCurrentThreadRecord()?.slot ??
|
|
342
|
-
telegramBusFollowerRegistrationState.getSlot() ??
|
|
343
|
-
telegramBusLeaderSlot
|
|
344
|
-
);
|
|
381
|
+
return getCurrentInstanceThreadIdentity().slot;
|
|
345
382
|
},
|
|
346
383
|
getInstanceThreadName() {
|
|
347
384
|
if (threadStore.getBotState().threadMode === "disabled") return undefined;
|
|
348
|
-
return (
|
|
349
|
-
getCurrentThreadRecord()?.threadName ??
|
|
350
|
-
telegramBusFollowerRegistrationState.getThreadName() ??
|
|
351
|
-
telegramBusLeaderThreadName
|
|
352
|
-
);
|
|
385
|
+
return getCurrentInstanceThreadIdentity().threadName;
|
|
353
386
|
},
|
|
354
387
|
});
|
|
355
388
|
const { updateStatus: updateStatusLine } = statusRuntime;
|
|
@@ -733,19 +766,15 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
733
766
|
},
|
|
734
767
|
getLocalThreadLabelForTarget(target) {
|
|
735
768
|
const followerTarget = telegramBusFollowerRegistrationState.getTarget();
|
|
736
|
-
|
|
769
|
+
const isLocalFollowerTarget =
|
|
770
|
+
telegramBusFollowerRegistrationState.isRegistered() &&
|
|
737
771
|
followerTarget?.chatId === target.chatId &&
|
|
738
|
-
followerTarget.threadId === target.threadId
|
|
739
|
-
|
|
740
|
-
return telegramBusFollowerRegistrationState.getThreadName();
|
|
741
|
-
}
|
|
742
|
-
if (
|
|
772
|
+
followerTarget.threadId === target.threadId;
|
|
773
|
+
const isLocalLeaderTarget =
|
|
743
774
|
telegramBusLeaderTarget?.chatId === target.chatId &&
|
|
744
|
-
telegramBusLeaderTarget.threadId === target.threadId
|
|
745
|
-
)
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
return undefined;
|
|
775
|
+
telegramBusLeaderTarget.threadId === target.threadId;
|
|
776
|
+
if (!isLocalFollowerTarget && !isLocalLeaderTarget) return undefined;
|
|
777
|
+
return getCurrentInstanceThreadIdentity(target).threadName;
|
|
749
778
|
},
|
|
750
779
|
getCurrentLeaderEpoch,
|
|
751
780
|
getThreadReconciliationMachineState() {
|
package/lib/bus-follower.ts
CHANGED
|
@@ -777,6 +777,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
777
777
|
let activeAuthSecret: string | undefined;
|
|
778
778
|
let activeContext: TContext | undefined;
|
|
779
779
|
let lastKnownTarget: TelegramTarget | undefined;
|
|
780
|
+
let lastKnownSlot: string | undefined;
|
|
781
|
+
let lastKnownThreadName: string | undefined;
|
|
780
782
|
const stopHeartbeat = () => {
|
|
781
783
|
if (!heartbeatInterval) return;
|
|
782
784
|
clearInterval(heartbeatInterval);
|
|
@@ -788,6 +790,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
788
790
|
deps.setActiveAuthSecret?.(undefined);
|
|
789
791
|
deps.registrationState?.setRegistered(false);
|
|
790
792
|
lastKnownTarget = undefined;
|
|
793
|
+
lastKnownSlot = undefined;
|
|
794
|
+
lastKnownThreadName = undefined;
|
|
791
795
|
activeContext = undefined;
|
|
792
796
|
void deps.stopReceiving?.();
|
|
793
797
|
};
|
|
@@ -849,8 +853,13 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
849
853
|
deps.getProfileKey?.(ctx) ??
|
|
850
854
|
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
|
|
851
855
|
threadName:
|
|
856
|
+
deps.registrationState?.getThreadName() ??
|
|
857
|
+
lastKnownThreadName ??
|
|
852
858
|
deps.getThreadName?.(ctx) ??
|
|
853
859
|
(ctx.cwd ? basename(ctx.cwd) : undefined),
|
|
860
|
+
...(deps.registrationState?.getSlot() ?? lastKnownSlot
|
|
861
|
+
? { slot: deps.registrationState?.getSlot() ?? lastKnownSlot }
|
|
862
|
+
: {}),
|
|
854
863
|
cwd: ctx.cwd,
|
|
855
864
|
pid: getPid(),
|
|
856
865
|
target:
|
|
@@ -912,6 +921,8 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
912
921
|
registrationResult,
|
|
913
922
|
);
|
|
914
923
|
lastKnownTarget = registrationResult.target;
|
|
924
|
+
lastKnownSlot = registrationResult.slot;
|
|
925
|
+
lastKnownThreadName = registrationResult.threadName;
|
|
915
926
|
activeLeaderSocketPath = leaderSocketPath;
|
|
916
927
|
activeContext = ctx;
|
|
917
928
|
await sendHeartbeat();
|
package/lib/bus-leader.ts
CHANGED
|
@@ -477,6 +477,22 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
477
477
|
: undefined;
|
|
478
478
|
const followerOwner =
|
|
479
479
|
Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
|
|
480
|
+
const recoverableTarget =
|
|
481
|
+
!reconnectRecord &&
|
|
482
|
+
registration.target?.chatId === chatId &&
|
|
483
|
+
registration.target.threadId !== undefined &&
|
|
484
|
+
!recordsBeforeProvision.some(
|
|
485
|
+
(record) =>
|
|
486
|
+
record.target.chatId === registration.target?.chatId &&
|
|
487
|
+
record.target.threadId === registration.target.threadId,
|
|
488
|
+
)
|
|
489
|
+
? registration.target
|
|
490
|
+
: undefined;
|
|
491
|
+
const recoveryHint = recoverableTarget
|
|
492
|
+
? deps.topicTargetStore.getFollowerRecoveryHintByTarget?.(
|
|
493
|
+
recoverableTarget,
|
|
494
|
+
)
|
|
495
|
+
: undefined;
|
|
480
496
|
const registrationKey = followerProfileKey || registration.instanceId;
|
|
481
497
|
const pendingRegistration = pendingRegistrations.get(registrationKey);
|
|
482
498
|
if (pendingRegistration) return pendingRegistration;
|
|
@@ -499,12 +515,69 @@ export function createTelegramBusFollowerTargetProvisioner(
|
|
|
499
515
|
deps.onProvisioningEnd?.();
|
|
500
516
|
}
|
|
501
517
|
};
|
|
518
|
+
const recoverRequestedTarget = async () => {
|
|
519
|
+
const nowMs = getNowMs();
|
|
520
|
+
const requestedThreadName =
|
|
521
|
+
registration.threadName &&
|
|
522
|
+
Threads.isTelegramTopicThreadNameValidForSlot(
|
|
523
|
+
registration.threadName,
|
|
524
|
+
registration.slot,
|
|
525
|
+
)
|
|
526
|
+
? registration.threadName
|
|
527
|
+
: recoveryHint?.threadName &&
|
|
528
|
+
Threads.isTelegramTopicThreadNameValidForSlot(
|
|
529
|
+
recoveryHint.threadName,
|
|
530
|
+
recoveryHint.slot,
|
|
531
|
+
)
|
|
532
|
+
? recoveryHint.threadName
|
|
533
|
+
: undefined;
|
|
534
|
+
let recoveredRecord = deps.topicTargetStore.upsert({
|
|
535
|
+
profileKey: followerProfileKey,
|
|
536
|
+
owner:
|
|
537
|
+
followerOwner.kind === "manual-follower"
|
|
538
|
+
? followerOwner
|
|
539
|
+
: {
|
|
540
|
+
kind: "manual-follower",
|
|
541
|
+
instanceId: registration.instanceId,
|
|
542
|
+
},
|
|
543
|
+
target: {
|
|
544
|
+
chatId: recoverableTarget!.chatId,
|
|
545
|
+
threadId: recoverableTarget!.threadId!,
|
|
546
|
+
},
|
|
547
|
+
status: "active",
|
|
548
|
+
createdAtMs: registration.connectedAtMs || nowMs,
|
|
549
|
+
updatedAtMs: nowMs,
|
|
550
|
+
instanceId: registration.instanceId,
|
|
551
|
+
...(requestedThreadName ? { threadName: requestedThreadName } : {}),
|
|
552
|
+
lastSyncObservedAtMs: nowMs,
|
|
553
|
+
lastReconcileAction: "follower-live-target-recovery",
|
|
554
|
+
});
|
|
555
|
+
const requestedSlot = registration.slot ?? recoveryHint?.slot;
|
|
556
|
+
const requestedSlotAvailable =
|
|
557
|
+
!!requestedSlot &&
|
|
558
|
+
/^[A-Z]$/.test(requestedSlot) &&
|
|
559
|
+
!recordsBeforeProvision.some((record) => record.slot === requestedSlot);
|
|
560
|
+
if (requestedSlotAvailable) {
|
|
561
|
+
recoveredRecord = deps.topicTargetStore.upsert({
|
|
562
|
+
...recoveredRecord,
|
|
563
|
+
slot: requestedSlot,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
await deps.topicTargetStore.persist();
|
|
567
|
+
return {
|
|
568
|
+
target: recoveredRecord.target,
|
|
569
|
+
reused: true,
|
|
570
|
+
record: recoveredRecord,
|
|
571
|
+
};
|
|
572
|
+
};
|
|
502
573
|
const runRegistration = async (): Promise<
|
|
503
574
|
(TelegramTarget & { slot?: string; threadName?: string }) | undefined
|
|
504
575
|
> => {
|
|
505
576
|
let result = reconnectRecord
|
|
506
577
|
? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
|
|
507
|
-
:
|
|
578
|
+
: recoverableTarget
|
|
579
|
+
? await recoverRequestedTarget()
|
|
580
|
+
: await provisionTarget();
|
|
508
581
|
if (reconnectRecord) {
|
|
509
582
|
const nowMs = getNowMs();
|
|
510
583
|
const transferredRecord = deps.topicTargetStore.upsert({
|
package/lib/bus.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface TelegramBusInstanceRegistration {
|
|
|
98
98
|
instanceId: string;
|
|
99
99
|
profileKey?: string;
|
|
100
100
|
threadName?: string;
|
|
101
|
+
slot?: string;
|
|
101
102
|
cwd?: string;
|
|
102
103
|
pid?: number;
|
|
103
104
|
target?: TelegramTarget;
|
|
@@ -1068,6 +1069,9 @@ function parseRegistration(
|
|
|
1068
1069
|
registration.profileKey = value.profileKey;
|
|
1069
1070
|
if (typeof value.threadName === "string")
|
|
1070
1071
|
registration.threadName = value.threadName;
|
|
1072
|
+
if (typeof value.slot === "string" && /^[A-Z]$/.test(value.slot)) {
|
|
1073
|
+
registration.slot = value.slot;
|
|
1074
|
+
}
|
|
1071
1075
|
if (typeof value.cwd === "string") registration.cwd = value.cwd;
|
|
1072
1076
|
if (typeof value.pid === "number") registration.pid = value.pid;
|
|
1073
1077
|
if (typeof value.busSocketPath === "string") {
|
package/lib/threads.ts
CHANGED
|
@@ -183,6 +183,9 @@ export interface TelegramTopicTargetStore {
|
|
|
183
183
|
load: () => Promise<void>;
|
|
184
184
|
persist: () => Promise<void>;
|
|
185
185
|
list: () => TelegramTopicTargetRecord[];
|
|
186
|
+
getFollowerRecoveryHintByTarget?: (
|
|
187
|
+
target: TelegramTarget,
|
|
188
|
+
) => { slot?: string; threadName?: string } | undefined;
|
|
186
189
|
listReservations: () => TelegramThreadReservation[];
|
|
187
190
|
listPendingProvisions: () => TelegramThreadPendingProvision[];
|
|
188
191
|
listSyncObservations: () => TelegramTopicSyncObservation[];
|
|
@@ -266,6 +269,7 @@ export function reconcileTelegramFreshAllocationCursor(
|
|
|
266
269
|
export interface TelegramTopicTargetStoreOptions {
|
|
267
270
|
path: string | (() => string);
|
|
268
271
|
getNowMs?: () => number;
|
|
272
|
+
canPersist?: () => boolean;
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
export interface TelegramTopicTargetProvisionerDeps {
|
|
@@ -869,6 +873,54 @@ function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
|
|
|
869
873
|
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
870
874
|
}
|
|
871
875
|
|
|
876
|
+
function getTargetRecoveryHintKey(target: TelegramTarget): string {
|
|
877
|
+
return `${target.chatId}:${target.threadId ?? "private"}`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function parseFollowerRecoveryHints(
|
|
881
|
+
value: unknown,
|
|
882
|
+
): Map<string, { slot?: string; threadName?: string }> {
|
|
883
|
+
const hints = new Map<string, { slot?: string; threadName?: string }>();
|
|
884
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
|
|
885
|
+
const liveRoster = (value as Record<string, unknown>).liveRoster;
|
|
886
|
+
if (!liveRoster || typeof liveRoster !== "object" || Array.isArray(liveRoster))
|
|
887
|
+
return hints;
|
|
888
|
+
const followers = (liveRoster as Record<string, unknown>).busFollowers;
|
|
889
|
+
if (!Array.isArray(followers)) return hints;
|
|
890
|
+
for (const follower of followers) {
|
|
891
|
+
if (!follower || typeof follower !== "object" || Array.isArray(follower))
|
|
892
|
+
continue;
|
|
893
|
+
const record = follower as Record<string, unknown>;
|
|
894
|
+
const target = record.target;
|
|
895
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) continue;
|
|
896
|
+
const targetRecord = target as Record<string, unknown>;
|
|
897
|
+
if (typeof targetRecord.chatId !== "number") continue;
|
|
898
|
+
const normalizedTarget: TelegramTarget = {
|
|
899
|
+
chatId: targetRecord.chatId,
|
|
900
|
+
...(typeof targetRecord.threadId === "number"
|
|
901
|
+
? { threadId: targetRecord.threadId }
|
|
902
|
+
: {}),
|
|
903
|
+
};
|
|
904
|
+
const slot =
|
|
905
|
+
typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot)
|
|
906
|
+
? targetRecord.slot
|
|
907
|
+
: typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)
|
|
908
|
+
? record.slot
|
|
909
|
+
: undefined;
|
|
910
|
+
const threadName =
|
|
911
|
+
typeof targetRecord.threadName === "string"
|
|
912
|
+
? targetRecord.threadName
|
|
913
|
+
: typeof record.threadName === "string"
|
|
914
|
+
? record.threadName
|
|
915
|
+
: undefined;
|
|
916
|
+
hints.set(getTargetRecoveryHintKey(normalizedTarget), {
|
|
917
|
+
...(slot ? { slot } : {}),
|
|
918
|
+
...(threadName ? { threadName } : {}),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
return hints;
|
|
922
|
+
}
|
|
923
|
+
|
|
872
924
|
function getInstanceProcessKey(
|
|
873
925
|
instanceId: string | undefined,
|
|
874
926
|
): string | undefined {
|
|
@@ -905,6 +957,10 @@ export function createTelegramTopicTargetStore(
|
|
|
905
957
|
let reservations: TelegramThreadReservation[] = [];
|
|
906
958
|
let pendingProvisions: TelegramThreadPendingProvision[] = [];
|
|
907
959
|
let syncObservations: TelegramTopicSyncObservation[] = [];
|
|
960
|
+
let followerRecoveryHints = new Map<
|
|
961
|
+
string,
|
|
962
|
+
{ slot?: string; threadName?: string }
|
|
963
|
+
>();
|
|
908
964
|
let loaded = false;
|
|
909
965
|
let loadedPath: string | undefined;
|
|
910
966
|
let dirty = false;
|
|
@@ -939,6 +995,7 @@ export function createTelegramTopicTargetStore(
|
|
|
939
995
|
reservations = [];
|
|
940
996
|
pendingProvisions = [];
|
|
941
997
|
syncObservations = [];
|
|
998
|
+
followerRecoveryHints = new Map();
|
|
942
999
|
statusSnapshot = {};
|
|
943
1000
|
loaded = false;
|
|
944
1001
|
dirty = false;
|
|
@@ -955,11 +1012,14 @@ export function createTelegramTopicTargetStore(
|
|
|
955
1012
|
reservations = [];
|
|
956
1013
|
pendingProvisions = [];
|
|
957
1014
|
syncObservations = [];
|
|
1015
|
+
followerRecoveryHints = new Map();
|
|
958
1016
|
loaded = true;
|
|
959
1017
|
return;
|
|
960
1018
|
}
|
|
961
1019
|
const content = await readFile(path, "utf8");
|
|
962
|
-
const
|
|
1020
|
+
const rawFile: unknown = JSON.parse(content);
|
|
1021
|
+
const file = parseTopicTargetFile(rawFile);
|
|
1022
|
+
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
963
1023
|
botState = file.bot;
|
|
964
1024
|
records = new Map(
|
|
965
1025
|
file.threads.map((record) => [
|
|
@@ -993,6 +1053,7 @@ export function createTelegramTopicTargetStore(
|
|
|
993
1053
|
target: { ...observation.target },
|
|
994
1054
|
}));
|
|
995
1055
|
loaded = true;
|
|
1056
|
+
dirty = false;
|
|
996
1057
|
};
|
|
997
1058
|
|
|
998
1059
|
return {
|
|
@@ -1003,7 +1064,11 @@ export function createTelegramTopicTargetStore(
|
|
|
1003
1064
|
async persist() {
|
|
1004
1065
|
const path = getPath();
|
|
1005
1066
|
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
1006
|
-
if (
|
|
1067
|
+
if (options.canPersist && !options.canPersist()) {
|
|
1068
|
+
await loadFromDisk();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!dirty || !loaded) await loadFromDisk();
|
|
1007
1072
|
await mkdir(dirname(path), { recursive: true });
|
|
1008
1073
|
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1009
1074
|
const nowMs = getNowMs();
|
|
@@ -1058,6 +1123,10 @@ export function createTelegramTopicTargetStore(
|
|
|
1058
1123
|
list() {
|
|
1059
1124
|
return Array.from(records.values()).map(cloneRecord);
|
|
1060
1125
|
},
|
|
1126
|
+
getFollowerRecoveryHintByTarget(target) {
|
|
1127
|
+
const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target));
|
|
1128
|
+
return hint ? { ...hint } : undefined;
|
|
1129
|
+
},
|
|
1061
1130
|
listReservations() {
|
|
1062
1131
|
const nowMs = getNowMs();
|
|
1063
1132
|
return reservations
|
|
@@ -1962,6 +2031,48 @@ export async function provisionOwnBusTopic(
|
|
|
1962
2031
|
};
|
|
1963
2032
|
}
|
|
1964
2033
|
|
|
2034
|
+
export interface TelegramInstanceThreadIdentityCandidate {
|
|
2035
|
+
target?: TelegramTarget;
|
|
2036
|
+
slot?: string;
|
|
2037
|
+
threadName?: string;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
export function resolveTelegramInstanceThreadIdentity(options: {
|
|
2041
|
+
target?: TelegramTarget;
|
|
2042
|
+
follower?: TelegramInstanceThreadIdentityCandidate;
|
|
2043
|
+
leader?: TelegramInstanceThreadIdentityCandidate;
|
|
2044
|
+
record?: TelegramTopicTargetRecord;
|
|
2045
|
+
}): TelegramInstanceThreadIdentityCandidate {
|
|
2046
|
+
const targetMatchesCandidate = (
|
|
2047
|
+
candidate: TelegramInstanceThreadIdentityCandidate | undefined,
|
|
2048
|
+
) => {
|
|
2049
|
+
if (!candidate) return false;
|
|
2050
|
+
if (!options.target) return true;
|
|
2051
|
+
return !!candidate.target && targetMatches(candidate.target, options.target);
|
|
2052
|
+
};
|
|
2053
|
+
const local = targetMatchesCandidate(options.follower)
|
|
2054
|
+
? options.follower
|
|
2055
|
+
: targetMatchesCandidate(options.leader)
|
|
2056
|
+
? options.leader
|
|
2057
|
+
: undefined;
|
|
2058
|
+
const record =
|
|
2059
|
+
options.record &&
|
|
2060
|
+
(!options.target || targetMatches(options.record.target, options.target))
|
|
2061
|
+
? options.record
|
|
2062
|
+
: undefined;
|
|
2063
|
+
return {
|
|
2064
|
+
...(local?.target ?? record?.target
|
|
2065
|
+
? { target: local?.target ?? record?.target }
|
|
2066
|
+
: {}),
|
|
2067
|
+
...(local?.slot ?? record?.slot
|
|
2068
|
+
? { slot: local?.slot ?? record?.slot }
|
|
2069
|
+
: {}),
|
|
2070
|
+
...(local?.threadName ?? record?.threadName
|
|
2071
|
+
? { threadName: local?.threadName ?? record?.threadName }
|
|
2072
|
+
: {}),
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1965
2076
|
export function findCurrentTelegramInstanceThreadRecord(options: {
|
|
1966
2077
|
records: readonly TelegramTopicTargetRecord[];
|
|
1967
2078
|
instanceId: string;
|