@llblab/pi-telegram 0.23.0 → 0.23.2

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/docs/outbound.md CHANGED
@@ -30,7 +30,7 @@ When `assistant.rendering` is `"rich"`, a Telegram-originated turn that queues e
30
30
 
31
31
  The optimization is deliberately narrow. HTML rendering, empty final text, multiple files, documents and other unsupported formats, Guest Mode, explicit `telegram_voice`, voice-preferred turns, and OGG/Opus artifacts retain their established text/attachment/voice paths. A known-safe Rich upload rejection falls back to those paths. A `commit-unknown` transport outcome or a nominally successful upload without a verifiable message id never falls back or replays because the first non-idempotent send may already have committed.
32
32
 
33
- This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `manual`, `mirror`, and `always` continue to decide voice synthesis independently.
33
+ This behavior does not generate media or alter voice policy. `telegram_attach` still represents an explicit assistant artifact decision, while `hidden`, `mirror`, and `always` continue to decide voice synthesis independently.
34
34
 
35
35
  Core assistant output accepts only the Markdown or HTML `InputRichMessage` forms and does not construct explicit block arrays or `InputRichBlockThinking`. Telegram's Thinking block is draft-only and must never become a projection of hidden reasoning or chain-of-thought. Any future use for Activity would require an explicitly public user-visible summary rather than provider reasoning content.
36
36
 
@@ -93,7 +93,7 @@ interface TelegramConfig {
93
93
  proactivePush?: boolean;
94
94
  };
95
95
  voice?: {
96
- replyMode?: "manual" | "mirror" | "always";
96
+ replyMode?: "hidden" | "mirror" | "always";
97
97
  sendTranscript?: boolean;
98
98
  };
