@llblab/pi-telegram 0.18.5 → 0.18.6

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 CHANGED
@@ -126,6 +126,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
126
126
  - Keep globally reusable rules as independent bullets in the most general applicable section of `AGENTS.md`; do not hide a cross-cutting rule inside one local feature paragraph just because that feature revealed it.
127
127
  - Local/domain bullets should state how a general rule applies there, or record domain-specific exceptions; they should not be the only source for a rule that applies to multiple tools, transports, domains, or docs surfaces.
128
128
  - Pi-facing tool results shown in compact tool rows should start with exactly one leading newline (`\nResult`) so the result is visually separated from the tool-call header by one blank line without excessive vertical spacing.
129
+ - For non-trivial implementation, release, or architecture-audit work, run an `AGENTS.md` compliance pass: reread the durable principles and relevant project docs, map the changed behavior to the rules it touches, validate code/tests/docs against those rules, and explicitly surface any rule conflict, obsolete rule, or evidence-backed improvement. When the rules themselves drift from the product reality, update `AGENTS.md` or document a deliberate exception in the same pass instead of silently working around it.
129
130
 
130
131
  ## 6.2 Validation Hotspots
131
132
 
@@ -221,14 +222,14 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
221
222
  - `Section domain ownership`: `lib/sections.ts` owns the registry, token mapping, callback dispatch, and context building. `lib/menu.ts` dispatches `section:` callbacks before built-in handling. `lib/menu-status.ts` injects section rows. `lib/menu-settings.ts` injects settings rows and passes `sectionRegistry` through callback deps
222
223
  - `Callback routing order`: button actions → compact confirmations → queue menu → settings menu → section callbacks → built-in menu handling → `[callback]` fallback. Settings menu callbacks always pass `sectionRegistry` to `updateTelegramSettingsMenuMessage` and `handleTelegramSettingsMenuCallbackAction`
223
224
 
224
- ## 9. Pre-Task Preparation Protocol
225
+ ## 10. Pre-Task Preparation Protocol
225
226
 
226
227
  - Read `README.md` for current user-facing behavior and fork positioning
227
228
  - Read `BACKLOG.md` before changing runtime behavior or documentation so open work stays truthful
228
229
  - Read `/docs/architecture.md` before restructuring queue, preview, rendering, or command-handling logic
229
230
  - Inspect the relevant `index.ts` section before editing because most bridge behavior is stateful and cross-linked
230
231
 
231
- ## 10. Task Completion Protocol
232
+ ## 11. Task Completion Protocol
232
233
 
233
234
  - Run the smallest meaningful validation for the touched area; `npm test` is the default regression suite once rendering or queue logic changes
234
235
  - For rendering changes, ensure regressions still cover nested lists, code blocks, underscore-heavy text, and long-message chunking
package/BACKLOG.md CHANGED
@@ -1,21 +1,16 @@
1
1
  # Project Backlog
2
2
 
3
- _Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation, core live Threaded Mode smoke, and native Windows classic↔Threaded Mode upgrade/downgrade smoke are green. This backlog intentionally tracks only release-relevant remaining work: evidence-gated Telegram client/runtime follow-ups and upstream Pi API blockers._
3
+ _Current deterministic status: Threaded Mode implementation, native typing/activity status, regression coverage, docs/context reconciliation, typecheck, full tests, pack check, audit, Domain DAG validation, context validation, native Windows classic↔Threaded Mode upgrade/downgrade smoke, and live post-reload leader/follower prompt routing, follower Active parity, and unbound reroute/restore smoke are green. This backlog intentionally tracks only release-relevant remaining work: evidence-gated Telegram client/runtime follow-ups and upstream Pi API blockers._
4
4
 
5
- ## P0 — Live Threaded Mode Regression Sweep
5
+ ## P0 — Promoted Follower Reload Smoke
6
6
 
7
- Context: live Linux testing exposed regressions around prompt dispatch readiness, visible thread rename noise during automatic leader reclaim, status thread-name fallback flicker, and follower voice/update forwarding health. These are local runtime correctness issues and must be validated before returning to Windows smoke.
7
+ Context: deterministic coverage protects promoted follower thread preservation, and the latest live Linux smoke closed the reload routing, follower Active, and reroute/restore regressions. Keep one explicit live check for promoted-follower reload identity unless that exact path is covered in the release smoke pass.
8
8
 
9
9
  Open work:
10
10
 
11
- - [ ] Live smoke on Linux with one leader and one follower:
12
- - [ ] dirty-state pass with old `state.json`/`logs.jsonl` present to prove live locks, bus registration, target ownership, and reconciliation override stale diagnostics;
13
- - [ ] leader reload recovers without duplicate visible thread renames;
14
- - [ ] prompts dispatch without a second command;
15
- - [ ] status remains stable around thread name and role while active turns start/end.
16
- - [ ] Add/keep proactive race protections so `state.json` never becomes authoritative over live lock ownership, bus registration, or current target identity.
11
+ - [ ] Close leader follower promotes `/reload` promoted leader preserves the same Telegram thread identity.
17
12
 
18
- Done when: local Linux live Threaded Mode smoke is stable for leader reload, follower connect, prompt dispatch, voice forwarding, and status naming without visible rename noise.
13
+ Done when: promoted-follower reload identity has live Telegram evidence or is deliberately deferred out of the hotfix scope.
19
14
 
20
15
  ## P1 — Native Windows Threaded Mode Follow-Ups
