@llblab/pi-telegram 0.11.2 → 0.12.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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Telegram bridge binding composition
3
+ * Zones: telegram, pi agent, orchestration
4
+ * Owns pi-facing tool, command, and lifecycle hook registration for the entrypoint
5
+ */
6
+
7
+ import * as Api from "./api.ts";
8
+ import * as CommandTemplates from "./command-templates.ts";
9
+ import * as Commands from "./commands.ts";
10
+ import * as Config from "./config.ts";
11
+ import * as Keyboard from "./keyboard.ts";
12
+ import * as Lifecycle from "./lifecycle.ts";
13
+ import * as Locks from "./locks.ts";
14
+ import * as Model from "./model.ts";
15
+ import * as OutboundAttachments from "./outbound-attachments.ts";
16
+ import * as OutboundHandlers from "./outbound.ts";
17
+ import * as Pi from "./pi.ts";
18
+ import * as Preview from "./preview.ts";
19
+ import * as Prompts from "./prompts.ts";
20
+ import * as Queue from "./queue.ts";
21
+ import * as Replies from "./replies.ts";
22
+ import * as Runtime from "./runtime.ts";
23
+ import * as Setup from "./setup.ts";
24
+ import * as Status from "./status.ts";
25
+
26
+ type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
27
+
28
+ type TelegramRuntimeEventRecorder = (
29
+ category: string,
30
+ error: unknown,
31
+ details?: Record<string, unknown>,
32
+ ) => void;
33
+
34
+ type TelegramBridgeStatusUpdater =
35
+ Status.TelegramStatusRuntime<Pi.ExtensionContext>["updateStatus"];
36
+
37
+ interface TelegramCommandsAndToolsBindingDeps {
38
+ pi: Pi.ExtensionAPI;
39
+ configStore: Config.TelegramConfigStore;
40
+ setup: Setup.TelegramSetupGuard;
41
+ activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
42
+ lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
43
+ getStatusLines: () => string[];
44
+ updateStatus: TelegramBridgeStatusUpdater;
45
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
46
+ }
47
+
48
+ export function registerTelegramCommandsAndTools({
49
+ pi,
50
+ configStore,
51
+ setup,
52
+ activeTurnRuntime,
53
+ lockedPollingRuntime,
54
+ getStatusLines,
55
+ updateStatus,
56
+ recordRuntimeEvent,
57
+ }: TelegramCommandsAndToolsBindingDeps): void {
58
+ OutboundAttachments.registerTelegramOutboundAttachmentTool(pi, {
59
+ getActiveTurn: activeTurnRuntime.get,
60
+ recordRuntimeEvent,
61
+ });
62
+
63
+ Commands.registerTelegramBridgeCommands(pi, {
64
+ promptForConfig: Setup.createTelegramSetupPromptRuntime({
65
+ getConfig: configStore.get,
66
+ setConfig: configStore.set,
67
+ setupGuard: setup,
68
+ getMe: Api.fetchTelegramBotIdentity,
69
+ persistConfig: configStore.persist,
70
+ startPolling: lockedPollingRuntime.start,
71
+ updateStatus,
72
+ recordRuntimeEvent,
73
+ }),
74
+ getStatusLines,
75
+ reloadConfig: configStore.load,
76
+ hasBotToken: configStore.hasBotToken,
77
+ startPolling: lockedPollingRuntime.start,
78
+ stopPolling: lockedPollingRuntime.stop,
79
+ updateStatus,
80
+ });
81
+ }
82
+
83
+ interface TelegramLifecycleBindingDeps {
84
+ pi: Pi.ExtensionAPI;
85
+ sessionLifecycleRuntime: Pick<
86
+ Lifecycle.TelegramLifecycleRegistrationDeps,
87
+ "onSessionStart" | "onSessionShutdown" | "onModelSelect"
88
+ >;
89
+ queueSessionLifecycle: Pick<
90
+ Lifecycle.TelegramLifecycleRegistrationDeps,
91
+ "onSessionShutdown"
92
+ >;
93
+ configStore: Pick<Config.TelegramConfigStore, "getOutboundHandlers">;
94
+ abort: Runtime.TelegramRuntimeAbortPort;
95
+ typing: Runtime.TelegramRuntimeTypingPort;
96
+ lifecycle: Runtime.TelegramRuntimeLifecyclePort;
97
+ activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
98
+ telegramQueueStore: Queue.TelegramQueueStore<Pi.ExtensionContext>;
99
+ modelSwitchController: Model.TelegramModelSwitchController<
100
+ Pi.ExtensionContext,
101
+ Model.ScopedTelegramModel<ActivePiModel>
102
+ >;
103
+ previewRuntime: Preview.TelegramAssistantPreviewRuntime<
104
+ Pi.AgentEndEvent["messages"][number],
105
+ Keyboard.TelegramInlineKeyboardMarkup
106
+ >;
107
+ promptDispatchRuntime: Runtime.TelegramPromptDispatchRuntime<Pi.ExtensionContext>;
108
+ deferredQueueDispatchRuntime: Queue.TelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>;
109
+ lockOwnershipGuard: Pick<
110
+ Locks.TelegramLockOwnershipGuard<Pi.ExtensionContext>,
111
+ "ownsContext"
112
+ >;
113
+ buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
114
+ callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
115
+ sendChatAction: NonNullable<
116
+ OutboundHandlers.TelegramVoiceReplySenderDeps["sendChatAction"]
117
+ >;
118
+ sendRecordVoiceAction: NonNullable<
119
+ OutboundHandlers.TelegramVoiceReplySenderDeps["sendRecordVoiceAction"]
120
+ >;
121
+ sendMarkdownReply: Queue.TelegramAgentEndHookRuntimeDeps<
122
+ Queue.PendingTelegramTurn,
123
+ Pi.ExtensionContext,
124
+ Pi.AgentEndEvent["messages"][number],
125
+ Keyboard.TelegramInlineKeyboardMarkup
126
+ >["sendMarkdownReply"];
127
+ sendTextReply: Queue.TelegramAgentEndHookRuntimeDeps<
128
+ Queue.PendingTelegramTurn,
129
+ Pi.ExtensionContext,
130
+ Pi.AgentEndEvent["messages"][number],
131
+ Keyboard.TelegramInlineKeyboardMarkup
132
+ >["sendTextReply"] &
133
+ NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
134
+ dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
135
+ answerGuestQuery: NonNullable<
136
+ Queue.TelegramAgentEndHookRuntimeDeps<
137
+ Queue.PendingTelegramTurn,
138
+ Pi.ExtensionContext,
139
+ Pi.AgentEndEvent["messages"][number],
140
+ Keyboard.TelegramInlineKeyboardMarkup
141
+ >["answerGuestQuery"]
142
+ >;
143
+ sendGuestReply: NonNullable<
144
+ Queue.TelegramAgentEndHookRuntimeDeps<
145
+ Queue.PendingTelegramTurn,
146
+ Pi.ExtensionContext,
147
+ Pi.AgentEndEvent["messages"][number],
148
+ Keyboard.TelegramInlineKeyboardMarkup
149
+ >["sendGuestReply"]
150
+ >;
151
+ finalizeMarkdownPreview: Queue.TelegramAgentEndHookRuntimeDeps<
152
+ Queue.PendingTelegramTurn,
153
+ Pi.ExtensionContext,
154
+ Pi.AgentEndEvent["messages"][number],
155
+ Keyboard.TelegramInlineKeyboardMarkup
156
+ >["finalizeMarkdownPreview"];
157
+ proactivePushChatIdGetter: () => number | undefined;
158
+ isProactivePushEnabled: () => boolean;
159
+ updateStatus: TelegramBridgeStatusUpdater;
160
+ recordRuntimeEvent: TelegramRuntimeEventRecorder;
161
+ }
162
+
163
+ export function registerTelegramLifecycleRuntimeHooks({
164
+ pi,
165
+ sessionLifecycleRuntime,
166
+ queueSessionLifecycle,
167
+ configStore,
168
+ abort,
169
+ typing,
170
+ lifecycle,
171
+ activeTurnRuntime,
172
+ telegramQueueStore,
173
+ modelSwitchController,
174
+ previewRuntime,
175
+ promptDispatchRuntime,
176
+ deferredQueueDispatchRuntime,
177
+ lockOwnershipGuard,
178
+ buttonActionStore,
179
+ callMultipart,
180
+ sendChatAction,
181
+ sendRecordVoiceAction,
182
+ sendMarkdownReply,
183
+ sendTextReply,
184
+ dispatchNextQueuedTelegramTurn,
185
+ answerGuestQuery,
186
+ sendGuestReply,
187
+ finalizeMarkdownPreview,
188
+ proactivePushChatIdGetter,
189
+ isProactivePushEnabled,
190
+ updateStatus,
191
+ recordRuntimeEvent,
192
+ }: TelegramLifecycleBindingDeps): void {
193
+ const agentEndResetter = Runtime.createTelegramAgentEndResetter({
194
+ abort,
195
+ typing,
196
+ clearActiveTurn: activeTurnRuntime.clear,
197
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
198
+ clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
199
+ clearDispatchPending: lifecycle.clearDispatchPending,
200
+ });
201
+ const queuedAttachmentSender =
202
+ OutboundAttachments.createTelegramQueuedOutboundAttachmentSender({
203
+ sendMultipart: callMultipart,
204
+ sendTextReply,
205
+ recordRuntimeEvent,
206
+ });
207
+ const outboundReplyPlanner =
208
+ OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
209
+ const outboundReplyArtifactSender =
210
+ OutboundHandlers.createTelegramOutboundReplyArtifactSender({
211
+ execCommand: CommandTemplates.execCommandTemplate,
212
+ sendMultipart: callMultipart,
213
+ sendTextReply,
214
+ sendChatAction,
215
+ sendRecordVoiceAction,
216
+ getHandlers: configStore.getOutboundHandlers,
217
+ recordRuntimeEvent,
218
+ });
219
+ const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
220
+ Queue.PendingTelegramTurn,
221
+ Pi.ExtensionContext,
222
+ unknown,
223
+ Keyboard.TelegramInlineKeyboardMarkup
224
+ >({
225
+ setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(abort),
226
+ getQueuedItems: telegramQueueStore.getQueuedItems,
227
+ hasPendingDispatch: lifecycle.hasDispatchPending,
228
+ hasActiveTurn: activeTurnRuntime.has,
229
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
230
+ resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
231
+ setQueuedItems: telegramQueueStore.setQueuedItems,
232
+ clearDispatchPending: lifecycle.clearDispatchPending,
233
+ setActiveTurn: activeTurnRuntime.set,
234
+ createPreviewState: previewRuntime.resetState,
235
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
236
+ updateStatus,
237
+ getActiveTurn: activeTurnRuntime.get,
238
+ extractAssistant: Replies.extractLatestAssistantMessageText,
239
+ getPreserveQueuedTurnsAsHistory:
240
+ lifecycle.shouldPreserveQueuedTurnsAsHistory,
241
+ resetRuntimeState: agentEndResetter,
242
+ dispatchNextQueuedTelegramTurn,
243
+ requestDeferredDispatchNextQueuedTelegramTurn:
244
+ deferredQueueDispatchRuntime.request,
245
+ clearPreview: previewRuntime.clear,
246
+ setPreviewPendingText: previewRuntime.setPendingText,
247
+ finalizeMarkdownPreview,
248
+ sendMarkdownReply,
249
+ sendTextReply,
250
+ sendQueuedAttachments: queuedAttachmentSender,
251
+ answerGuestQuery,
252
+ sendGuestReply,
253
+ planOutboundReply: outboundReplyPlanner,
254
+ sendOutboundReplyArtifacts: outboundReplyArtifactSender,
255
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
256
+ getDefaultChatId: proactivePushChatIdGetter,
257
+ isProactivePushEnabled,
258
+ recordRuntimeEvent,
259
+ getActiveToolExecutions: lifecycle.getActiveToolExecutions,
260
+ setActiveToolExecutions: lifecycle.setActiveToolExecutions,
261
+ triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
262
+ });
263
+
264
+ Lifecycle.setResetTransportReplyDedup(Replies.resetTransportReplyDedup);
265
+ const agentStartWithDedupReset = Lifecycle.createAgentStartDedupHook(
266
+ agentLifecycleHooks.onAgentStart,
267
+ );
268
+ const compactionObserver = Lifecycle.createTelegramCompactionObserverRuntime({
269
+ setCompactionInProgress: lifecycle.setCompactionInProgress,
270
+ updateStatus,
271
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
272
+ stopTypingLoop: typing.stop,
273
+ requestDeferredDispatchNextQueuedTelegramTurn:
274
+ deferredQueueDispatchRuntime.request,
275
+ dispatchNextQueuedTelegramTurn,
276
+ recordRuntimeEvent,
277
+ });
278
+ const messageActivityTypingHooks =
279
+ Lifecycle.createTelegramMessageActivityTypingHooks({
280
+ hasActiveTurn: activeTurnRuntime.has,
281
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
282
+ onMessageStart: previewRuntime.onMessageStart,
283
+ onMessageUpdate: previewRuntime.onMessageUpdate,
284
+ });
285
+ Lifecycle.registerTelegramLifecycleHooks(pi, {
286
+ ...sessionLifecycleRuntime,
287
+ ...agentLifecycleHooks,
288
+ async onSessionShutdown(event, ctx) {
289
+ compactionObserver.onSessionShutdown();
290
+ await queueSessionLifecycle.onSessionShutdown(event, ctx);
291
+ },
292
+ onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
293
+ onSessionCompact: compactionObserver.onSessionCompact,
294
+ onAgentStart: agentStartWithDedupReset,
295
+ onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
296
+ isProactivePushEnabled,
297
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
298
+ }),
299
+ ...messageActivityTypingHooks,
300
+ });
301
+ }
package/lib/commands.ts CHANGED
@@ -307,6 +307,10 @@ export interface TelegramRuntimeEventRecorderPort {
307
307
  ) => void;