99
99
  time?: {
@@ -106,7 +106,7 @@ interface TelegramConfig {
106
106
  Hidden/default semantics are represented by absence:
107
107
 
108
108
  - `assistant.proactivePush` defaults to `true`; omit it to keep projection enabled, or set it explicitly to `false` to disable it. When enabled, each completed public assistant text block from local or autonomous work is projected to the authorized Telegram target once and in source order. This includes visible intermediate commentary/checkpoints and the final block. It excludes token deltas, hidden reasoning, tool calls/arguments/results, Telegram-owned turns, empty blocks, and stale authority. Projection uses the configured Rich or HTML assistant renderer and binds admitted work to the exact target, profile/token transport generation, direct leader epoch or follower registration generation, and session generation. The old top-level `proactivePush` key is ignored; move the setting manually under `assistant`.
109
- - Voice Reply `hidden`: no `voice.replyMode` key is persisted.
109
+ - Voice Reply `hidden`: no `voice.replyMode` key is persisted; legacy `manual` resolves to this silent default. `mirror` adds `[voice] delivery: automatic voice` only to voice/audio-input turns, while `always` adds the same effective line to every Telegram turn.
110
110
  - Agent activity status is not configurable in this release. Telegram uses native `sendChatAction(typing)` / product `...active` status as the only automatic in-chat work signal before the final reply.
111
111
  - Time Injection `hidden`: no `time.injectionMode` key is persisted; if `time` becomes empty, the whole `time` object may be omitted.
112
112
 
package/docs/ui-style.md CHANGED
@@ -171,8 +171,8 @@ Rules:
171
171
 
172
172
  Examples:
173
173
 
174
+ - `hidden`
174
175
  - `🟢 mirror`
175
- - `manual`
176
176
  - `always`
177
177
 
178
178
  ## Navigation
package/docs/voice.md CHANGED
@@ -33,14 +33,13 @@ Voice provider extensions can also register STT backends with `registerTelegramV
33
33
 
34
34
  ## Voice Reply Policy
35
35
 
36
- The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). If config does not set a valid mode, the bridge uses the `hidden` default: manual behavior without adding voice policy text to the prompt context.
36
+ The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing, invalid, `hidden`, and legacy `manual` values resolve to the silent `hidden` default.
37
37
 
38
38
  ### Modes
39
39
 
40
- - **`hidden` (default):** no `voice.replyMode` is stored; same behavior as manual, no prompt voice context.
41
- - **`manual`:** only reply with voice when the agent authors `<!-- telegram_voice -->` markup; explicit prompt context is added.
42
- - **`mirror`:** reply with voice when the inbound message was a voice note or audio file. Text input stays on the manual path where explicit `telegram_voice` markup still works.
43
- - **`always`:** always reply with voice.
40
+ - **`hidden` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
41
+ - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `hidden` behavior.
42
+ - **`always`:** every Telegram turn activates automatic voice delivery.
44
43
 
45
44
  **Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
46
45
 
@@ -97,7 +96,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
97
96
 
98
97
  ## Outbound Voice Handlers
99
98
 
100
- Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`manual`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
99
+ Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`hidden`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
101
100
 
102
101
  Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
103
102
 
@@ -167,7 +166,7 @@ Voice provider extensions can register a Voice Extension Section (settings UI) v
167
166
 
168
167
  ## Prompt Guidance
169
168
 
170
- The bridge keeps voice prompt context compact and policy-owned. It adds `[voice] reply mode: ...` only when `telegram.json` explicitly contains a valid `voice.replyMode`. `hidden`/no configured mode behaves like manual, but prompts stay silent. When explicit, voice-originated `manual` turns add `[voice] reply mode: manual`, voice-originated `mirror` turns add `[voice] reply mode: mirror`, and `always` mode adds `[voice] reply mode: always` for every turn. If voice context later contains multiple fields, the bridge renders it as a `[voice]` list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
169
+ The bridge keeps voice prompt context compact, effective, and policy-owned. `hidden` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
171
170
 
172
171
  Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
173
172
 
@@ -204,12 +203,12 @@ Voice synthesis providers can supply prompt guidance through `getVoicePromptCont
204
203
  ```json
205
204
  {
206
205
  "voice": {
207
- "replyMode": "manual"
206
+ "replyMode": "mirror"
208
207
  }
209
208
  }
210
209
  ```
211
210
 
212
- Valid stored values: `"manual"`, `"mirror"`, `"always"`. Missing or invalid values are shown in Settings as `hidden`, behave like manual, and stay silent in prompt context.
211
+ Valid modes are `"hidden"`, `"mirror"`, and `"always"`; selecting `hidden` removes the key. Missing, invalid, and legacy `"manual"` values resolve to `hidden` and stay silent in prompt context.
213
212
 
214
213
  The bridge reads `voice.replyMode` from the config when building a turn.
215
214
 
package/index.ts CHANGED
@@ -155,11 +155,6 @@ export default function (pi: Pi.ExtensionAPI) {
155
155
  contextStore: telegramSessionContextStore,
156
156
  });
157
157
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
158
- const proactivePushChatIdGetter =
159
- Config.createTelegramProactivePushChatIdGetter({
160
- getActiveTurnChatId: activeTurnRuntime.getChatId,
161
- getAllowedUserId: configStore.getAllowedUserId,
162
- });
163
158
  const proactivePushTargetGetter =
164
159
  Config.createTelegramProactivePushTargetGetter({
165
160
  getActiveTurnTarget: activeTurnRuntime.getTarget,
@@ -171,6 +166,8 @@ export default function (pi: Pi.ExtensionAPI) {
171
166
  },
172
167
  getAllowedUserId: configStore.getAllowedUserId,
173
168
  });
169
+ const proactivePushChatIdGetter =
170
+ Config.createTelegramProactivePushChatIdGetter(proactivePushTargetGetter);
174
171
  const buttonActionStore = Outbound.createTelegramButtonActionStore();
175
172
  const pendingModelSwitchStore =
176
173
  Model.createPendingModelSwitchStore<
@@ -225,6 +222,11 @@ export default function (pi: Pi.ExtensionAPI) {
225
222
  persist: configStore.persist,
226
223
  markConfigChange: telegramSyncStateRuntime.markConfigChange,
227
224
  });
225
+ const persistTelegramPollingOffset =
226
+ Config.createTelegramPollingOffsetPersister(
227
+ configStore,
228
+ persistTelegramConfigWithSync,
229
+ );
228
230
  const currentInstanceThreadRuntime =
