@llblab/pi-telegram 0.27.2 → 0.27.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 CHANGED
@@ -82,7 +82,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
82
82
  - `/telegram-connect` is the bounded liveness-recovery boundary for unclean-shutdown corruption in disposable `tmp/telegram` state. It may quarantine only classifier-approved malformed `owners.json`, active-profile `state*.json`, and `owners.json.transaction` after serializing recoverers and revalidating under ownership fencing; it must preserve `telegram.json`, diagnostics, unrelated artifacts, and every verifiable live owner/transaction holder. Stale heartbeats older than eight seconds do not gain protection through PID reuse. Require confirmed local polling suspension before quarantine mutation; a suspension failure blocks recovery, while an ownership-release failure may continue only into final guarded live-owner revalidation. Use the established bounded Windows sharing-violation rename retries. Retry polling exactly once after recovery, preserve unrelated startup errors, and replace blocked/repeated recovery failure with one explicit restart instruction rather than broad `tmp/` deletion or an automatic loop.
83
83
  - Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Persist every bot/session field (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) under `telegram.json` `profiles.<name>`, including `profiles.default`; keep shared handlers and assistant/voice/time/thread-lifecycle settings top-level. Bare and explicit `default` setup/connect commands address the same profile. Legacy root identity may normalize atomically only when unambiguous; conflicts fail closed without mutation. Any owner slot, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Use the `default` owner slot for the default profile and validated profile names for named slots; give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
84
84
  - Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
85
- - 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. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. 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 only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. 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 by itself 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 confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. 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` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader deletes its own bound thread before releasing transport ownership, while a follower sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Cleanup must be confirmed by successful deletion or explicit already-gone evidence. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before destructive API work; interruption preserves that intent for the current or successor leader to replay under its own epoch, while leader startup must first adopt a still-active same-profile binding and cancel its superseded cleanup before any destructive API call. Confirmation removes both the binding and intent atomically from the next persisted snapshot. Silence, heartbeat expiry, process absence, IPC failure, and auth mismatch create no cleanup intent by themselves; only stale heartbeat combined with enabled cleanup and OS-confirmed absence of the exact registered follower PID may authorize fallback intent. Incomplete manual cleanup preserves binding/routing state, keeps the session open, and returns actionable retry guidance. A promoted leader authorizes inherited follower-thread cleanup by its current leader epoch rather than the historical owner label. 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.
85
+ - 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. Heartbeat acknowledgements carry the authenticated live follower-slot roster: on leader loss, the lowest observed live slot attempts the atomic lock first, while higher slots defer for one bounded grace and then re-check so a missing lower follower cannot block recovery indefinitely. 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 only through an atomic expected-owner transition after the exact observed leader lease becomes stale or inactive. An unreachable IPC endpoint does not authorize replacing a still-live lock owner; remain disconnected/retrying rather than creating split-brain polling. 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 by itself 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 confirmed manual disconnect, graceful Pi quit, or confirmed reconciliation cases. A stale follower may become a confirmed reconciliation case only when Thread cleanup is enabled, the OS reports the exact registered PID absent, and cleanup serializes ahead of replacement registration; live/unknown PID state remains non-destructive. 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` retains destructive confirmation; graceful Pi `quit` invokes the same teardown without prompting when top-level `threads.automaticCleanup` is enabled (default), while disabling it and `reload`, `new`, `resume`, or `fork` preserve the binding through safe handoff/suspension. A leader persists cleanup intent and attempts to delete its own bound thread before releasing transport ownership, while a follower sends an authenticated exact-registration-generation disconnect envelope and waits for the leader to delete its assigned thread, mark the durable binding offline, and remove live routing authority before unregistering. Successful cleanup still requires confirmed deletion or explicit already-gone evidence, but an incomplete manual leader cleanup must not retain the transport lock: release leadership, preserve the binding plus intent, and let the successor replay it. Confirmed leader/follower teardown persists an exact target/runtime-generation cleanup intent before destructive API work; interruption preserves that intent for the current or successor leader to replay under its own epoch, while leader startup must first adopt a still-active same-profile binding and cancel its superseded cleanup before any destructive API call. Confirmation removes both the binding and intent atomically from the next persisted snapshot. Silence, heartbeat expiry, process absence, IPC failure, and auth mismatch create no cleanup intent by themselves; only stale heartbeat combined with enabled cleanup and OS-confirmed absence of the exact registered follower PID may authorize fallback intent. Incomplete manual follower cleanup preserves binding/routing state and returns actionable retry guidance; incomplete leader or promoted-leader cleanup preserves the durable intent but disconnects and releases leadership with a warning so cleanup failure cannot pin election. A promoted leader authorizes inherited follower-thread cleanup by its current leader epoch rather than the historical owner label. 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.
86
86
  - Runtime routing binds to a live `instanceId` plus its exact follower registration generation; current thread identity should use explicit owner records (`leader`, `manual-follower`, `pending-topic`) rather than hiding ownership direction in legacy string keys. Persisted manual-follower records may act as stable restart hints, but only an authenticated live registry entry authorizes inbound forwarding or outbound transport; 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.