308
308
  }
309
309
 
310
+ export interface TelegramCompactConfirmationReplyMarkup {
311
+ inline_keyboard: { text: string; callback_data: string }[][];
312
+ }
313
+
310
314
  export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorderPort {
311
315
  isIdle: () => boolean;
312
316
  hasPendingMessages: () => boolean;
@@ -327,6 +331,42 @@ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorder
327
331
  onError: (error: unknown) => void;
328
332
  }) => void;
329
333
  sendTextReply: (text: string) => Promise<void>;
334
+ suppressStartNotice?: boolean;
335
+ }
336
+
337
+ export interface TelegramCompactConfirmationDeps {
338
+ sendInteractiveMessage: (
339
+ chatId: number,
340
+ text: string,
341
+ mode: "html" | "plain",
342
+ replyMarkup: TelegramCompactConfirmationReplyMarkup,
343
+ ) => Promise<number | undefined>;
344
+ }
345
+
346
+ export interface TelegramCompactConfirmationCallbackQuery {
347
+ id: string;
348
+ data?: string;
349
+ message?: { chat?: { id?: number }; message_id?: number };
350
+ }
351
+
352
+ export interface TelegramCompactConfirmationCallbackDeps<TContext> {
353
+ ctx: TContext;
354
+ answerCallbackQuery: (
355
+ callbackQueryId: string,
356
+ text?: string,
357
+ ) => Promise<void>;
358
+ editInteractiveMessage: (
359
+ chatId: number,
360
+ messageId: number,
361
+ text: string,
362
+ mode: "html" | "plain",
363
+ replyMarkup: TelegramCompactConfirmationReplyMarkup,
364
+ ) => Promise<void>;
365
+ runCompact: (
366
+ ctx: TContext,
367
+ chatId: number,
368
+ replyToMessageId: number,
369
+ ) => Promise<void>;
330
370
  }
