@llblab/pi-telegram 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -487,6 +487,7 @@ export default function (pi: Pi.ExtensionAPI) {
487
487
  getMessageText: Replies.getAgentMessageText,
488
488
  getDefaultReplyToMessageId: activeTurnRuntime.getReplyToMessageId,
489
489
  sendDraft: nativeMarkdownDraftSender,
490
+ canSend: configControls.areRichDraftPreviewsEnabled,
490
491
  sendMarkdownReply,
491
492
  recordRuntimeEvent,
492
493
  ...replyTransport,
@@ -662,6 +663,9 @@ export default function (pi: Pi.ExtensionAPI) {
662
663
  return telegramInstanceId;
663
664
  },
664
665
  getMessageOwnership: messageOwnershipStore.get,
666
+ recordMessageOwnership(input) {
667
+ messageOwnershipStore.record(input);
668
+ },
665
669
  getTargetOwnership(target) {
666
670
  return Bus.getTelegramFollowerTargetOwnership({
667
671
  target,
@@ -821,7 +825,29 @@ export default function (pi: Pi.ExtensionAPI) {
821
825
  telegramBusLifecycleOverridePhase = phase;
822
826
  },
823
827
  updateStatus,
824
- async promoteToLeader(ctx) {
828
+ async promoteToLeader(ctx, binding) {
829
+ const promotedRecord =
830
+ await Threads.promoteTelegramFollowerBindingToLeader({
831
+ store: threadStore,
832
+ instanceId: telegramInstanceId,
833
+ cwd: ctx.cwd,
834
+ target: binding.target,
835
+ slot: binding.slot,
836
+ threadName: binding.threadName,
837
+ });
838
+ if (promotedRecord) {
839
+ recordRuntimeEvent(
840
+ "bus",
841
+ "Follower thread binding promoted to leader",
842
+ {
843
+ phase: "follower-promoted-binding",
844
+ chatId: promotedRecord.target.chatId,
845
+ threadId: promotedRecord.target.threadId,
846
+ slot: promotedRecord.slot,
847
+ threadName: promotedRecord.threadName,
848
+ },
849
+ );
850
+ }
825
851
  await lockedPollingRuntime.start(ctx, { force: true });
826
852
  },
827
853
  sleep(ms) {
@@ -898,6 +924,14 @@ export default function (pi: Pi.ExtensionAPI) {
898
924
  startPolling: pollingRuntime.start,
899
925
  stopPolling: pollingRuntime.stop,
900
926
  authorizeFollowerApiCall: Bus.isTelegramFollowerApiCallAllowed,
927
+ recordFollowerMessageOwnership(record) {
928
+ messageOwnershipStore.record({
929
+ chatId: record.chatId,
930
+ messageId: record.messageId,
931
+ target: record.target,
932
+ instanceId: record.follower.instanceId,
933
+ });
934
+ },
901
935
  provisionLeaderTarget:
902
936
  BusLeader.createTelegramBusLeaderTargetProvisioner<Pi.ExtensionContext>({
903
937
  getAllowedUserId: configStore.getAllowedUserId,
@@ -1273,8 +1307,6 @@ export default function (pi: Pi.ExtensionAPI) {
1273
1307
  sendRecordVoiceAction,
1274
1308
  sendMarkdownReply,
1275
1309
  sendTextReply,
1276
- editInteractiveMessage,
1277
- deleteMessage: deleteTelegramMessage,
1278
1310
  dispatchNextQueuedTelegramTurn,
1279
1311
  answerGuestQuery,
1280
1312
  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/config.ts CHANGED
@@ -57,6 +57,7 @@ export interface TelegramConfig {
57
57
  attachmentHandlers?: TelegramInboundHandlerConfig[];
58
58
  outboundHandlers?: TelegramOutboundHandlerConfig[];
59
59
  proactivePush?: boolean;
60
+ richDraftPreviews?: boolean;
60
61
  voice?: {
61
62
  replyMode?: "manual" | "mirror" | "always";
62
63
  /** Whether to attach the provider's transcriptText as caption on voice messages */
@@ -259,6 +260,23 @@ export function createTelegramProactivePushSetter(
259
260
  };
260
261
  }
261
262
 
263
+ export function createTelegramRichDraftPreviewsChecker(
264
+ configStore: Pick<TelegramConfigStore, "get">,
265
+ ): () => boolean {
266
+ return () => configStore.get().richDraftPreviews ?? false;
267
+ }
268
+
269
+ export function createTelegramRichDraftPreviewsSetter(
270
+ configStore: TelegramMutableConfigStore,
271
+ ): (enabled: boolean) => Promise<void> {
272
+ return async (enabled) => {
273
+ await loadLatestTelegramConfig(configStore);
274
+ const config = { ...configStore.get(), richDraftPreviews: enabled };
275
+ configStore.set(config);
276
+ await configStore.persist(config);
277
+ };
278
+ }
279
+
262
280
  export function createTelegramVoiceReplyModeGetter(
263
281
  configStore: Pick<TelegramConfigStore, "get">,
264
282
  ): () => "manual" | "mirror" | "always" {
@@ -394,6 +412,10 @@ export function createTelegramConfigControls(
394
412
  return {
395
413
  isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
396
414
  setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
415
+ areRichDraftPreviewsEnabled:
416
+ createTelegramRichDraftPreviewsChecker(configStore),
417
+ setRichDraftPreviewsEnabled:
418
+ createTelegramRichDraftPreviewsSetter(configStore),
397
419
  getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
398
420
  isVoiceReplyModeConfigured:
399
421
  createTelegramVoiceReplyModeConfiguredChecker(configStore),