87
87
  - 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. Registration requires a present exact generation. Cross-session target reuse and any carried target absent from current bindings require one synchronous visibility probe: explicit stale evidence may provision a replacement, while ambiguous/non-stale failure must reject registration without replay, binding invalidation, or speculative replacement. Persist an ambiguously observed absent target only as non-routable `probe-required` restoration evidence so targetless retries and leader reloads must probe the exact target again before activation or replacement. Registration and explicit disconnect mutations for one durable follower profile must serialize across awaited cleanup so an old runtime's disconnect cannot delete or unregister a replacement generation. Manual follower identity must survive a Pi process reload in the same terminal so reload is not treated as a new follower/thread; derive stable process-birth identity from OS metadata where available, and carry a fresh authenticated previous-runtime-plus-exact-target handoff through initial registration so a changed identity migrates rather than provisions. 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.
88
88
  - 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 survive process absence as stable restoration hints unless enabled Thread cleanup turns stale heartbeat plus exact-PID death confirmation into fenced cleanup authority; otherwise only explicit stale/deleted/offline evidence invalidates them. Successful target reuse must refresh the binding timestamp and replacement runtime identity; generic leader startup or heartbeat pruning alone must not erase the target merely because no follower registration is currently live. 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. Preserve a cursor that belongs to a live or recoverable binding and continue skipping retained restart hints plus unexpired pending/reserved slots. Explicit stale/deleted/offline reconciliation may release that slot; generic process absence may not. The alphabet cap is a feature — compact single-letter tabs are scannable.
@@ -94,7 +94,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
94
94
  - 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 or command-created temporary threads receive the complete forward plus replace/restore control set and remain preserved until the user chooses one. Successful forward deletes the chooser and temporary thread; successful replace/restore always deletes the chooser, rebinds the temporary thread, and deletes only the replaced old thread. Any destructive cleanup must go through `thread-reconciler`. Unknown `forum_topic_created` service events are observations, not destructive cleanup proof.
95
95
  - 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.
96
96
  - 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.
97
- - Local bus IPC is a local-only trust boundary: private socket directories/files are necessary but not the whole authorization model; never let arbitrary local processes inject prompts, callbacks, API sends, or artifact routes.
97
+ - Local bus IPC is a local-only trust boundary: private socket directories/files are necessary but not the whole authorization model; never let arbitrary local processes inject prompts, callbacks, API sends, or artifact routes. Ordinary follower Bot API sends remain assigned-target scoped; runtime-marked `telegram_message` cross-target sends may address only a different thread in the same paired chat, and the leader must strip that internal authorization marker before Telegram transport.
98
98
  - Queue admission is explicit and validated: immediate commands, control lane, priority lane, and default lane must preserve allowed kind/lane pairings
99
99
  - Dispatch is gated by active turns, pending dispatch, unsettled control work, compaction, `ctx.isIdle()`, and Pi pending messages; dispatched prompts remain queued until `agent_start` consumes them
100
100
  - Telegram `/compact` owns a native `typing` keepalive for the compaction window so phone clients show activity between the started/completed notices; stop it on both completion and failure. Automatic/session compaction may start native typing only when an active Telegram turn exists, and must reuse that turn target; startup, reload, connect, restore, and idle/background compaction must not create visible typing by themselves.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.4: Disconnect Leadership Release Hotfix