331
371
 
332
372
  export type TelegramControlCommandType =
@@ -580,6 +620,7 @@ export interface TelegramCommandRuntimeDeps<
580
620
  getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
581
621
  persistConfig: () => Promise<void>;
582
622
  sendTextReply: (message: TMessage, text: string) => Promise<void>;
623
+ sendInteractiveMessage?: TelegramCompactConfirmationDeps["sendInteractiveMessage"];
583
624
  }
584
625
 
585
626
  export const TELEGRAM_APP_MENU_INTRO_HTML = [
@@ -784,6 +825,69 @@ function dispatchNextQueuedTelegramTurnAfterCompact(
784
825
  deps.dispatchNextQueuedTelegramTurn();
785
826
  }
786
827
 
828
+ export function buildTelegramCompactConfirmationReplyMarkup(): TelegramCompactConfirmationReplyMarkup {
829
+ return {
830
+ inline_keyboard: [
831
+ [
832
+ { text: "🗜 Yes, compact", callback_data: "compact:confirm" },
833
+ { text: "❌ No", callback_data: "compact:cancel" },
834
+ ],
835
+ ],
836
+ };
837
+ }
838
+
839
+ export function getTelegramCompactConfirmationHtml(): string {
840
+ return "<b>Compact session?</b>";
841
+ }
842
+
843
+ export async function openTelegramCompactConfirmation(
844
+ chatId: number,
845
+ deps: TelegramCompactConfirmationDeps,
846
+ ): Promise<void> {
847
+ await deps.sendInteractiveMessage(
848
+ chatId,
849
+ getTelegramCompactConfirmationHtml(),
850
+ "html",
851
+ buildTelegramCompactConfirmationReplyMarkup(),
852
+ );
853
+ }
854
+
855
+ export async function handleTelegramCompactConfirmationCallback<TContext>(
856
+ query: TelegramCompactConfirmationCallbackQuery,
857
+ deps: TelegramCompactConfirmationCallbackDeps<TContext>,
858
+ ): Promise<boolean> {
859
+ if (query.data !== "compact:confirm" && query.data !== "compact:cancel") {
860
+ return false;
861
+ }
862
+ const chatId = query.message?.chat?.id;
863
+ const messageId = query.message?.message_id;
864
+ if (typeof chatId !== "number" || typeof messageId !== "number") {
865
+ await deps.answerCallbackQuery(query.id, "Interactive message expired.");
866
+ return true;
867
+ }
868
+ if (query.data === "compact:cancel") {
869
+ await deps.editInteractiveMessage(
870
+ chatId,
871
+ messageId,
872
+ "Compaction cancelled.",
873
+ "plain",
874
+ { inline_keyboard: [] },
875
+ );
876
+ await deps.answerCallbackQuery(query.id);
877
+ return true;
878
+ }
879
+ await deps.editInteractiveMessage(
880
+ chatId,
881
+ messageId,
882
+ "Compaction started.",
883
+ "plain",
884
+ { inline_keyboard: [] },
885
+ );
886
+ await deps.answerCallbackQuery(query.id);
887
+ await deps.runCompact(deps.ctx, chatId, messageId);
888
+ return true;
889
+ }
890
+
787
891
  export async function handleTelegramCompactCommand(
788
892
  deps: TelegramCompactCommandDeps,
789
893
  ): Promise<void> {
@@ -834,7 +938,9 @@ export async function handleTelegramCompactCommand(
834
938
  await deps.sendTextReply(`Compaction failed: ${errorMessage}`);
835
939
  return;
836
940
  }
837
- await deps.sendTextReply("Compaction started.");
941
+ if (!deps.suppressStartNotice) {
942
+ await deps.sendTextReply("Compaction started.");
943
+ }
838
944
  if (compactionStillInProgress) deps.startTypingLoop?.();
839
945
  }
840
946
 
@@ -978,6 +1084,7 @@ export function createTelegramCommandHandlerTargetRuntime<
978
1084
  stopTypingLoop: deps.stopTypingLoop,
979
1085
  enqueueContinueTurn: deps.enqueueContinueTurn,
980
1086
  compact: deps.compact,
1087
+ sendInteractiveMessage: deps.sendInteractiveMessage,
981
1088
  enqueueControlItem: commandTargetRuntime.enqueueControlItem,
982
1089
  showStatus: commandTargetRuntime.showStatus,
983
1090
  openModelMenu: commandTargetRuntime.openModelMenu,
@@ -1110,6 +1217,12 @@ async function handleTelegramCommandRuntime<
1110
1217
  await deps.openQueueMenu(nextMessage, commandCtx);
1111
1218
  },