229
231
  Threads.createTelegramCurrentInstanceThreadRuntime({
230
232
  instanceId: telegramInstanceId,
@@ -825,7 +827,7 @@ export default function (pi: Pi.ExtensionAPI) {
825
827
  hasBotToken: configStore.hasBotToken,
826
828
  deleteWebhook,
827
829
  getUpdates,
828
- persistConfig: persistTelegramConfigWithSync,
830
+ persistConfig: persistTelegramPollingOffset,
829
831
  prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
830
832
  handleUpdate: Updates.createTelegramUpdateHandle({
831
833
  defaultHandle: inboundRouteRuntime.handleUpdate,
@@ -1067,7 +1069,6 @@ export default function (pi: Pi.ExtensionAPI) {
1067
1069
  deleteMessage: deleteTelegramMessage,
1068
1070
  sendGuestReply,
1069
1071
  finalizeMarkdownPreview,
1070
- proactivePushChatIdGetter,
1071
1072
  proactivePushTargetGetter,
1072
1073
  isProactivePushEnabled: configControls.isProactivePushEnabled,
1073
1074
  getAssistantRenderingMode: configControls.getAssistantRenderingMode,
package/lib/bindings.ts CHANGED
@@ -36,9 +36,7 @@ type TelegramRuntimeEventRecorder = (
36
36
  type TelegramBridgeStatusUpdater =
37
37
  Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
38
38
 
39
- export interface TelegramAssistantOutputBindingRuntime<
40
- TTransportStamp,
41
- > {
39
+ export interface TelegramAssistantOutputBindingRuntime<TTransportStamp> {
42
40
  runtime: Activity.TelegramAssistantOutputRuntime;
43
41
  observeEvent: (event: Activity.TelegramActivityEvent) => void;
44
42
  authority: Routing.TelegramAssistantOutputAuthorityRuntime<TTransportStamp>;
@@ -49,7 +47,9 @@ export function createTelegramAssistantOutputBindingRuntime<
49
47
  >(deps: {
50
48
  isEnabled: () => boolean;
51
49
  authority: {
52
- getPreferredTarget: () => OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"] | undefined;
50
+ getPreferredTarget: () =>
51
+ | OutboundAttachments.TelegramQueuedOutboundAttachmentTurnView["target"]
52
+ | undefined;
53
53
  getFallbackChatId: () => number | undefined;
54
54
  getTransportStamp: () => TTransportStamp;
55
55
  isTransportStampActive: (stamp: TTransportStamp) => boolean;
@@ -63,8 +63,9 @@ export function createTelegramAssistantOutputBindingRuntime<
63
63
  >[0];
64
64
  recordRuntimeEvent: TelegramRuntimeEventRecorder;
65
65
  }): TelegramAssistantOutputBindingRuntime<TTransportStamp> {
66
- const authority =
67
- Routing.createTelegramAssistantOutputAuthorityRuntime(deps.authority);
66
+ const authority = Routing.createTelegramAssistantOutputAuthorityRuntime(
67
+ deps.authority,
68
+ );
68
69
  const send =
69
70
  OutboundHandlers.createTelegramAssistantOutputSender<TTransportStamp>(
70
71
  deps.sender,
@@ -344,7 +345,6 @@ interface TelegramLifecycleBindingDeps {
344
345
  Pi.AgentEndEvent["messages"][number],
345
346
  Keyboard.TelegramInlineKeyboardMarkup
346
347
  >["finalizeMarkdownPreview"];
347
- proactivePushChatIdGetter: () => number | undefined;
348
348
  proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
349
349
  isProactivePushEnabled: () => boolean;
350
350
  getAssistantRenderingMode: () => "rich" | "html";
@@ -387,7 +387,6 @@ export function registerTelegramLifecycleRuntimeHooks({
387
387
  deleteMessage,
388
388
  sendGuestReply,
389
389
  finalizeMarkdownPreview,
390
- proactivePushChatIdGetter,
391
390
  proactivePushTargetGetter,
392
391
  isProactivePushEnabled,
393
392
  getAssistantRenderingMode,
@@ -425,7 +424,7 @@ export function registerTelegramLifecycleRuntimeHooks({
425
424
  caption?: string,
426
425
  ): Promise<void> => {
427
426
  const stagingTarget = proactivePushTargetGetter();
428
- const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
427
+ const stagingChatId = stagingTarget?.chatId;
429
428
  if (stagingChatId === undefined) {
430
429
  throw new Error(
431
430
  "Guest attachment staging requires a paired Telegram chat",
@@ -470,7 +469,7 @@ export function registerTelegramLifecycleRuntimeHooks({
470
469
  caption?: string,
471
470
  ): Promise<void> => {
472
471
  const stagingTarget = proactivePushTargetGetter();
473
- const stagingChatId = stagingTarget?.chatId ?? proactivePushChatIdGetter();
472
+ const stagingChatId = stagingTarget?.chatId;
474
473
  if (stagingChatId === undefined) {
475
474
  throw new Error("Guest voice staging requires a paired Telegram chat");
476
475
  }
@@ -595,11 +594,9 @@ export function registerTelegramLifecycleRuntimeHooks({
595
594
  if (!canSendAgentActivity(ctx)) return false;
596
595
  const turn = activeTurnRuntime.get();
597
596
  const target = turn?.target ?? proactivePushTargetGetter();
598
- promptDispatchRuntime.startTypingLoop(
599
- ctx,
600
- turn?.chatId ?? target?.chatId ?? proactivePushChatIdGetter(),
601
- { target },
602
- );
597
+ promptDispatchRuntime.startTypingLoop(ctx, turn?.chatId ?? target?.chatId, {
598
+ target,
599
+ });
603
600
  return true;
604
601
  };
605
602
  const startActiveTurnTypingLoop = (ctx: Pi.ExtensionContext): void => {
@@ -644,6 +641,7 @@ export function registerTelegramLifecycleRuntimeHooks({
644
641
  },
645
642
  async onSessionShutdown(event, ctx) {
646
643
  if (!isSessionContextActive(ctx)) return;
644
+ agentLifecycleHooks.clearRetainedAgentEnd();
647
645
  activityRuntime.onSessionShutdown();
648
646
  assistantOutputRuntime.stop();
649
647
  compactionObserver.onSessionShutdown();
@@ -710,8 +708,9 @@ export function registerTelegramLifecycleRuntimeHooks({
710
708
  activityRuntime.onAgentEnd();
711
709
  await agentLifecycleHooks.onAgentEnd(event, ctx);
712
710
  },
713
- onAgentSettled(_event, ctx) {
711
+ async onAgentSettled(event, ctx) {
714
712
  if (!isSessionContextActive(ctx)) return;
713
+ await agentLifecycleHooks.onAgentSettled(event, ctx);
715
714
  activityRuntime.onAgentSettled();
716
715
  },
717
716
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
package/lib/bus-leader.ts CHANGED
@@ -109,17 +109,10 @@ export interface TelegramBusFollowerTargetProvisionerDeps {
109
109
  ) => void;
110
110
  }
111
111
 
112
- export interface TelegramBusFollowerPruneHandlerDeps {
112
+ export interface TelegramBusFollowerDisconnectHandlerDeps {
113
113
  topicTargetStore: Pick<
114
114
  Threads.TelegramTopicTargetStore,
115
- | "load"
116
- | "getActiveByInstanceId"
117
- | "list"
118
- | "listPendingProvisions"
119
- | "markStaleByTarget"
120
- | "markOfflineByInstanceId"
121
- | "persist"
122
- | "removePendingProvision"
115
+ "markOfflineByInstanceId" | "persist"
123
116
  >;
124
117
  callApi: <TResponse>(
125
118
  method: string,
@@ -161,11 +154,9 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
161
154
  runtime: Omit<
162
155
  TelegramBusLeaderRuntimeDeps<TContext>,
163
156
  | "callApi"
164
- | "onFollowerPruned"
165
157
  | "onFollowerDisconnected"
166
158
  | "provisionFollowerTarget"
167
159
  | "provisionLeaderTarget"
168
- | "reconcileFollowerBindings"
169
160
  | "recordRuntimeEvent"
170
161
  >;
171
162
  getAllowedUserId: () => number | undefined;
@@ -218,9 +209,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
218
209
  recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
219
210
  setLeaderTarget: deps.setLeaderTarget,
220
211
  }),
221
- onFollowerPruned: createTelegramBusFollowerPruneHandler({
222
- ...provisionerPorts,
223
- }),
224
212
  onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
225
213
  ...provisionerPorts,
226
214
  }),
@@ -228,12 +216,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
228
216
  ...provisionerPorts,
229
217
  }),
230
218
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
231
- reconcileFollowerBindings:
232
- createTelegramBusFollowerBindingRealityReconciler({
233
- topicTargetStore: deps.topicTargetStore,
234
- followerRegistry: deps.runtime.followerRegistry,
235
- recordRuntimeEvent: deps.recordRuntimeEvent,
236
- }),
237
219
  callApi: createTelegramBusLeaderApiProxy({
238
220
  call: deps.callApi,
239
221
  callMultipart: deps.callMultipart,
@@ -244,29 +226,6 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
244
226
  });
245
227
  }
246
228
 
247
- export const TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS = 10_000;
248
-
249
- export interface TelegramBusFollowerBindingRealityDeps {
250
- topicTargetStore: Pick<
251
- Threads.TelegramTopicTargetStore,
252
- | "load"
253
- | "list"
254
- | "markOfflineByInstanceId"
255
- | "forgetIdentityByProfileKey"
256
- | "getBotState"
257
- | "persist"
258
- | "setBotState"
259
- >;
260
- followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
261
- getNowMs?: () => number;
262
- recentBindingGraceMs?: number;
263
- recordRuntimeEvent: (
264
- category: string,
265
- error: unknown,
266
- details?: Record<string, unknown>,
267
- ) => void;
268
- }
269
-
270
229
  export interface TelegramBusFollowerMessageOwnershipRecord {
271
230
  follower: TelegramBusFollowerView;
272
231
  chatId: number;
@@ -296,15 +255,10 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
296
255
  registration: TelegramBusInstanceRegistration,
297
256
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
298
257
  getCurrentLeaderEpoch?: () => number | string | undefined;
299
- reconcileFollowerBindings?: () => Promise<unknown> | unknown;
300
258
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
301
259
  getNowMs?: () => number;
302
260
  followerPruneIntervalMs?: number;
303
261
  followerStaleAfterMs?: number;
304
- followerRecoveryGraceMs?: number;
305
- onFollowerPruned?: (
306
- follower: TelegramBusFollowerView,
307
- ) => Promise<void> | void;
308
262
  onFollowerDisconnected?: (
309
263
  follower: TelegramBusFollowerView,
310
264
  ) => Promise<void> | void;
@@ -315,23 +269,11 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
315
269
  ) => void;
316
270
  }
317
271
 
318
- export function createTelegramBusFollowerBindingRealityReconciler(
319
- deps: TelegramBusFollowerBindingRealityDeps,
320
- ): () => Promise<number> {
321
- return async () => {
322
- await deps.topicTargetStore.load();
323
- // A missing live registration proves only that the follower process is
324
- // currently absent. Keep its durable owner/target binding so a replacement
325
- // process with the same manual profile can reclaim the existing thread.
326
- return 0;
327
- };
328
- }
329
-
330
272
  export function createTelegramBusInstanceLifecycleAnnouncement(input: {
331
273
  target: TelegramTarget & { threadId: number };
332
274
  threadName?: string;
333
275
  slot?: string;
334
- state: "connected" | "disconnected";
276
+ state: "connected";
335
277
  }): TelegramBusFollowerLifecycleAnnouncement {
336
278
  return {
337
279
  target: { ...input.target },
@@ -340,23 +282,6 @@ export function createTelegramBusInstanceLifecycleAnnouncement(input: {
340
282
  };
341
283
  }
342
284
 
343
- export function createTelegramBusFollowerDisconnectedAnnouncement(input: {
344
- follower: TelegramBusFollowerView;
345
- threadName?: string;
346
- slot?: string;
347
- }): TelegramBusFollowerLifecycleAnnouncement | undefined {
348
- if (!input.follower.target?.threadId) return undefined;
349
- return createTelegramBusInstanceLifecycleAnnouncement({
350
- target: {
351
- chatId: input.follower.target.chatId,
352
- threadId: input.follower.target.threadId,
353
- },
354
- threadName: input.threadName,
355
- slot: input.slot,
356
- state: "disconnected",
357
- });
358
- }
359
-
360
285
  const TELEGRAM_BUS_SLOW_FOLLOWER_REGISTRATION_MS = 1000;
361
286
 
362
287
  function scheduleTelegramBusLeaderBackgroundTask(
@@ -753,23 +678,8 @@ export function createTelegramBusFollowerTargetProvisioner(
753
678
  };
754
679
  }
755
680
 
756
- export function createTelegramBusFollowerPruneHandler(
757
- deps: TelegramBusFollowerPruneHandlerDeps,
758
- ): (follower: TelegramBusFollowerView) => Promise<void> {
759
- return async (follower) => {
760
- deps.recordRuntimeEvent(
761
- "bus",
762
- "Telegram bus follower heartbeat stale; preserving thread binding",
763
- {
764
- phase: "follower-pruned",
765
- instanceId: follower.instanceId,
766
- },
767
- );
768
- };
769
- }
770
-
771
681
  export function createTelegramBusFollowerDisconnectHandler(
772
- deps: TelegramBusFollowerPruneHandlerDeps,
682
+ deps: TelegramBusFollowerDisconnectHandlerDeps,
773
683
  ): (follower: TelegramBusFollowerView) => Promise<void> {
774
684
  return async (follower) => {
775
685
  const target = follower.target;
@@ -1458,32 +1368,12 @@ export function createTelegramBusLeaderRuntime<TContext>(
1458
1368
  const getNowMs = deps.getNowMs ?? Date.now;
1459
1369
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
1460
1370
  const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
1461
- const followerRecoveryGraceMs = deps.followerRecoveryGraceMs ?? 5000;
1462
1371
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1463
- let followerRealityTimer: ReturnType<typeof setTimeout> | undefined;
1464
1372
  const stopPruning = () => {
1465
1373
  if (!pruneInterval) return;
1466
1374
  clearInterval(pruneInterval);
1467
1375
  pruneInterval = undefined;
1468
1376
  };
1469
- const stopFollowerRealityTimer = () => {
1470
- if (!followerRealityTimer) return;
1471
- clearTimeout(followerRealityTimer);
1472
- followerRealityTimer = undefined;
1473
- };
1474
- const scheduleFollowerBindingReality = () => {
1475
- stopFollowerRealityTimer();
1476
- if (!deps.reconcileFollowerBindings) return;
1477
- followerRealityTimer = setTimeout(() => {
1478
- followerRealityTimer = undefined;
1479
- void Promise.resolve(deps.reconcileFollowerBindings?.()).catch((error) =>
1480
- deps.recordRuntimeEvent?.("bus", error, {
1481
- phase: "follower-binding-reality",
1482
- }),
1483
- );
1484
- }, followerRecoveryGraceMs);
1485
- followerRealityTimer.unref?.();
1486
- };
1487
1377
  const pruneFollowers = async () => {
1488
1378
  try {
1489
1379
  await localServer.ensureEndpoint();
@@ -1497,18 +1387,14 @@ export function createTelegramBusLeaderRuntime<TContext>(
1497
1387
  followerStaleAfterMs,
1498
1388
  );
1499
1389
  for (const follower of removed) {
1500
- try {
1501
- await deps.onFollowerPruned?.(follower);
1502
- } catch (error) {
1503
- deps.recordRuntimeEvent?.("bus", error, {
1504
- phase: "follower-prune-offline",
1390
+ deps.recordRuntimeEvent?.(
1391
+ "bus",
1392
+ "Telegram bus follower heartbeat stale; preserving thread binding",
1393
+ {
1394
+ phase: "follower-pruned",
1505
1395
  instanceId: follower.instanceId,
1506
- });
1507
- }
1508
- deps.recordRuntimeEvent?.("bus", "Telegram bus follower timed out", {
1509
- phase: "follower-prune",
1510
- instanceId: follower.instanceId,
1511
- });
1396
+ },
1397
+ );
1512
1398
  }
1513
1399
  };
1514
1400
  const startPruning = () => {
@@ -1542,21 +1428,18 @@ export function createTelegramBusLeaderRuntime<TContext>(
1542
1428
  return {
1543
1429
  startPolling: async (ctx) => {
1544
1430
  await localServer.start();
1545
- scheduleFollowerBindingReality();
1546
1431
  startPruning();
1547
1432
  try {
1548
1433
  await deps.provisionLeaderTarget?.(ctx);
1549
1434
  await deps.startPolling(ctx);
1550
1435
  } catch (error) {
1551
1436
  stopPruning();
1552
- stopFollowerRealityTimer();
1553
1437
  await localServer.stop();
1554
1438
  throw error;
1555
1439
  }
1556
1440
  },
1557
1441
  stopPolling: async () => {
1558
1442
  stopPruning();
1559
- stopFollowerRealityTimer();
1560
1443
  try {
1561
1444
  await deps.stopPolling();
1562
1445
  } finally {
package/lib/config.ts CHANGED
@@ -17,8 +17,8 @@ import {
17
17
  import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
18
18
  import { resolveAgentDir, resolveTelegramConfigPath } from "./paths.ts";
19
19
 
20
- import type { TelegramInboundHandlerConfig } from "./inbound.ts";
21
20
  import type { CommandTemplateObjectConfig } from "./command-templates.ts";
21
+ import type { TelegramInboundHandlerConfig } from "./inbound.ts";
22
22
  import { withTelegramFileTransaction } from "./locks.ts";
23
23
 
24
24
  const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
@@ -72,7 +72,7 @@ export interface TelegramConfig {
72
72
  /** @deprecated use assistant.rendering */
73
73
  assistantRendering?: TelegramAssistantRenderingMode;
74
74
  voice?: {
75
- replyMode?: "manual" | "mirror" | "always";
75
+ replyMode?: "hidden" | "mirror" | "always";
76
76
  /** Whether to attach the provider's transcriptText as caption on voice messages */
77
77
  sendTranscript?: boolean;
78
78
  };
@@ -543,6 +543,27 @@ export function createTelegramConfigStore(
543
543
  };
544
544
  }
545
545
 
546
+ export function createTelegramPollingOffsetPersister(
547
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
548
+ persist: () => Promise<void> = () => configStore.persist(),
549
+ ): (pollingConfig: { lastUpdateId?: number }) => Promise<void> {
550
+ return async (pollingConfig) => {
551
+ const nextOffset = pollingConfig.lastUpdateId;
552
+ if (typeof nextOffset === "number") {
553
+ const current = configStore.get();
554
+ const currentOffset = current.lastUpdateId;
555
+ configStore.set({
556
+ ...current,
557
+ lastUpdateId:
558
+ typeof currentOffset === "number"
559
+ ? Math.max(currentOffset, nextOffset)
560
+ : nextOffset,
561
+ });
562
+ }
563
+ await persist();
564
+ };
565
+ }
566
+
546
567
  export function createTelegramProactivePushChecker(
547
568
  configStore: Pick<TelegramConfigStore, "get">,
548
569
  ): () => boolean {
@@ -625,12 +646,10 @@ export function createTelegramAssistantRenderingModeSetter(
625
646
 
626
647
  export function createTelegramVoiceReplyModeGetter(
627
648
  configStore: Pick<TelegramConfigStore, "get">,
628
- ): () => "manual" | "mirror" | "always" {
649
+ ): () => "hidden" | "mirror" | "always" {
629
650
  return () => {
630
651
  const mode = configStore.get().voice?.replyMode;
631
- return mode === "mirror" || mode === "always" || mode === "manual"
632
- ? mode
633
- : "manual";
652
+ return mode === "mirror" || mode === "always" ? mode : "hidden";
634
653
  };
635
654
  }
636
655
 
@@ -639,17 +658,17 @@ export function createTelegramVoiceReplyModeConfiguredChecker(
639
658
  ): () => boolean {
640
659
  return () => {
641
660
  const mode = configStore.get().voice?.replyMode;
642
- return mode === "mirror" || mode === "always" || mode === "manual";
661
+ return mode === "mirror" || mode === "always";
643
662
  };
644
663
  }
645
664
 
646
665
  export function createTelegramVoiceReplyModeSetter(
647
666
  configStore: TelegramMutableConfigStore,
648
- ): (replyMode: "manual" | "mirror" | "always" | undefined) => Promise<void> {
667
+ ): (replyMode: "hidden" | "mirror" | "always" | undefined) => Promise<void> {
649
668
  return async (replyMode) => {
650
669
  await loadLatestTelegramConfig(configStore);
651
670
  const current = configStore.get();
652
- if (replyMode === undefined) {
671
+ if (replyMode === undefined || replyMode === "hidden") {
653
672
  const { replyMode: _replyMode, ...remainingVoice } = current.voice ?? {};
654
673
  const next = { ...current };
655
674
  if (Object.keys(remainingVoice).length > 0) next.voice = remainingVoice;
@@ -730,11 +749,10 @@ export interface TelegramProactivePushTarget {
730
749
  threadId?: number;
731
750
  }
732
751
 
733
- export function createTelegramProactivePushChatIdGetter(deps: {
734
- getActiveTurnChatId: () => number | undefined;
735
- getAllowedUserId: () => number | undefined;
736
- }): () => number | undefined {
737
- return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
752
+ export function createTelegramProactivePushChatIdGetter(
753
+ getTarget: () => TelegramProactivePushTarget | undefined,
754
+ ): () => number | undefined {
755
+ return () => getTarget()?.chatId;
738
756
  }
739
757
 
740
758
  export function createTelegramProactivePushTargetGetter(deps: {