21
16
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.18.6: Threaded Mode parity hotfix
6
+
7
+ - `[Threaded Mode]` Follower-routed prompts now record message ownership when the leader forwards them by thread target, and edited messages can route by stored message ownership when Telegram omits thread identity. Impact: later queue-control reactions and queued-prompt edits on the original Telegram message route back to the owning follower instead of being handled by the leader.
8
+ - `[Native Activity Status]` Follower typing/activity startup now sends one thread-scoped action plus one aggregate action, and the default keepalive cadence is back to 2.5 seconds. Impact: follower `...active` status avoids duplicate chat-action bursts that can hit Telegram rate limits.
9
+ - `[Threaded Mode]` Follower `/start` can now register the visible bot command menu through the leader bus without a warning before opening the local menu, follower-sent Bot API messages are recorded in the leader's ownership map, and follower message edit/delete calls used by menu surfaces are allowed as validated same-chat message operations. Impact: follower menu callbacks and interactive cleanup route back to the owning follower even when Telegram callback payloads omit thread identity, without false bus-allowlist failures.
10
+ - `[Threaded Mode]` Follower promotion now snapshots the current follower thread binding before clearing registration state and converts that binding into the leader profile before forced lock acquisition. Impact: an elected follower keeps its existing thread identity across promotion and later reload instead of becoming disconnected or provisioning a fresh leader thread.
11
+ - `[Threaded Mode]` Follower registration now probes a reused same-profile thread with the connected notice before reporting it as reusable, and recreates the thread when Telegram reports the old target as stale. Impact: reconnecting a follower after its Telegram tab was closed no longer leaves the terminal showing a follower thread name while no matching Telegram thread is visible.
12
+ - `[Threaded Mode]` Unbound-thread reroute controls now expose replace/restore for the current live bus roster instead of requiring prior leader reroute confirmation. Impact: when a new unbound Telegram thread appears, the operator can choose any currently live leader/follower instance to route to or replace/restore into that source thread.
13
+ - `[Status]` Follower terminal status now gives active/compacting processing labels precedence over the stable follower role, matching leader behavior. Impact: a follower shows its thread name plus `active` while processing a Telegram-originated prompt, then returns to `follower` when idle.
14
+ - `[Threaded Mode]` Reload-time target ownership now classifies only follower-owned records as follower targets and replaces stale same-profile/same-target follower registry entries on registration. Impact: after leader or follower reload, Telegram prompts and reroutes no longer get forwarded to dead instance ids or report a live thread as unavailable.
15
+ - `[Validation]` Live Linux Threaded Mode smoke now passes for post-reload leader/follower prompt routing, unbound reroute/restore, and leader/follower terminal Active status. Impact: the hotfix candidate has Telegram-client evidence for the regressions fixed after `0.18.5`.
16
+ - `[Docs]` Added a leader/follower capability parity matrix covering prompts, queue reactions, edits, callbacks, replies, previews, attachments, native activity, command/menu bootstrap, and diagnostics. Impact: follower behavior now has an explicit parity checklist instead of being inferred from leader behavior.
17
+ - `[Engineering]` Formalized an `AGENTS.md` compliance pass for non-trivial work and cleaned unused runtime/lifecycle test surfaces surfaced by strict no-unused diagnostics. Impact: future changes have an explicit project-rule audit step, and dead-code diagnostics stay actionable.
18
+
3
19
  ## 0.18.5: Windows Threaded Mode stabilization hotfix
4
20
 
5
21
  - `[Bus Transport]` Introduced an explicit local bus transport boundary for endpoint derivation, socket-vs-pipe detection, operation-aware retry policy, timeout/transient IPC error classification, endpoint reachability probes, request-scoped server/client transport events, and handler-failure ACKs instead of silent client timeouts. Impact: Unix socket behavior remains the stable baseline while Windows named-pipe readiness and retry behavior are contained in the transport layer instead of leaking into routing.
@@ -184,7 +184,7 @@ Manual smoke checklist:
184
184
 
185
185
  1. Enable Telegram private-chat Threaded Mode for the paired bot.
186
186
  2. Start Pi in one Windows terminal and run `/telegram-connect`; verify it becomes the leader and gets a named Telegram thread.
187
- 3. Start Pi in a second Windows terminal and run `/telegram-connect`; verify it registers as follower rather than offering takeover, creates/uses its assigned thread, and terminal status shows `<ThreadName> Follower`.
187
+ 3. Start Pi in a second Windows terminal and run `/telegram-connect`; verify it registers as follower rather than offering takeover, creates/uses its assigned thread, terminal status shows `<ThreadName> Follower` while idle, and a follower prompt flips it to `<ThreadName> Active` while work is running.
188
188
  4. From the follower thread, send a prompt that requests inline buttons; tap a button and verify the follow-up prompt queues in the follower instance.
189
189
  5. From the follower thread, request a voice reply and/or attachment; verify upload routes through the leader transport into the follower thread.
190
190
  6. Close the follower terminal; verify heartbeat pruning, disconnected notice, and cleanup behavior match Unix-like behavior.
@@ -283,6 +283,27 @@ This preserves one API bus and one set of rate-limit/retry diagnostics. The curr
283
283
 
284
284
  Every outbound request carries its target. The leader injects `message_thread_id` when `target.threadId` exists.
285
285
 
286
+ ## Leader/Follower Capability Parity Matrix
287
+
288
+ Threaded Mode should make follower threads behave like normal Telegram instance surfaces, with the leader acting only as transport owner. Any feature in the matrix below that works for the leader must either work for followers or have an explicit documented exception.
289
+
290
+ | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
291
+ | --- | --- | --- | --- | --- |
292
+ | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
293
+ | Queued-message removal reactions | 👎/👻/💔/💩/🗑 removes pending prompt/media turn | Same reaction on a queued follower prompt removes that follower's pending turn before dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
294
+ | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
295
+ | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
296
+ | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
297
+ | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
298
+ | Previews/Rich Drafts | Draft previews use the active thread target | Follower previews use the same native draft lifecycle through the leader | Preview transport preserves target and draft id | Preview thread-target tests |
299
+ | Attachments/voice | Files and voice upload in the instance thread | Follower uploads route through leader multipart transport | Multipart calls are target-scoped and follower-authorized | Bus allowlist and outbound delivery tests |
300
+ | Native activity status | `sendChatAction(typing)` shows thread Active, mirrors aggregate `All`, and terminal status flips from role to `active` during work | Follower work sends one thread action and one aggregate action through leader transport, and terminal status follows the same idle-role → active transition as leaders | Typing loop targets the active turn and avoids duplicate aggregate sends/rate-limit pressure; status rendering gives processing labels precedence over stable bus role labels | Runtime typing loop starter and status bar parity regressions |
301
+ | Leader election / promotion | Current leader keeps its thread across reload | A promoted follower keeps its existing thread, slot, and name when elected and after later reload | Promotion converts the current follower binding into the leader profile before forced lock acquisition, so leader startup reuses it instead of provisioning a new thread | Follower heartbeat recovery passes binding snapshot into promotion; own-topic provisioner reuses promoted bindings |
302
+ | `/start` command/menu bootstrap | Registers visible bot commands and opens the menu | Follower `/start` can refresh the bot command menu through the leader and open its local menu without warnings | Bot command registration is a validated global Bot API call allowed through trusted follower bus transport | Bus allowlist regression for `setMyCommands` |
303
+ | Follower reconnect | Existing leader binding is reused only when still usable | Follower reconnect that points at a closed/stale Telegram tab recreates a visible thread before reporting success, and session replacement replaces any old same-profile/same-target registry entry | Same-profile reuse is probed with a connected notice; stale Bot API errors mark the old target stale and provision a fresh target; live follower target ownership never falls back to leader records | Bus leader stale reused follower-thread provisioner and bus registry/ownership regressions |
304
+ | Unbound thread reroute/restore | New unbound thread can route to a live instance or replace the selected instance thread | Same chooser exposes all currently live bus leader/follower targets; restore is offered from concrete unbound threads, not historical snapshots | Live bus roster plus active target bindings define the selectable set; history/state snapshots are not authority | Routing chooser regressions for live target filtering and restore rows |
305
+ | Status/menu diagnostics | Status reflects leader role, queue, and target | Follower status reflects follower role, thread name, queue, and bus health | Status is local runtime truth plus bus registration state, not leader queue state | Status and bus diagnostics tests |
306
+
286
307
  ## Queue And State Scoping