1112
1219
  handleCompact: async (nextMessage, commandCtx) => {
1220
+ if (deps.sendInteractiveMessage) {
1221
+ await openTelegramCompactConfirmation(nextMessage.chat.id, {
1222
+ sendInteractiveMessage: deps.sendInteractiveMessage,
1223
+ });
1224
+ return;
1225
+ }
1113
1226
  await handleTelegramCompactCommand({
1114
1227
  isIdle: () => deps.isIdle(commandCtx),
1115
1228
  hasPendingMessages: () => deps.hasPendingMessages(commandCtx),
package/lib/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram bridge config and pairing helpers
3
3
  * Zones: telegram config, pairing, filesystem
4
- * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects
4
+ * Owns persisted bot/session pairing state, local config storage, live config controls, authorization policy, and first-user pairing side effects
5
5
  */
6
6
 
7
7
  import { existsSync } from "node:fs";
@@ -9,7 +9,7 @@ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
10
  import { join, resolve } from "node:path";
11
11
 
12
- import type { TelegramInboundHandlerConfig } from "./inbound-handlers.ts";
12
+ import type { TelegramInboundHandlerConfig } from "./inbound.ts";
13
13
  import type { CommandTemplateObjectConfig } from "./command-templates.ts";
14
14
 
15
15
  const CONFIG_RUNTIME_KEY = "__piTelegramConfigRuntime__";
@@ -110,6 +110,22 @@ export function updateTelegramVoiceConfig(
110
110
  return true;
111
111
  }
112
112
 
113
+ export function bindGlobalTelegramConfigRuntime(
114
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
115
+ ): void {
116
+ setGlobalTelegramConfigRuntime({
117
+ updateVoiceConfig(voice) {
118
+ const current = configStore.get();
119
+ const next = {
120
+ ...current,
121
+ voice: { ...(current.voice ?? {}), ...voice },
122
+ };
123
+ configStore.set(next);
124
+ void configStore.persist(next);
125
+ },
126
+ });
127
+ }
128
+
113
129
  export async function readTelegramConfig(
114
130
  configPath: string,
115
131
  ): Promise<TelegramConfig> {
@@ -266,6 +282,16 @@ export function createTelegramTimeInjectionModeSetter(
266
282
  ): (injectionMode: TelegramTimeMode) => Promise<void> {
267
283
  return async (injectionMode) => {
268
284
  const current = configStore.get();
285
+ if (injectionMode === "hidden") {
286
+ const { injectionMode: _injectionMode, ...remainingTime } =
287
+ current.time ?? {};
288
+ const next = { ...current };
289
+ if (Object.keys(remainingTime).length > 0) next.time = remainingTime;
290
+ else delete next.time;
291
+ configStore.set(next);
292
+ await configStore.persist(next);
293
+ return;
294
+ }
269
295
  const next = {
270
296
  ...current,
271
297
  time: { ...(current.time ?? {}), injectionMode },
@@ -282,6 +308,21 @@ export function createTelegramProactivePushChatIdGetter(deps: {
282
308
  return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
283
309
  }
284
310
 
311
+ export function createTelegramConfigControls(
312
+ configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
313
+ ) {
314
+ return {
315
+ isProactivePushEnabled: createTelegramProactivePushChecker(configStore),
316
+ setProactivePushEnabled: createTelegramProactivePushSetter(configStore),
317
+ getVoiceReplyMode: createTelegramVoiceReplyModeGetter(configStore),
318
+ isVoiceReplyModeConfigured:
319
+ createTelegramVoiceReplyModeConfiguredChecker(configStore),
320
+ setVoiceReplyMode: createTelegramVoiceReplyModeSetter(configStore),
321
+ getTimeInjectionMode: createTelegramTimeInjectionModeGetter(configStore),
322
+ setTimeInjectionMode: createTelegramTimeInjectionModeSetter(configStore),
323
+ };
324
+ }
325
+
285
326
  export type TelegramAuthorizationState =
286
327
  | { kind: "pair"; userId: number }
287
328
  | { kind: "allow" }
@@ -216,9 +216,7 @@ function matchesWildcard(pattern: string, value: string | undefined): boolean {
216
216
  return new RegExp(`^${escaped}$`).test(normalizedValue);
217
217
  }
218
218
 
219
- function handlerHasSelectors(
220
- handler: TelegramInboundHandlerConfig,
221
- ): boolean {
219
+ function handlerHasSelectors(handler: TelegramInboundHandlerConfig): boolean {
222
220
  return (
223
221
  normalizeStringList(handler.match).length > 0 ||
224
222
  normalizeStringList(handler.mime).length > 0 ||
@@ -642,7 +640,10 @@ async function readBuiltInTelegramTextAttachment(
642
640
  if (!isTelegramTextMimeType(file.mimeType)) return undefined;
643
641
  const content = await readFile(file.path, "utf8");
644
642
  const normalized = content.trim();
645
- if (!normalized || Buffer.byteLength(normalized, "utf8") > BUILT_IN_TEXT_ATTACHMENT_MAX_BYTES) {
643
+ if (
644
+ !normalized ||
645
+ Buffer.byteLength(normalized, "utf8") > BUILT_IN_TEXT_ATTACHMENT_MAX_BYTES
646
+ ) {
646
647
  return undefined;
647
648
  }
648
649
  const name = file.fileName || basename(file.path);
package/lib/lifecycle.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram lifecycle hook registration helpers
3
3
  * Zones: pi agent lifecycle, telegram session
4
- * Owns binding prepared Telegram lifecycle runtimes to pi extension lifecycle events
4
+ * Binds prepared Telegram lifecycle runtimes to pi extension lifecycle events
5
5
  */
6
6
 
7
7
  import type {
@@ -107,16 +107,15 @@ type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
107
107
  export interface TelegramCompactionObserverRuntimeDeps<TContext> {
108
108
  setCompactionInProgress: (inProgress: boolean) => void;
109
109
  updateStatus: (ctx: TContext) => void;
110
+ startTypingLoop?: (ctx: TContext) => void;
111
+ stopTypingLoop?: () => void;
110
112
  requestDeferredDispatchNextQueuedTelegramTurn: (
111
113
  dispatch: (ctx: TContext) => void,
112
114
  ) => void;
113
115
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
114
116
  recordRuntimeEvent?: (category: string, error: unknown) => void;
115
117
  timeoutMs?: number;
116
- setTimer?: (
117
- callback: () => void,
118
- ms: number,
119
- ) => TelegramLifecycleTimer;
118
+ setTimer?: (callback: () => void, ms: number) => TelegramLifecycleTimer;
120
119
  clearTimer?: (timer: TelegramLifecycleTimer) => void;
121
120
  }
122
121
 
@@ -149,11 +148,13 @@ export function createTelegramCompactionObserverRuntime<TContext>(
149
148
  return {
150
149
  onSessionBeforeCompact: (_event, ctx) => {
151
150
  deps.setCompactionInProgress(true);
151
+ deps.startTypingLoop?.(ctx);
152
152
  deps.updateStatus(ctx);
153
153
  clearFallbackTimer();
154
154
  fallbackTimer = setTimer(() => {
155
155
  fallbackTimer = undefined;
156
156
  deps.setCompactionInProgress(false);
157
+ deps.stopTypingLoop?.();
157
158
  deps.updateStatus(ctx);
158
159
  deps.recordRuntimeEvent?.(
159
160
  "compact",
@@ -165,10 +166,44 @@ export function createTelegramCompactionObserverRuntime<TContext>(
165
166
  onSessionCompact: (_event, ctx) => {
166
167
  clearFallbackTimer();
167
168
  deps.setCompactionInProgress(false);
169
+ deps.stopTypingLoop?.();
168
170
  deps.updateStatus(ctx);
169
171
  requestDispatch();
170
172
  },
171
- onSessionShutdown: clearFallbackTimer,
173
+ onSessionShutdown: () => {
174
+ clearFallbackTimer();
175
+ deps.stopTypingLoop?.();
176
+ },
177
+ };
178
+ }
179
+
180
+ export interface TelegramMessageActivityTypingDeps<TContext> {
181
+ hasActiveTurn: () => boolean;
182
+ startTypingLoop: (ctx: TContext) => void;
183
+ onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
184
+ onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
185
+ }
186
+
187
+ export function createTelegramMessageActivityTypingHooks<
188
+ TContext extends ExtensionContext,
189
+ >(
190
+ deps: TelegramMessageActivityTypingDeps<TContext>,
191
+ ): Pick<
192
+ TelegramLifecycleRegistrationDeps,
193
+ "onMessageStart" | "onMessageUpdate"
194
+ > {
195
+ const ensureTyping = (ctx: TContext): void => {
196
+ if (deps.hasActiveTurn()) deps.startTypingLoop(ctx);
197
+ };
198
+ return {
199
+ onMessageStart: async (event, ctx) => {
200
+ ensureTyping(ctx as TContext);
201
+ await deps.onMessageStart(event, ctx);
202
+ },
203
+ onMessageUpdate: async (event, ctx) => {
204
+ ensureTyping(ctx as TContext);
205
+ await deps.onMessageUpdate(event, ctx);
206
+ },
172
207
  };
173
208
  }
174
209
 
package/lib/menu-model.ts CHANGED
@@ -983,7 +983,7 @@ export function buildModelMenuReplyMarkup(
983
983
  callback_data: "model:scope:scoped",
984
984
  },
985
985
  {
986
- text: state.scope === "all" ? "🟡 All" : "⚫️ All",
986
+ text: state.scope === "all" ? "🟣 All" : "⚫️ All",
987
987
  callback_data: "model:scope:all",
988
988
  },
989
989
  ]);
@@ -1050,7 +1050,7 @@ export function buildModelDetailMenuReplyMarkup(
1050
1050
  callback_data: "model:scope-enable",
1051
1051
  },
1052
1052
  {
1053
- text: scoped ? "⚫️ All" : "🟡 All",
1053
+ text: scoped ? "⚫️ All" : "🟣 All",
1054
1054
  callback_data: "model:scope-disable",
1055
1055
  },
1056
1056
  ],
@@ -1091,7 +1091,7 @@ export function buildModelPageMenuReplyMarkup(
1091
1091
  return {
1092
1092
  text:
1093
1093
  pageIndex === menuPage.page
1094
- ? `🟢 ${pageIndex + 1}`
1094
+ ? `🟣 ${pageIndex + 1}`
1095
1095
  : String(pageIndex + 1),
1096
1096
  callback_data: `model:page:${pageIndex}`,
1097
1097
  };
package/lib/menu-queue.ts CHANGED
@@ -162,7 +162,7 @@ function buildTelegramQueueItemSubmenuReplyMarkup(
162
162
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`,
163
163
  },
164
164
  {
165
- text: isPriority ? "⚫️ Normal" : "🟡 Normal",
165
+ text: isPriority ? "⚫️ Normal" : "🟣 Normal",
166
166
  callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`,
167
167
  },
168
168
  ],