4
+
5
+ - `Leadership Release`: Manual disconnect now treats thread deletion as a durable cleanup side effect rather than a prerequisite for relinquishing transport leadership. A leader or promoted follower persists the exact cleanup intent, attempts close/delete under its epoch, and then stops polling and releases the owner lock even when Telegram rejects cleanup or the epoch changes. Impact: `Telegram Error` after `/telegram-disconnect` can no longer leave a live process pinning leadership and force newly connected instances to become followers.
6
+ - `Successor Recovery`: Incomplete leader cleanup remains recorded with its binding and exact generation for the next elected leader to replay; the disconnect returns a warning instead of throwing before lock release. Successful cleanup behavior remains unchanged, and manual followers still require authenticated live-leader teardown. Impact: thread safety and retryability survive without sacrificing leadership liveness.
7
+ - `Regression Coverage`: Added exact promoted-leader and epoch-loss disconnect regressions proving cleanup diagnostics remain visible while `stopPolling` still runs. Impact: future cleanup hardening cannot silently restore the lock-hostage failure mode.
8
+
9
+ ## 0.27.3: Follower Cross-Thread Delivery Hotfix
10
+
11
+ - `Follower Cross-Thread Delivery`: The bus-aware Telegram API runtime now marks sends whose target differs from the follower's assigned thread, and the leader authorizes that internal marker only for a different target inside the same paired chat before stripping it from the Bot API request. Impact: `telegram_message` can fulfill explicit inter-thread delivery from follower instances without opening arbitrary cross-chat or generic follower API access.
12
+ - `Authorization Coverage`: Focused regressions cover follower marker admission, assigned-target rejection, wrong-chat rejection, unmarked rejection, transport metadata stripping, and Rich-message marking while preserving the existing target-aware active-turn guard. Impact: the outbound policy and bus trust boundary now agree end to end instead of allowing a request locally and rejecting it at leader transport.
13
+
3
14
  ## 0.27.2: Target-Aware Direct Message Guard
4
15
 
5
16
  - `Direct Message Guard`: `telegram_message` now rejects implicit delivery and an explicit target equal to the active Telegram turn, leaving the ordinary final-reply path as the sole current-target response. An explicit different chat/thread target remains allowed for user-requested cross-target delivery, while local/TUI direct sends and automatic Proactive Push remain unchanged. Impact: accidental duplicate replies are prevented without breaking legitimate inter-thread messaging.
@@ -408,7 +408,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
408
408
 
409
409
  - Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
410
410
  - Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
411
- - Follower Bot API proxying is allowlisted and target-scoped where applicable so a follower can reply in its assigned thread without gaining arbitrary bot control.
411
+ - Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
412
412
  - Button and section callbacks verify authorized `from.id` and owning target/instance.
413
413
  - Generated artifacts stay scoped to the owning thread after leader failover.
414
414
  - Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
@@ -439,7 +439,7 @@ Live client and native Windows evidence gates are tracked in `BACKLOG.md`; this
439
439
  - Local IPC is the default internal bus. Registered followers receive normalized inbound updates and send allowlisted, target-scoped Bot API calls through the leader.
440
440
  - Thread targets are current-state bindings, not historical delivery addresses. Stable restart hints require a fresh authenticated follower registration before they become live routing authority; stale/offline/failed entries remain reconciliation evidence only.
441
441
  - Failover promotes a remaining follower after dead or clean-disconnected leaders without creating competing pollers; follower heartbeat recovery owns re-register → grace → promotion while preserving thread bindings across transient leader reload gaps.
442
- - Thread cleanup is centralized in `thread-reconciler`, fails closed without a leader epoch while leadership exists, revalidates that epoch immediately before every close/delete call and local cleanup-state mutation, and requires confirmed delete/stale evidence before state is marked deleted.
442
+ - Thread cleanup is centralized in `thread-reconciler`, fails closed without a leader epoch while leadership exists, revalidates that epoch immediately before every close/delete call and local cleanup-state mutation, and requires confirmed delete/stale evidence before state is marked deleted. Manual leader and promoted-leader disconnect persist cleanup intent first but always stop polling and release the owner lock even when Telegram cleanup is incomplete; the successor replays the retained intent, so a Bot API or epoch failure cannot pin leadership. Manual followers still require a live leader acknowledgement for destructive teardown.
443
443
  - Stable docs/UI now describe classic mode, opt-in Threaded Mode, manual follower registration, status/diagnostics, unbound-thread reroute/restore UX, and operator recovery boundaries.