287
308
 
288
309
  Each instance owns its own queue and active turn state. The leader does not become a central queue scheduler for all agents; that would be a separate daemon-mode architecture.
@@ -312,7 +333,7 @@ Typical config remains just bot identity and authorization:
312
333
  Rules:
313
334
 
314
335
  - Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
315
- - Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` so transport role is visible without opening diagnostics.
336
+ - Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar reports `telegram leader` or `telegram follower` while idle so transport role is visible without opening diagnostics, and both roles switch to `active`/`compacting` processing labels during local Telegram work. Follower registration is unique by live profile/target: a reload or session replacement must replace stale registry entries rather than leaving multiple routable ids for one Telegram thread, and fallback target ownership must not classify leader records as followers.
316
337
  - The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
317
338
  - Thread reuse is extension-owned through current live binding identity; there is no separate `topics` config surface in the active private-chat thread model. Manual followers use instance-scoped internal keys by default so multiple terminal processes in the same cwd can receive separate threads.
318
339
  - Thread cleanup remains conservative and centralized: destructive close/delete actions are planned and applied through `thread-reconciler` with proof-before-delete checks, leader-epoch fencing, and retry-preserving failure semantics.
package/index.ts CHANGED
@@ -662,6 +662,9 @@ export default function (pi: Pi.ExtensionAPI) {
662
662
  return telegramInstanceId;
663
663
  },
664
664
  getMessageOwnership: messageOwnershipStore.get,
665
+ recordMessageOwnership(input) {
666
+ messageOwnershipStore.record(input);
667
+ },
665
668
  getTargetOwnership(target) {
666
669
  return Bus.getTelegramFollowerTargetOwnership({
667
670
  target,
@@ -821,7 +824,29 @@ export default function (pi: Pi.ExtensionAPI) {
821
824
  telegramBusLifecycleOverridePhase = phase;
822
825
  },
823
826
  updateStatus,
824
- async promoteToLeader(ctx) {
827
+ async promoteToLeader(ctx, binding) {
828
+ const promotedRecord =
829
+ await Threads.promoteTelegramFollowerBindingToLeader({
830
+ store: threadStore,
831
+ instanceId: telegramInstanceId,
832
+ cwd: ctx.cwd,
833
+ target: binding.target,
834
+ slot: binding.slot,
835
+ threadName: binding.threadName,
836
+ });
837
+ if (promotedRecord) {
838
+ recordRuntimeEvent(
839
+ "bus",
840
+ "Follower thread binding promoted to leader",
841
+ {
842
+ phase: "follower-promoted-binding",
843
+ chatId: promotedRecord.target.chatId,
844
+ threadId: promotedRecord.target.threadId,
845
+ slot: promotedRecord.slot,
846
+ threadName: promotedRecord.threadName,
847
+ },
848
+ );
849
+ }
825
850
  await lockedPollingRuntime.start(ctx, { force: true });
826
851
  },
827
852
  sleep(ms) {
@@ -898,6 +923,14 @@ export default function (pi: Pi.ExtensionAPI) {
898
923
  startPolling: pollingRuntime.start,
899
924
  stopPolling: pollingRuntime.stop,
900
925
  authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
926
+ recordFollowerMessageOwnership(record) {
927
+ messageOwnershipStore.record({
928
+ chatId: record.chatId,
929
+ messageId: record.messageId,
930
+ target: record.target,
931
+ instanceId: record.follower.instanceId,
932
+ });
933
+ },
901
934
  provisionLeaderTarget:
902
935
  BusLeader.createTelegramBusLeaderTargetProvisioner<Pi.ExtensionContext>({
903
936
  getAllowedUserId: configStore.getAllowedUserId,
@@ -1273,8 +1306,6 @@ export default function (pi: Pi.ExtensionAPI) {
1273
1306
  sendRecordVoiceAction,
1274
1307
  sendMarkdownReply,
1275
1308
  sendTextReply,
1276
- editInteractiveMessage,
1277
- deleteMessage: deleteTelegramMessage,
1278
1309
  dispatchNextQueuedTelegramTurn,
1279
1310
  answerGuestQuery,
1280
1311
  sendGuestReply,
package/lib/bindings.ts CHANGED
@@ -21,7 +21,6 @@ import * as Replies from "./replies.ts";
21
21
  import * as Runtime from "./runtime.ts";
22
22
  import * as Setup from "./setup.ts";
23
23
  import * as Status from "./status.ts";
24
- import * as Target from "./target.ts";
25
24
  import * as TelegramApi from "./telegram-api.ts";
26
25
 
27
26
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
@@ -166,14 +165,6 @@ interface TelegramLifecycleBindingDeps {
166
165
  Keyboard.TelegramInlineKeyboardMarkup
167
166
  >["sendTextReply"] &
168
167
  NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
169
- editInteractiveMessage: (
170
- chatId: number,
171
- messageId: number,
172
- text: string,
173
- mode: "html" | "markdown",
174
- replyMarkup: Keyboard.TelegramInlineKeyboardMarkup,
175
- ) => Promise<void>;
176
- deleteMessage: (chatId: number, messageId: number) => Promise<void>;
177
168
  dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
178
169
  answerGuestQuery: NonNullable<
179
170
  Queue.TelegramAgentEndHookRuntimeDeps<
@@ -225,8 +216,6 @@ export function registerTelegramLifecycleRuntimeHooks({
225
216
  sendRecordVoiceAction,
226
217
  sendMarkdownReply,
227
218
  sendTextReply,
228
- editInteractiveMessage,
229
- deleteMessage,
230
219
  dispatchNextQueuedTelegramTurn,
231
220
  answerGuestQuery,
232
221
  sendGuestReply,
@@ -367,12 +356,12 @@ export function registerTelegramLifecycleRuntimeHooks({
367
356
  onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
368
357
  onSessionCompact: compactionObserver.onSessionCompact,
369
358
  onAgentStart: agentStartWithDedupReset,
370
- async onToolExecutionStart(event, _ctx) {
359
+ async onToolExecutionStart(_event, _ctx) {
371
360
  agentLifecycleHooks.onToolExecutionStart();
372
361
  },
373
362
  onToolExecutionUpdate() {},
374
- async onToolExecutionEnd(event, ctx) {
375
- agentLifecycleHooks.onToolExecutionEnd(event, ctx);
363
+ async onToolExecutionEnd(_event, ctx) {
364
+ agentLifecycleHooks.onToolExecutionEnd(_event, ctx);
376
365
  },
377
366
  onAgentEnd: agentLifecycleHooks.onAgentEnd,
378
367
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
@@ -203,16 +203,25 @@ export interface TelegramBusFollowerLeaderLock {
203
203
  busSecret?: string;
204
204
  }
205
205
 
206
+ export interface TelegramBusFollowerPromotedBinding {
207
+ target?: TelegramTarget;
208
+ slot?: string;
209
+ threadName?: string;
210
+ }
211
+
206
212
  export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
207
213
  registrationState: Pick<
208
214
  TelegramBusFollowerRegistrationState,
209
- "setRegistered"
215
+ "getTarget" | "getSlot" | "getThreadName" | "setRegistered"
210
216
  >;
211
217
  getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
212
218
  getLeaderState: () => TelegramBusFollowerLeaderState;
213
219
  setLifecyclePhase: (phase: "electing" | undefined) => void;
214
220
  updateStatus: (ctx: TContext) => void;
215
- promoteToLeader: (ctx: TContext) => Promise<void> | void;
221
+ promoteToLeader: (
222
+ ctx: TContext,
223
+ binding: TelegramBusFollowerPromotedBinding,
224
+ ) => Promise<void> | void;
216
225
  sleep: (ms: number) => Promise<void>;
217
226
  promotionGraceMs: number;
218
227
  recordRuntimeEvent: (
@@ -511,7 +520,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
511
520
  return false;
512
521
  }
513
522
  };
514
- const promoteToLeader = async (reason: unknown, ctx: TContext) => {
523
+ const snapshotBinding = (): TelegramBusFollowerPromotedBinding => ({
524
+ target: deps.registrationState.getTarget(),
525
+ slot: deps.registrationState.getSlot(),
526
+ threadName: deps.registrationState.getThreadName(),
527
+ });
528
+ const promoteToLeader = async (
529
+ reason: unknown,
530
+ ctx: TContext,
531
+ binding = snapshotBinding(),
532
+ ) => {
515
533
  deps.setLifecyclePhase("electing");
516
534
  safeUpdateStatus(ctx);
517
535
  deps.recordRuntimeEvent("bus", reason, {
@@ -523,7 +541,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
523
541
  deps.recordRuntimeEvent("bus", "Telegram follower elected for promotion", {
524
542
  phase: "follower-promotion-electing",
525
543
  });
526
- await deps.promoteToLeader(ctx);
544
+ await deps.promoteToLeader(ctx, binding);
527
545
  deps.setLifecyclePhase(undefined);
528
546
  safeUpdateStatus(ctx);
529
547
  deps.recordRuntimeEvent("bus", "Telegram follower promotion completed", {
@@ -534,6 +552,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
534
552
  if (promotionPending) return;
535
553
  promotionPending = true;
536
554
  try {
555
+ const initialBinding = snapshotBinding();
537
556
  const state = deps.getLeaderState();
538
557
  if (state.kind === "active-elsewhere") {
539
558
  clearRegisteredState(ctx);
@@ -565,16 +584,16 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
565
584
  ) {
566
585
  return;
567
586
  }
568
- await promoteToLeader(error, ctx);
587
+ await promoteToLeader(error, ctx, initialBinding);
569
588
  return;
570
589
  }
571
590
  if (graceState.kind === "stale" || graceState.kind === "inactive") {
572
- await promoteToLeader(error, ctx);
591
+ await promoteToLeader(error, ctx, initialBinding);
573
592
  }
574
593
  return;
575
594
  }
576
595
  if (state.kind === "stale" || state.kind === "inactive") {
577
- await promoteToLeader(error, ctx);
596
+ await promoteToLeader(error, ctx, initialBinding);
578
597
  }
579
598
  } catch (promotionError) {
580
599
  deps.setLifecyclePhase(undefined);
package/lib/bus-leader.ts CHANGED
@@ -163,6 +163,17 @@ export interface TelegramBusFollowerRegistryRestoreDeps {
163
163
  getNowMs?: () => number;
164
164
  }
165
165
 
166
+ export interface TelegramBusFollowerMessageOwnershipRecord {
167
+ follower: TelegramBusFollowerView;
168
+ chatId: number;
169
+ messageId: number;
170
+ target?: TelegramTarget;
171
+ }
172
+
173
+ export type TelegramBusFollowerMessageOwnershipRecorder = (
174
+ record: TelegramBusFollowerMessageOwnershipRecord,
175
+ ) => void;
176
+
166
177
  export interface TelegramBusLeaderRuntimeDeps<TContext> {
167
178
  socketPath: string;
168
179
  followerRegistry: TelegramBusFollowerRegistry;
@@ -175,6 +186,7 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
175
186
  method: string;
176
187
  args: unknown[];
177
188
  }) => boolean;
189
+ recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
178
190
  provisionFollowerTarget?: (
179
191
  registration: TelegramBusInstanceRegistration,
180
192
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
@@ -325,30 +337,74 @@ export function createTelegramBusFollowerTargetProvisioner(
325
337
  registration.profileKey ?? `manual:${registration.instanceId}`;
326
338
  const followerOwner =
327
339
  Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
328
- deps.onProvisioningStart?.();
329
- let result: Threads.TelegramTopicTargetProvisionResult;
330
- try {
331
- result = await provision({
332
- instanceId: registration.instanceId,
333
- owner:
334
- followerOwner.kind === "manual-follower"
335
- ? followerOwner
336
- : {
337
- kind: "manual-follower",
338
- instanceId: registration.instanceId,
339
- },
340
- profileKey: followerProfileKey,
341
- threadName: registration.threadName,
342
- });
343
- } finally {
344
- deps.onProvisioningEnd?.();
345
- }
340
+ const provisionTarget = async () => {
341
+ deps.onProvisioningStart?.();
342
+ try {
343
+ return await provision({
344
+ instanceId: registration.instanceId,
345
+ owner:
346
+ followerOwner.kind === "manual-follower"
347
+ ? followerOwner
348
+ : {
349
+ kind: "manual-follower",
350
+ instanceId: registration.instanceId,
351
+ },
352
+ profileKey: followerProfileKey,
353
+ threadName: registration.threadName,
354
+ });
355
+ } finally {
356
+ deps.onProvisioningEnd?.();
357
+ }
358
+ };
359
+ let result = await provisionTarget();
346
360
  deps.setSyncState(
347
361
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
348
362
  nowMs: getNowMs(),
349
363
  action: "follower-register",
350
364
  }),
351
365
  );
366
+ let connectedAnnouncement =
367
+ createTelegramBusInstanceLifecycleAnnouncement({
368
+ target: result.target,
369
+ threadName: result.record.threadName,
370
+ slot: result.record.slot,
371
+ state: "connected",
372
+ });
373
+ let connectedAnnouncementSent = false;
374
+ if (result.reused && connectedAnnouncement) {
375
+ try {
376
+ await deps.callApi("sendMessage", {
377
+ chat_id: connectedAnnouncement.target.chatId,
378
+ message_thread_id: connectedAnnouncement.target.threadId,
379
+ text: connectedAnnouncement.text,
380
+ parse_mode: connectedAnnouncement.parseMode,
381
+ });
382
+ connectedAnnouncementSent = true;
383
+ } catch (error) {
384
+ deps.recordRuntimeEvent("telegram", error, {
385
+ phase: "follower-topic-reuse-probe",
386
+ instanceId: registration.instanceId,
387
+ chatId: result.target.chatId,
388
+ threadId: result.target.threadId,
389
+ });
390
+ if (Threads.isTelegramTopicTargetStaleError(error)) {
391
+ deps.topicTargetStore.markStaleByTarget(
392
+ result.target,
393
+ "deleted",
394
+ "Follower registration found the reusable thread target stale.",
395
+ );
396
+ await deps.topicTargetStore.persist();
397
+ result = await provisionTarget();
398
+ connectedAnnouncement = createTelegramBusInstanceLifecycleAnnouncement({
399
+ target: result.target,
400
+ threadName: result.record.threadName,
401
+ slot: result.record.slot,
402
+ state: "connected",
403
+ });
404
+ connectedAnnouncementSent = false;
405
+ }
406
+ }
407
+ }
352
408
  recordSlowTelegramBusFollowerRegistrationStep(deps, {
353
409
  phase: "follower-register-critical",
354
410
  elapsedMs: Date.now() - registrationStartedAtMs,
@@ -356,16 +412,9 @@ export function createTelegramBusFollowerTargetProvisioner(
356
412
  target: result.target,
357
413
  reused: result.reused,
358
414
  });
359
- const connectedAnnouncement =
360
- createTelegramBusInstanceLifecycleAnnouncement({
361
- target: result.target,
362
- threadName: result.record.threadName,
363
- slot: result.record.slot,
364
- state: "connected",
365
- });
366
415
  scheduleTelegramBusLeaderBackgroundTask(async () => {
367
416
  const backgroundStartedAtMs = Date.now();
368
- if (connectedAnnouncement) {
417
+ if (connectedAnnouncement && !connectedAnnouncementSent) {
369
418
  try {
370
419
  await deps.callApi("sendMessage", {
371
420
  chat_id: connectedAnnouncement.target.chatId,
@@ -579,6 +628,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
579
628
  method: string;
580
629
  args: unknown[];
581
630
  }) => boolean;
631
+ recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
582
632
  provisionFollowerTarget?: (
583
633
  registration: TelegramBusInstanceRegistration,
584
634
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
@@ -706,6 +756,72 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
706
756
  };
707
757
  }
708
758
 
759
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
760
+ return value && typeof value === "object" && !Array.isArray(value)
761
+ ? (value as Record<string, unknown>)
762
+ : undefined;
763
+ }
764
+
765
+ function asInteger(value: unknown): number | undefined {
766
+ if (typeof value === "number" && Number.isInteger(value)) return value;
767
+ if (typeof value !== "string" || value.trim() === "") return undefined;
768
+ const parsed = Number(value);
769
+ return Number.isInteger(parsed) ? parsed : undefined;
770
+ }
771
+
772
+ function getFollowerApiMethodAndBody(envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>): {
773
+ apiMethod: string;
774
+ body?: Record<string, unknown>;
775
+ } {
776
+ if (envelope.method === "call" || envelope.method === "callMultipart") {
777
+ return {
778
+ apiMethod:
779
+ typeof envelope.args[0] === "string" ? envelope.args[0] : "",
780
+ body: asRecord(envelope.args[1]),
781
+ };
782
+ }
783
+ return { apiMethod: envelope.method, body: asRecord(envelope.args[0]) };
784
+ }
785
+
786
+ function getSentMessageIds(result: unknown): number[] {
787
+ const values = Array.isArray(result) ? result : [result];
788
+ return values
789
+ .map((value) => asInteger(asRecord(value)?.message_id))
790
+ .filter((messageId): messageId is number => messageId !== undefined);
791
+ }
792
+
793
+ function recordFollowerApiMessageOwnership(input: {
794
+ envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>;
795
+ follower: TelegramBusFollowerView;
796
+ result: unknown;
797
+ record?: TelegramBusFollowerMessageOwnershipRecorder;
798
+ }): void {
799
+ if (!input.record) return;
800
+ const { apiMethod, body } = getFollowerApiMethodAndBody(input.envelope);
801
+ if (
802
+ apiMethod !== "sendMessage" &&
803
+ apiMethod !== "sendRichMessage" &&
804
+ apiMethod !== "sendPhoto" &&
805
+ apiMethod !== "sendDocument" &&
806
+ apiMethod !== "sendVoice" &&
807
+ apiMethod !== "sendMediaGroup"
808
+ ) {
809
+ return;
810
+ }
811
+ const chatId = asInteger(body?.chat_id) ?? input.follower.target?.chatId;
812
+ if (chatId === undefined) return;
813
+ const threadId = asInteger(body?.message_thread_id) ?? input.follower.target?.threadId;
814
+ const target = threadId !== undefined ? { chatId, threadId } : { chatId };
815
+ for (const messageId of getSentMessageIds(input.result)) {
816
+ input.record({
817
+ follower: input.follower,
818
+ chatId,
819
+ messageId,
820
+ target,
821
+ });
822
+ }
823
+ }
824
+
709
825
  async function handleFollowerApiCall(
710
826
  envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>,
711
827
  deps: {
@@ -717,6 +833,7 @@ async function handleFollowerApiCall(
717
833
  method: string;
718
834
  args: unknown[];
719
835
  }) => boolean;
836
+ recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
720
837
  },
721
838
  ): Promise<TelegramBusEnvelope> {
722
839
  const follower = deps.followerRegistry.get(envelope.instanceId);
@@ -753,11 +870,18 @@ async function handleFollowerApiCall(
753
870
  };
754
871
  }
755
872
  try {
873
+ const result = await deps.callApi(envelope.method, envelope.args);
874
+ recordFollowerApiMessageOwnership({
875
+ envelope,
876
+ follower,
877
+ result,
878
+ record: deps.recordFollowerMessageOwnership,
879
+ });
756
880
  return {
757
881
  kind: "bus.ack",
758
882
  requestId: envelope.requestId,
759
883
  ok: true,
760
- result: await deps.callApi(envelope.method, envelope.args),
884
+ result,
761
885
  };
762
886
  } catch (error) {
763
887
  return {
@@ -882,6 +1006,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
882
1006
  getNowMs,
883
1007
  callApi: deps.callApi,
884
1008
  authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
1009
+ recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
885
1010
  provisionFollowerTarget: deps.provisionFollowerTarget,
886
1011
  }),
887
1012
  });
package/lib/bus.ts CHANGED
@@ -79,6 +79,8 @@ export function getTelegramFollowerTargetOwnership(input: {
79
79
  activeThreadRecords?: Array<{
80
80
  status?: string;
81
81
  instanceId?: string;
82
+ profileKey?: string;
83
+ owner?: { kind?: string };
82
84
  target: TelegramTarget;
83
85
  }>;
84
86
  currentInstanceId?: string;
@@ -91,7 +93,11 @@ export function getTelegramFollowerTargetOwnership(input: {
91
93
  });
92
94
  if (liveFollower) return { instanceId: liveFollower.instanceId };
93
95
  const record = input.activeThreadRecords?.find((candidate) => {
96
+ const isFollowerRecord = candidate.owner?.kind
97
+ ? candidate.owner.kind === "manual-follower"
98
+ : candidate.profileKey?.startsWith("manual:") === true;
94
99
  return (
100
+ isFollowerRecord &&
95
101
  candidate.status === "active" &&
96
102
  candidate.instanceId &&
97
103
  candidate.instanceId !== input.currentInstanceId &&
@@ -144,6 +150,28 @@ export function isTelegramFollowerApiCallAllowed(input: {
144
150
  const record = body as Record<string, unknown>;
145
151
  return matchesId(record.chat_id, target.chatId);
146
152
  };
153
+ const isTargetMessageScoped = (body: unknown): boolean => {
154
+ if (!isTargetChatScoped(body)) return false;
155
+ const messageId = (body as Record<string, unknown>).message_id;
156
+ const parsedMessageId =
157
+ typeof messageId === "number" ? messageId : Number(messageId);
158
+ return Number.isInteger(parsedMessageId) && matchesId(messageId, parsedMessageId);
159
+ };
160
+ const isBotCommandRegistration = (body: unknown): boolean => {
161
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
162
+ const commands = (body as Record<string, unknown>).commands;
163
+ return (
164
+ Array.isArray(commands) &&
165
+ commands.every(
166
+ (command) =>
167
+ command &&
168
+ typeof command === "object" &&
169
+ !Array.isArray(command) &&
170
+ typeof (command as Record<string, unknown>).command === "string" &&
171
+ typeof (command as Record<string, unknown>).description === "string",
172
+ )
173
+ );
174
+ };
147
175
  if (input.method === "downloadFile") return true;
148
176
  if (input.method === "call") {
149
177
  const apiMethod = input.args[0];
@@ -155,7 +183,12 @@ export function isTelegramFollowerApiCallAllowed(input: {
155
183
  return true;
156
184
  }
157
185
  if (apiMethod === "getMe") return true;
186
+ if (apiMethod === "setMyCommands")
187
+ return isBotCommandRegistration(input.args[1]);
158
188
  if (apiMethod === "sendChatAction") return isTargetChatScoped(input.args[1]);
189
+ if (apiMethod === "deleteMessage" || apiMethod === "editMessageText") {
190
+ return isTargetMessageScoped(input.args[1]);
191
+ }
159
192
  return allowedCallMethods.has(apiMethod) && isTargetScoped(input.args[1]);
160
193
  }
161
194
  if (input.method === "callMultipart") {
@@ -728,6 +761,17 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
728
761
  return {
729
762
  register: (registration) => {
730
763
  const existing = followers.get(registration.instanceId);
764
+ for (const [instanceId, follower] of followers.entries()) {
765
+ if (instanceId === registration.instanceId) continue;
766
+ const sameProfile =
767
+ registration.profileKey !== undefined &&
768
+ registration.profileKey === follower.profileKey;
769
+ const sameTarget =
770
+ registration.target !== undefined &&
771
+ follower.target?.chatId === registration.target.chatId &&
772
+ follower.target.threadId === registration.target.threadId;
773
+ if (sameProfile || sameTarget) followers.delete(instanceId);
774
+ }
731
775
  const next: TelegramBusFollowerView = {
732
776
  ...registration,
733
777
  target: registration.target ? { ...registration.target } : undefined,
package/lib/routing.ts CHANGED
@@ -217,21 +217,13 @@ function formatTelegramAllTabMenuChooserText(command: string): string {
217
217
  function buildTelegramUnboundRerouteChooserMarkup(
218
218
  rerouteId: string,
219
219
  records: readonly Threads.TelegramTopicTargetRecord[],
220
- options: {
220
+ _options: {
221
221
  currentLeaderProfileKey?: string;
222
222
  currentInstanceId?: string;
223
223
  } = {},
224
224
  ): Menu.TelegramReplyMarkup {
225
225
  const activeRecords = records.filter((record) => record.status === "active");
226
- const canRestoreCurrentLeader = activeRecords.some(
227
- (record) =>
228
- typeof record.rerouteConfirmedAtMs === "number" &&
229
- isCurrentLeaderTopicRecord(
230
- record,
231
- options.currentLeaderProfileKey,
232
- options.currentInstanceId,
233
- ),
234
- );
226
+ const canRestoreAnyLiveThread = activeRecords.length > 0;
235
227
  const rows = activeRecords.map((record) => [
236
228
  {
237
229
  text: getTelegramRouteThreadButtonLabel(record),
@@ -242,7 +234,7 @@ function buildTelegramUnboundRerouteChooserMarkup(
242
234
  },
243
235
  ]);
244
236
  return {
245
- inline_keyboard: canRestoreCurrentLeader
237
+ inline_keyboard: canRestoreAnyLiveThread
246
238
  ? [
247
239
  ...rows,
248
240
  [
@@ -414,6 +406,7 @@ export interface TelegramInboundRouteRuntimeDeps<
414
406
  getCurrentInstanceId?: () => string | undefined;
415
407
  getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
416
408
  getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
409
+ recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
417
410
  getLiveThreadTargets?: () => Queue.TelegramQueueTarget[];
418
411
  getLocalThreadLabelForTarget?: (
419
412
  target: Queue.TelegramQueueTarget,
@@ -1861,6 +1854,7 @@ export function createTelegramInboundRouteRuntime<
1861
1854
  getCurrentInstanceId: deps.getCurrentInstanceId,
1862
1855
  getMessageOwnership: deps.getMessageOwnership,
1863
1856
  getTargetOwnership: deps.getTargetOwnership,
1857
+ recordMessageOwnership: deps.recordMessageOwnership,
1864
1858
  handleTelegramTopicLifecycleUpdate,
1865
1859
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
1866
1860
  setAllowedUserId: deps.configStore.setAllowedUserId,
package/lib/runtime.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
6
 
7
- const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 1500;
7
+ const TELEGRAM_TYPING_ACTION_INTERVAL_MS = 2500;
8
8
  const TELEGRAM_TYPING_IDLE_DRAIN_MAX_MS = 250;
9
9
 
10
10
  export interface TelegramRuntimeQueueCounters {
@@ -398,13 +398,9 @@ export function createTelegramTypingLoopStarter<TContext>(
398
398
  chatId: chatId ?? deps.getDefaultChatId(),
399
399
  target: options?.target,
400
400
  intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
401
- sendTypingAction: async (targetChatId) => {
401
+ sendTypingAction: async (targetChatId, actionOptions) => {
402
402
  try {
403
- const threadParams = getTelegramTypingLoopThreadParams(options?.target);
404
- await deps.sendTypingAction(targetChatId, threadParams);
405
- if (threadParams?.message_thread_id !== undefined) {
406
- await deps.sendAggregateTypingAction?.(targetChatId);
407
- }
403
+ await deps.sendTypingAction(targetChatId, actionOptions);
408
404
  } catch (error) {
409
405
  const message =
410
406
  error instanceof Error ? error.message : String(error);
@@ -419,6 +415,26 @@ export function createTelegramTypingLoopStarter<TContext>(
419
415
  });
420
416
  }
421
417
  },
418
+ sendAggregateTypingAction: deps.sendAggregateTypingAction
419
+ ? async (targetChatId) => {
420
+ try {
421
+ await deps.sendAggregateTypingAction?.(targetChatId);
422
+ } catch (error) {
423
+ const message =
424
+ error instanceof Error ? error.message : String(error);
425
+ updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
426
+ error: message,
427
+ category: "typing",
428
+ phase: "status-update",
429
+ recordRuntimeEvent: deps.recordRuntimeEvent,
430
+ });
431
+ deps.recordRuntimeEvent?.("typing", error, {
432
+ chatId: targetChatId,
433
+ aggregate: true,
434
+ });
435
+ }
436
+ }
437
+ : undefined,
422
438
  });
423
439
  };
424
440
  }
package/lib/status.ts CHANGED
@@ -771,8 +771,6 @@ export function buildTelegramStatusBarText(
771
771
  return `${label} ${theme.fg("warning", "electing")}${queued}`;
772
772
  if (!state.pollingActive && state.busRole !== "follower")
773
773
  return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
774
- if (state.busRole === "follower")
775
- return `${label} ${theme.fg("success", "follower")}${queued}`;
776
774
  if (state.compactionInProgress) {
777
775
  return `${label} ${theme.fg("warning", "compacting")}${queued}`;
778
776
  }
@@ -784,6 +782,8 @@ export function buildTelegramStatusBarText(
784
782
  processingStatus === "active" ? "warning" : "accent";
785
783
  return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
786
784
  }
785
+ if (state.busRole === "follower")
786
+ return `${label} ${theme.fg("success", "follower")}${queued}`;
787
787
  if (state.busRole === "leader")
788
788
  return `${label} ${theme.fg("success", "leader")}`;
789
789
  return `${label} ${theme.fg("success", "connected")}`;
package/lib/threads.ts CHANGED
@@ -1438,7 +1438,7 @@ function getTelegramThreadNameEntropyIndex(
1438
1438
 
1439
1439
  export function getTelegramTopicThreadNameValidationError(
1440
1440
  threadName: string,
1441
- slot: string | undefined,
1441
+ _slot: string | undefined,
1442
1442
  ): string | undefined {
1443
1443
  const identity = getTelegramTopicIdentityName(threadName);
1444
1444
  const reasons: string[] = [];
@@ -1501,6 +1501,60 @@ function asInteger(value: unknown): number | undefined {
1501
1501
  return Number.isInteger(parsed) ? parsed : undefined;
1502
1502
  }
1503
1503
 
1504
+ export interface TelegramPromoteFollowerBindingToLeaderDeps {
1505
+ store: TelegramTopicTargetStore;
1506
+ instanceId: string;
1507
+ cwd?: string;
1508
+ target?: TelegramTarget;
1509
+ slot?: string;
1510
+ threadName?: string;
1511
+ nowMs?: number;
1512
+ }
1513
+
1514
+ export async function promoteTelegramFollowerBindingToLeader(
1515
+ deps: TelegramPromoteFollowerBindingToLeaderDeps,
1516
+ ): Promise<TelegramTopicTargetRecord | undefined> {
1517
+ const target = deps.target;
1518
+ if (typeof target?.threadId !== "number") return undefined;
1519
+ await deps.store.load();
1520
+ const nowMs = deps.nowMs ?? Date.now();
1521
+ const existing = deps.store
1522
+ .list()
1523
+ .find(
1524
+ (record) =>
1525
+ record.target.chatId === target.chatId &&
1526
+ record.target.threadId === target.threadId,
1527
+ );
1528
+ const owner: TelegramThreadOwner = {
1529
+ kind: "leader",
1530
+ cwd: deps.cwd,
1531
+ instanceId: deps.instanceId,
1532
+ };
1533
+ const record = deps.store.upsert({
1534
+ profileKey: getTelegramThreadOwnerKey(owner),
1535
+ owner,
1536
+ target: { chatId: target.chatId, threadId: target.threadId },
1537
+ status: "active",
1538
+ createdAtMs: existing?.createdAtMs ?? nowMs,
1539
+ updatedAtMs: nowMs,
1540
+ ...(existing?.threadName ?? deps.threadName
1541
+ ? { threadName: existing?.threadName ?? deps.threadName }
1542
+ : {}),
1543
+ instanceId: deps.instanceId,
1544
+ ...(existing?.slot ?? deps.slot ? { slot: existing?.slot ?? deps.slot } : {}),
1545
+ ...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
1546
+ ...(existing?.lastSyncObservedAtMs !== undefined
1547
+ ? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
1548
+ : {}),
1549
+ lastReconcileAction: "follower-promoted-to-leader",
1550
+ ...(existing?.rerouteConfirmedAtMs !== undefined
1551
+ ? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
1552
+ : {}),
1553
+ });
1554
+ await deps.store.persist();
1555
+ return record;
1556
+ }
1557
+
1504
1558
  export interface TelegramOwnTopicProvisionDeps {
1505
1559
  getAllowedUserId: () => number | undefined;
1506
1560
  instanceId: string;
@@ -2077,7 +2131,7 @@ export function createTelegramTopicTargetProvisioner(
2077
2131
  existing.threadName ?? identityThreadName ?? bakedThreadName,
2078
2132
  instanceId: request.instanceId,
2079
2133
  slot,
2080
- owner: existing.owner ?? request.owner,
2134
+ owner: request.owner ?? existing.owner,
2081
2135
  lastError: undefined,
2082
2136
  });
2083
2137
  return { target: record.target, reused: true, record };
package/lib/updates.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  createTelegramThreadTarget,
10
10
  type TelegramTarget,
11
11
  } from "./target.ts";
12
+ import type { TelegramMessageOwnershipStore } from "./ownership.ts";
12
13
  import {
13
14
  createTelegramUserPairingRuntime,
14
15
  getTelegramAuthorizationState,
@@ -538,6 +539,14 @@ export function buildTelegramUpdateExecutionPlanFromUpdate<
538
539
 
539
540
  // --- Runtime ---
540
541
 
542
+ export type TelegramMessageOwnershipRecorderInput = Parameters<
543
+ TelegramMessageOwnershipStore["record"]
544
+ >[0];
545
+
546
+ export type TelegramMessageOwnershipRecorder = (
547
+ input: TelegramMessageOwnershipRecorderInput,
548
+ ) => void;
549
+
541
550
  export interface TelegramUpdateRuntimeDeps<
542
551
  TContext = unknown,
543
552
  TReactionUpdate extends TelegramMessageReactionUpdated =
@@ -549,6 +558,7 @@ export interface TelegramUpdateRuntimeDeps<
549
558
  getCurrentInstanceId?: () => string | undefined;
550
559
  getMessageOwnership?: TelegramMessageOwnershipLookup;
551
560
  getTargetOwnership?: TelegramTargetOwnershipLookup;
561
+ recordMessageOwnership?: TelegramMessageOwnershipRecorder;
552
562
  foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
553
563
  TContext,
554
564
  TReactionUpdate,
@@ -612,6 +622,7 @@ export interface TelegramUpdateRuntimeControllerDeps<
612
622
  getCurrentInstanceId?: () => string | undefined;
613
623
  getMessageOwnership?: TelegramMessageOwnershipLookup;
614
624
  getTargetOwnership?: TelegramTargetOwnershipLookup;
625
+ recordMessageOwnership?: TelegramMessageOwnershipRecorder;
615
626
  foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
616
627
  TContext,
617
628
  TelegramMessageReactionUpdated,
@@ -824,6 +835,7 @@ export function createTelegramPairedUpdateRuntime<
824
835
  getCurrentInstanceId: deps.getCurrentInstanceId,
825
836
  getMessageOwnership: deps.getMessageOwnership,
826
837
  getTargetOwnership: deps.getTargetOwnership,
838
+ recordMessageOwnership: deps.recordMessageOwnership,
827
839
  handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
828
840
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
829
841
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
@@ -890,6 +902,7 @@ export function createTelegramUpdateRuntime<
890
902
  getCurrentInstanceId: deps.getCurrentInstanceId,
891
903
  getMessageOwnership: deps.getMessageOwnership,
892
904
  getTargetOwnership: deps.getTargetOwnership,
905
+ recordMessageOwnership: deps.recordMessageOwnership,
893
906
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
894
907
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
895
908
  removeQueuedTelegramTurnsByMessageIds:
@@ -1109,11 +1122,40 @@ export async function executeTelegramUpdatePlan<
1109
1122
  }
1110
1123
  return;
1111
1124
  }
1125
+ const foreignMessageOwnership = getForeignTelegramMessageOwnership(
1126
+ getTelegramMessageReplyTarget(plan.message),
1127
+ deps,
1128
+ );
1129
+ if (foreignMessageOwnership) {
1130
+ if (plan.kind === "edited-message") {
1131
+ await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1132
+ message: plan.message,
1133
+ ownership: foreignMessageOwnership,
1134
+ ctx: deps.ctx,
1135
+ });
1136
+ } else {
1137
+ await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1138
+ message: plan.message,
1139
+ ownership: foreignMessageOwnership,
1140
+ ctx: deps.ctx,
1141
+ });
1142
+ }
1143
+ return;
1144
+ }
1145
+ const messageTarget = getTelegramMessageTarget(plan.message);
1112
1146
  const foreignTargetOwnership = getForeignTelegramTargetOwnership(
1113
- getTelegramMessageTarget(plan.message),
1147
+ messageTarget,
1114
1148
  deps,
1115
1149
  );
1116
1150
  if (foreignTargetOwnership) {
1151
+ if (typeof plan.message.message_id === "number") {
1152
+ deps.recordMessageOwnership?.({
1153
+ chatId: messageTarget!.chatId,
1154
+ messageId: plan.message.message_id,
1155
+ target: messageTarget,
1156
+ instanceId: foreignTargetOwnership.instanceId,
1157
+ });
1158
+ }
1117
1159
  if (plan.kind === "edited-message") {
1118
1160
  await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1119
1161
  message: plan.message,
@@ -1129,7 +1171,6 @@ export async function executeTelegramUpdatePlan<
1129
1171
  }
1130
1172
  return;
1131
1173
  }
1132
- const messageTarget = getTelegramMessageTarget(plan.message);
1133
1174
  if (
1134
1175
  plan.kind === "message" &&
1135
1176
  messageTarget?.threadId != null &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"