444
444
 
445
445
  ## Evidence Gates
package/lib/bus-api.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * Wraps the direct Telegram Bot API runtime so follower instances can route outbound calls through the bus leader
5
5
  */
6
6
 
7
- import { stripTelegramBusApiMetadata } from "./bus.ts";
7
+ import {
8
+ markTelegramBusCrossTargetDelivery,
9
+ stripTelegramBusApiMetadata,
10
+ } from "./bus.ts";
8
11
  import { isTelegramMessageNotModifiedError } from "./telegram-api.ts";
9
12
  import type {
10
13
  TelegramAnswerGuestQueryOptions,
@@ -56,6 +59,19 @@ function withDefaultThreadTarget(
56
59
  : body;
57
60
  }
58
61
 
62
+ function markFollowerCrossTargetDelivery<T extends Record<string, unknown>>(
63
+ body: T,
64
+ defaultTarget: { chatId: number; threadId?: number } | undefined,
65
+ ): T {
66
+ if (!defaultTarget || body.chat_id !== defaultTarget.chatId) return body;
67
+ const threadId = body.message_thread_id;
68
+ const isDifferentTarget =
69
+ threadId === undefined
70
+ ? defaultTarget.threadId !== undefined
71
+ : threadId !== defaultTarget.threadId;
72
+ return isDifferentTarget ? markTelegramBusCrossTargetDelivery(body) : body;
73
+ }
74
+
59
75
  function rejectTelegramDirectOwnership(method: string): Promise<never> {
60
76
  return Promise.reject(
61
77
  new Error(`Telegram ${method} requires direct transport ownership.`),
@@ -238,16 +254,28 @@ export function createTelegramBusAwareApiRuntime(
238
254
  return deps.ownsDirect()
239
255
  ? deps.directRuntime.sendMessage(stripTelegramBusApiMetadata(body))
240
256
  : deps
241
- .callFollowerApi("call", ["sendMessage", body])
257
+ .callFollowerApi("call", [
258
+ "sendMessage",
259
+ markFollowerCrossTargetDelivery(
260
+ body,
261
+ deps.getDefaultTarget?.(),
262
+ ),
263
+ ])
242
264
  .then(asSentMessage);
243
265
  },
244
266
  sendRichMessage(
245
267
  body: TelegramSendRichMessageBody,
246
268
  ): Promise<TelegramSentMessage> {
247
269
  return deps.ownsDirect()
248
- ? deps.directRuntime.sendRichMessage(body)
270
+ ? deps.directRuntime.sendRichMessage(stripTelegramBusApiMetadata(body))
249
271
  : deps
250
- .callFollowerApi("call", ["sendRichMessage", body])
272
+ .callFollowerApi("call", [
273
+ "sendRichMessage",
274
+ markFollowerCrossTargetDelivery(
275
+ body,
276
+ deps.getDefaultTarget?.(),
277
+ ),
278
+ ])
251
279
  .then(asSentMessage);
252
280
  },
253
281
  sendRichMessageDraft(
package/lib/bus.ts CHANGED
@@ -229,6 +229,8 @@ export function getTelegramFollowerTargetOwnership(input: {
229
229
  }
230
230
 
231
231
  const TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD = "__piTelegramAggregateDelivery";
232
+ const TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD =
233
+ "__piTelegramCrossTargetDelivery";
232
234
 
233
235
  export function markTelegramBusAggregateDelivery<
234
236
  T extends Record<string, unknown>,
@@ -249,12 +251,38 @@ export function isTelegramBusAggregateDelivery(body: unknown): boolean {
249
251
  );
250
252
  }
251
253
 
254
+ export function markTelegramBusCrossTargetDelivery<
255
+ T extends Record<string, unknown>,
256
+ >(body: T): T {
257
+ return {
258
+ ...body,
259
+ [TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD]: true,
260
+ };
261
+ }
262
+
263
+ export function isTelegramBusCrossTargetDelivery(body: unknown): boolean {
264
+ return Boolean(
265
+ body &&
266
+ typeof body === "object" &&
267
+ !Array.isArray(body) &&
268
+ (body as Record<string, unknown>)[
269
+ TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD
270
+ ] === true,
271
+ );
272
+ }
273
+
252
274
  export function stripTelegramBusApiMetadata<T extends Record<string, unknown>>(
253
275
  body: T,
254
276
  ): T {
255
- if (!(TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD in body)) return body;
277
+ if (
278
+ !(TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD in body) &&
279
+ !(TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD in body)
280
+ ) {
281
+ return body;
282
+ }
256
283
  const clean = { ...body };
257
284
  delete clean[TELEGRAM_BUS_AGGREGATE_DELIVERY_FIELD];
285
+ delete clean[TELEGRAM_BUS_CROSS_TARGET_DELIVERY_FIELD];
258
286
  return clean;
259
287
  }
260
288
 
@@ -304,6 +332,18 @@ export function isTelegramFollowerApiCallAllowed(input: {
304
332
  const record = body as Record<string, unknown>;
305
333
  return matchesId(record.chat_id, target.chatId);
306
334
  };
335
+ const isDifferentTargetScoped = (body: unknown): boolean => {
336
+ if (!target || !isTargetChatScoped(body)) return false;
337
+ const threadId = (body as Record<string, unknown>).message_thread_id;
338
+ if (threadId === undefined) return target.threadId !== undefined;
339
+ const parsedThreadId =
340
+ typeof threadId === "number" ? threadId : Number(threadId);
341
+ return (
342
+ Number.isInteger(parsedThreadId) &&
343
+ (target.threadId === undefined ||
344
+ !matchesId(threadId, target.threadId))
345
+ );
346
+ };
307
347
  const isTargetMessageScoped = (body: unknown): boolean => {
308
348
  if (!isTargetChatScoped(body)) return false;
309
349
  const messageId = (body as Record<string, unknown>).message_id;
@@ -350,6 +390,12 @@ export function isTelegramFollowerApiCallAllowed(input: {
350
390
  const body = input.args[1] as Record<string, unknown>;
351
391
  return body.message_thread_id === undefined && isTargetChatScoped(body);
352
392
  }
393
+ if (
394
+ (apiMethod === "sendMessage" || apiMethod === "sendRichMessage") &&
395
+ isTelegramBusCrossTargetDelivery(input.args[1])
396
+ ) {
397
+ return isDifferentTargetScoped(input.args[1]);
398
+ }
353
399
  if (
354
400
  apiMethod === "deleteMessage" ||
355
401
  apiMethod === "editMessageReplyMarkup" ||
package/lib/sync.ts CHANGED
@@ -167,6 +167,7 @@ export function createTelegramManualThreadDisconnectHandler<
167
167
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
168
168
  return async () => {
169
169
  const currentRecord = deps.getCurrentThreadRecord();
170
+ let cleanupPending = false;
170
171
  if (currentRecord?.target.threadId) {
171
172
  const isManualFollower = currentRecord.owner?.kind === "manual-follower";
172
173
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -228,11 +229,7 @@ export function createTelegramManualThreadDisconnectHandler<
228
229
  recordRuntimeEvent: deps.recordRuntimeEvent,
229
230
  },
230
231
  );
231
- if (cleanup.incompleteActions?.length) {
232
- throw new Error(
233
- "Telegram thread deletion was not confirmed; inspect /telegram-status --debug and retry /telegram-disconnect.",
234
- );
235
- }
232
+ cleanupPending = Boolean(cleanup.incompleteActions?.length);
236
233
  }
237
234
  const leaderTarget = deps.getLeaderTarget();
238
235
  if (
@@ -248,7 +245,10 @@ export function createTelegramManualThreadDisconnectHandler<
248
245
  }) as TSyncState,
249
246
  );
250
247
  }
251
- return deps.stopPolling();
248
+ const stopped = await deps.stopPolling();
249
+ return cleanupPending
250
+ ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
251
+ : stopped;
252
252
  };
253
253
  }
254
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.27.2",
3
+ "version": "0.27.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"