@llblab/pi-telegram 0.2.10 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +52 -19
  2. package/docs/README.md +2 -3
  3. package/docs/architecture.md +62 -31
  4. package/docs/locks.md +136 -0
  5. package/index.ts +323 -1880
  6. package/lib/api.ts +396 -60
  7. package/lib/attachments.ts +128 -16
  8. package/lib/commands.ts +648 -14
  9. package/lib/config.ts +169 -0
  10. package/lib/handlers.ts +474 -0
  11. package/lib/locks.ts +306 -0
  12. package/lib/media.ts +196 -46
  13. package/lib/menu.ts +920 -338
  14. package/lib/model.ts +647 -0
  15. package/lib/pi.ts +90 -0
  16. package/lib/polling.ts +240 -14
  17. package/lib/preview.ts +420 -25
  18. package/lib/queue.ts +1137 -110
  19. package/lib/registration.ts +214 -31
  20. package/lib/rendering.ts +560 -366
  21. package/lib/replies.ts +198 -8
  22. package/lib/routing.ts +217 -0
  23. package/lib/runtime.ts +475 -0
  24. package/lib/setup.ts +143 -1
  25. package/lib/status.ts +432 -13
  26. package/lib/turns.ts +217 -36
  27. package/lib/updates.ts +340 -109
  28. package/package.json +18 -3
  29. package/AGENTS.md +0 -91
  30. package/BACKLOG.md +0 -5
  31. package/CHANGELOG.md +0 -34
  32. package/lib/model-switch.ts +0 -62
  33. package/lib/types.ts +0 -137
  34. package/tests/api.test.ts +0 -331
  35. package/tests/attachments.test.ts +0 -132
  36. package/tests/commands.test.ts +0 -85
  37. package/tests/config.test.ts +0 -80
  38. package/tests/media.test.ts +0 -166
  39. package/tests/menu.test.ts +0 -676
  40. package/tests/polling.test.ts +0 -202
  41. package/tests/preview.test.ts +0 -480
  42. package/tests/queue.test.ts +0 -3245
  43. package/tests/registration.test.ts +0 -268
  44. package/tests/rendering.test.ts +0 -526
  45. package/tests/replies.test.ts +0 -142
  46. package/tests/turns.test.ts +0 -247
  47. package/tests/updates.test.ts +0 -416
package/index.ts CHANGED
@@ -3,1921 +3,364 @@
3
3
  * Keeps the runtime wiring in one place while delegating reusable domain logic to /lib modules
4
4
  */
5
5
 
6
- import { mkdir, readFile, stat } from "node:fs/promises";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
-
10
- import type { AgentMessage } from "@mariozechner/pi-agent-core";
11
- import type { Model } from "@mariozechner/pi-ai";
12
- import type {
13
- ExtensionAPI,
14
- ExtensionContext,
15
- } from "@mariozechner/pi-coding-agent";
16
- import { SettingsManager } from "@mariozechner/pi-coding-agent";
17
-
18
- import {
19
- cleanupTelegramTempFiles,
20
- createTelegramApiClient,
21
- readTelegramConfig,
22
- writeTelegramConfig,
23
- type TelegramConfig,
24
- } from "./lib/api.ts";
25
- import { sendQueuedTelegramAttachments } from "./lib/attachments.ts";
26
- import {
27
- buildTelegramCommandAction,
28
- executeTelegramCommandAction,
29
- parseTelegramCommand,
30
- } from "./lib/commands.ts";
31
- import {
32
- collectTelegramFileInfos,
33
- extractFirstTelegramMessageText,
34
- extractTelegramMessagesText,
35
- guessMediaType,
36
- queueTelegramMediaGroupMessage,
37
- removePendingTelegramMediaGroupMessages,
38
- type TelegramMediaGroupState,
39
- } from "./lib/media.ts";
40
- import {
41
- buildTelegramModelMenuState,
42
- getCanonicalModelId,
43
- handleTelegramMenuCallbackEntry,
44
- handleTelegramModelMenuCallbackAction,
45
- handleTelegramStatusMenuCallbackAction,
46
- handleTelegramThinkingMenuCallbackAction,
47
- sendTelegramModelMenuMessage,
48
- sendTelegramStatusMessage,
49
- updateTelegramModelMenuMessage,
50
- updateTelegramStatusMessage,
51
- updateTelegramThinkingMenuMessage,
52
- type ScopedTelegramModel,
53
- type TelegramModelMenuState,
54
- type TelegramReplyMarkup,
55
- type ThinkingLevel,
56
- } from "./lib/menu.ts";
57
- import {
58
- buildTelegramModelSwitchContinuationText,
59
- canRestartTelegramTurnForModelSwitch,
60
- restartTelegramModelSwitchContinuation,
61
- shouldTriggerPendingTelegramModelSwitchAbort,
62
- } from "./lib/model-switch.ts";
63
- import { runTelegramPollLoop } from "./lib/polling.ts";
64
- import {
65
- clearTelegramPreview,
66
- finalizeTelegramMarkdownPreview,
67
- finalizeTelegramPreview,
68
- flushTelegramPreview,
69
- type TelegramPreviewRuntimeState,
70
- } from "./lib/preview.ts";
71
- import {
72
- buildTelegramAgentEndPlan,
73
- buildTelegramAgentStartPlan,
74
- buildTelegramSessionShutdownState,
75
- buildTelegramSessionStartState,
76
- canDispatchTelegramTurnState,
77
- clearTelegramQueuePromptPriority,
78
- compareTelegramQueueItems,
79
- consumeDispatchedTelegramPrompt,
80
- executeTelegramControlItemRuntime,
81
- executeTelegramQueueDispatchPlan,
82
- formatQueuedTelegramItemsStatus,
83
- getNextTelegramToolExecutionCount,
84
- partitionTelegramQueueItemsForHistory,
85
- planNextTelegramQueueAction,
86
- prioritizeTelegramQueuePrompt,
87
- removeTelegramQueueItemsByMessageIds,
88
- shouldDispatchAfterTelegramAgentEnd,
89
- shouldStartTelegramPolling,
90
- type PendingTelegramControlItem,
91
- type PendingTelegramTurn,
92
- type TelegramQueueItem,
93
- } from "./lib/queue.ts";
94
- import {
95
- registerTelegramAttachmentTool,
96
- registerTelegramCommands,
97
- registerTelegramLifecycleHooks,
98
- } from "./lib/registration.ts";
99
- import {
100
- MAX_MESSAGE_LENGTH,
101
- renderMarkdownPreviewText,
102
- renderTelegramMessage,
103
- type TelegramRenderMode,
104
- } from "./lib/rendering.ts";
105
- import {
106
- buildTelegramReplyTransport,
107
- sendTelegramMarkdownReply,
108
- sendTelegramPlainReply,
109
- } from "./lib/replies.ts";
110
- import {
111
- getTelegramBotTokenInputDefault,
112
- getTelegramBotTokenPromptSpec,
113
- } from "./lib/setup.ts";
114
- import { buildStatusHtml } from "./lib/status.ts";
115
- import {
116
- buildTelegramPromptTurn,
117
- truncateTelegramQueueSummary,
118
- updateTelegramPromptTurnText,
119
- } from "./lib/turns.ts";
120
- import type {
121
- TelegramApiResponse,
122
- TelegramBotCommand,
123
- TelegramCallbackQuery,
124
- TelegramMessage,
125
- TelegramMessageReactionUpdated,
126
- TelegramSentMessage,
127
- TelegramUpdate,
128
- TelegramUser,
129
- } from "./lib/types.ts";
130
- import {
131
- collectTelegramReactionEmojis,
132
- executeTelegramUpdate,
133
- getTelegramAuthorizationState,
134
- } from "./lib/updates.ts";
135
-
136
- // --- Extension State Types ---
137
-
138
- interface DownloadedTelegramFile {
139
- path: string;
140
- fileName: string;
141
- isImage: boolean;
142
- mimeType?: string;
143
- }
144
-
145
- type ActiveTelegramTurn = PendingTelegramTurn;
146
-
147
- type TelegramPreviewState = TelegramPreviewRuntimeState;
148
-
149
- interface StoredTelegramModelMenuState {
150
- state: TelegramModelMenuState;
151
- updatedAt: number;
152
- }
153
-
154
- interface CachedTelegramModelMenuInputs {
155
- expiresAt: number;
156
- availableModels: Model<any>[];
157
- configuredScopedModelPatterns: string[];
158
- cliScopedModelPatterns?: string[];
159
- }
160
-
161
- const AGENT_DIR = join(homedir(), ".pi", "agent");
162
- const CONFIG_PATH = join(AGENT_DIR, "telegram.json");
163
- const TEMP_DIR = join(AGENT_DIR, "tmp", "telegram");
164
- const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
165
- const TELEGRAM_PREFIX = "[telegram]";
166
- const MAX_ATTACHMENTS_PER_TURN = 10;
167
- const PREVIEW_THROTTLE_MS = 750;
168
- const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647;
169
- const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
170
- const TELEGRAM_MODEL_MENU_CACHE_TTL_MS = 5000;
171
- const TELEGRAM_MODEL_MENU_STATE_TTL_MS = 10 * 60 * 1000;
172
- const MAX_STORED_TELEGRAM_MODEL_MENUS = 50;
173
- const SYSTEM_PROMPT_SUFFIX = `
174
-
175
- Telegram bridge extension is active.
176
- - Messages forwarded from Telegram are prefixed with "[telegram]".
177
- - [telegram] messages may include local temp file paths for Telegram attachments. Read those files as needed.
178
- - If a [telegram] user asked for a file or generated artifact, use the telegram_attach tool with the local file path so the extension can send it with your next final reply.
179
- - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`;
180
-
181
- // --- Generic Utilities ---
182
-
183
- function isTelegramPrompt(prompt: string): boolean {
184
- return prompt.trimStart().startsWith(TELEGRAM_PREFIX);
185
- }
186
-
187
- function sanitizeFileName(name: string): string {
188
- return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
189
- }
190
-
191
- function getCliScopedModelPatterns(): string[] | undefined {
192
- const args = process.argv.slice(2);
193
- for (let i = 0; i < args.length; i++) {
194
- const arg = args[i];
195
- if (arg === "--models") {
196
- const value = args[i + 1] ?? "";
197
- const patterns = value
198
- .split(",")
199
- .map((pattern) => pattern.trim())
200
- .filter(Boolean);
201
- return patterns.length > 0 ? patterns : undefined;
202
- }
203
- if (arg.startsWith("--models=")) {
204
- const patterns = arg
205
- .slice("--models=".length)
206
- .split(",")
207
- .map((pattern) => pattern.trim())
208
- .filter(Boolean);
209
- return patterns.length > 0 ? patterns : undefined;
210
- }
211
- }
212
- return undefined;
213
- }
214
-
215
- function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
216
- return label.length <= maxLength
217
- ? label
218
- : `${label.slice(0, maxLength - 1)}…`;
219
- }
6
+ import * as Api from "./lib/api.ts";
7
+ import * as Attachments from "./lib/attachments.ts";
8
+ import * as Config from "./lib/config.ts";
9
+ import * as Handlers from "./lib/handlers.ts";
10
+ import * as Locks from "./lib/locks.ts";
11
+ import * as Media from "./lib/media.ts";
12
+ import * as Menu from "./lib/menu.ts";
13
+ import * as Model from "./lib/model.ts";
14
+ import * as Pi from "./lib/pi.ts";
15
+ import * as Polling from "./lib/polling.ts";
16
+ import * as Preview from "./lib/preview.ts";
17
+ import * as Queue from "./lib/queue.ts";
18
+ import * as Registration from "./lib/registration.ts";
19
+ import * as Replies from "./lib/replies.ts";
20
+ import * as Runtime from "./lib/runtime.ts";
21
+ import * as Routing from "./lib/routing.ts";
22
+ import * as Setup from "./lib/setup.ts";
23
+ import * as Status from "./lib/status.ts";
24
+
25
+ type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
26
+ type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
220
27
 
221
28
  // --- Extension Runtime ---
222
29
 
223
- export const __telegramTestUtils = {
224
- MAX_MESSAGE_LENGTH,
225
- renderTelegramMessage,
226
- compareTelegramQueueItems,
227
- removeTelegramQueueItemsByMessageIds,
228
- clearTelegramQueuePromptPriority,
229
- prioritizeTelegramQueuePrompt,
230
- partitionTelegramQueueItemsForHistory,
231
- consumeDispatchedTelegramPrompt,
232
- planNextTelegramQueueAction,
233
- shouldDispatchAfterTelegramAgentEnd,
234
- buildTelegramAgentEndPlan,
235
- canDispatchTelegramTurnState,
236
- getTelegramBotTokenInputDefault,
237
- getTelegramBotTokenPromptSpec,
238
- canRestartTelegramTurnForModelSwitch,
239
- restartTelegramModelSwitchContinuation,
240
- shouldTriggerPendingTelegramModelSwitchAbort,
241
- buildTelegramModelSwitchContinuationText: (
242
- model: Pick<Model<any>, "provider" | "id">,
243
- thinkingLevel?: ThinkingLevel,
244
- ) =>
245
- buildTelegramModelSwitchContinuationText(
246
- TELEGRAM_PREFIX,
247
- model,
248
- thinkingLevel,
249
- ),
250
- };
251
-
252
- export default function (pi: ExtensionAPI) {
253
- let config: TelegramConfig = {};
254
- let pollingController: AbortController | undefined;
255
- let pollingPromise: Promise<void> | undefined;
256
- let queuedTelegramItems: TelegramQueueItem[] = [];
257
- let nextQueuedTelegramItemOrder = 0;
258
- let nextQueuedTelegramControlOrder = 0;
259
- let nextPriorityReactionOrder = 0;
260
- let activeTelegramTurn: ActiveTelegramTurn | undefined;
261
- let activeTelegramToolExecutions = 0;
262
- let pendingTelegramModelSwitch: ScopedTelegramModel | undefined;
263
- let telegramTurnDispatchPending = false;
264
- let typingInterval: ReturnType<typeof setInterval> | undefined;
265
- let currentAbort: (() => void) | undefined;
266
- let preserveQueuedTurnsAsHistory = false;
267
- let compactionInProgress = false;
268
- let setupInProgress = false;
269
- let previewState: TelegramPreviewState | undefined;
270
- let draftSupport: "unknown" | "supported" | "unsupported" = "unknown";
271
- let nextDraftId = 0;
272
- let currentTelegramModel: Model<any> | undefined;
273
- const mediaGroups = new Map<
274
- string,
275
- TelegramMediaGroupState<TelegramMessage>
276
- >();
277
- const modelMenus = new Map<number, StoredTelegramModelMenuState>();
278
- let cachedModelMenuInputs: CachedTelegramModelMenuInputs | undefined;
279
-
280
- // --- Runtime State ---
281
-
282
- function allocateDraftId(): number {
283
- nextDraftId = nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : nextDraftId + 1;
284
- return nextDraftId;
285
- }
286
-
287
- function canDispatchQueuedTelegramTurn(ctx: ExtensionContext): boolean {
288
- return canDispatchTelegramTurnState({
289
- compactionInProgress,
290
- hasActiveTelegramTurn: !!activeTelegramTurn,
291
- hasPendingTelegramDispatch: telegramTurnDispatchPending,
292
- isIdle: ctx.isIdle(),
293
- hasPendingMessages: ctx.hasPendingMessages(),
30
+ export default function (pi: Pi.ExtensionAPI) {
31
+ const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
32
+ const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
33
+ const configStore = Config.createTelegramConfigStore();
34
+ const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
35
+ const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
36
+ const pendingModelSwitchStore =
37
+ Model.createPendingModelSwitchStore<
38
+ Model.ScopedTelegramModel<ActivePiModel>
39
+ >();
40
+ const modelMenuRuntime = Menu.createTelegramModelMenuRuntime<ActivePiModel>();
41
+ const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
42
+ getBotToken: configStore.getBotToken,
43
+ });
44
+ const mediaGroupRuntime =
45
+ Media.createTelegramMediaGroupController<Api.TelegramMessage>();
46
+ const telegramQueueStore =
47
+ Queue.createTelegramQueueStore<Pi.ExtensionContext>();
48
+ const pollingControllerState = Polling.createTelegramPollingControllerState();
49
+ const { getStatusLines, updateStatus } =
50
+ Status.createTelegramBridgeStatusRuntime<
51
+ Pi.ExtensionContext,
52
+ RuntimeTelegramQueueItem
53
+ >({
54
+ getConfig: configStore.get,
55
+ isPollingActive: Polling.createTelegramPollingActivityReader(
56
+ pollingControllerState,
57
+ ),
58
+ getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds,
59
+ hasActiveTurn: activeTurnRuntime.has,
60
+ hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
61
+ isCompactionInProgress: bridgeRuntime.lifecycle.isCompactionInProgress,
62
+ getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
63
+ hasPendingModelSwitch: pendingModelSwitchStore.has,
64
+ getQueuedItems: telegramQueueStore.getQueuedItems,
65
+ formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
66
+ getRecentRuntimeEvents: runtimeEvents.getEvents,
67
+ getRuntimeLockState: lockRuntime.getStatusLabel,
294
68
  });
295
- }
296
-
297
- function executeQueuedTelegramControlItem(
298
- item: PendingTelegramControlItem,
299
- ctx: ExtensionContext,
300
- ): void {
301
- void executeTelegramControlItemRuntime(item, {
302
- ctx,
303
- sendTextReply,
304
- onSettled: () => {
305
- updateStatus(ctx);
306
- dispatchNextQueuedTelegramTurn(ctx);
307
- },
69
+ const currentModelRuntime = Model.createCurrentModelRuntime<
70
+ Pi.ExtensionContext,
71
+ ActivePiModel
72
+ >({
73
+ getContextModel: Pi.getExtensionContextModel,
74
+ updateStatus,
75
+ });
76
+ const queueMutationRuntime =
77
+ Queue.createTelegramQueueMutationController<Pi.ExtensionContext>({
78
+ ...telegramQueueStore,
79
+ getNextPriorityReactionOrder:
80
+ bridgeRuntime.queue.getNextPriorityReactionOrder,
81
+ incrementNextPriorityReactionOrder:
82
+ bridgeRuntime.queue.incrementNextPriorityReactionOrder,
83
+ updateStatus,
308
84
  });
309
- }
310
-
311
- function dispatchNextQueuedTelegramTurn(ctx: ExtensionContext): void {
312
- const dispatchPlan = planNextTelegramQueueAction(
313
- queuedTelegramItems,
314
- canDispatchQueuedTelegramTurn(ctx),
315
- );
316
- if (dispatchPlan.kind !== "none") {
317
- queuedTelegramItems = dispatchPlan.remainingItems;
318
- }
319
- executeTelegramQueueDispatchPlan(dispatchPlan, {
320
- executeControlItem: (item) => {
321
- updateStatus(ctx);
322
- executeQueuedTelegramControlItem(item, ctx);
323
- },
324
- onPromptDispatchStart: (chatId) => {
325
- telegramTurnDispatchPending = true;
326
- startTypingLoop(ctx, chatId);
327
- updateStatus(ctx);
328
- },
329
- sendUserMessage: (content) => {
330
- pi.sendUserMessage(content);
331
- },
332
- onPromptDispatchFailure: (message) => {
333
- telegramTurnDispatchPending = false;
334
- stopTypingLoop();
335
- updateStatus(ctx, `dispatch failed: ${message}`);
336
- },
337
- onIdle: () => {
338
- updateStatus(ctx);
339
- },
85
+ const attachmentHandlerRuntime =
86
+ Handlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>({
87
+ getHandlers: configStore.getAttachmentHandlers,
88
+ execCommand: piRuntime.exec,
89
+ getCwd: Pi.getExtensionContextCwd,
90
+ recordRuntimeEvent: runtimeEvents.record,
340
91
  });
341
- }
342
-
343
- // --- Status ---
344
-
345
- function updateStatus(ctx: ExtensionContext, error?: string): void {
346
- const theme = ctx.ui.theme;
347
- const label = theme.fg("accent", "telegram");
348
- if (error) {
349
- ctx.ui.setStatus(
350
- "telegram",
351
- `${label} ${theme.fg("error", "error")} ${theme.fg("muted", error)}`,
352
- );
353
- return;
354
- }
355
- if (!config.botToken) {
356
- ctx.ui.setStatus(
357
- "telegram",
358
- `${label} ${theme.fg("muted", "not configured")}`,
359
- );
360
- return;
361
- }
362
- if (!pollingPromise) {
363
- ctx.ui.setStatus(
364
- "telegram",
365
- `${label} ${theme.fg("muted", "disconnected")}`,
366
- );
367
- return;
368
- }
369
- if (!config.allowedUserId) {
370
- ctx.ui.setStatus(
371
- "telegram",
372
- `${label} ${theme.fg("warning", "awaiting pairing")}`,
373
- );
374
- return;
375
- }
376
- if (compactionInProgress) {
377
- const queued = theme.fg(
378
- "muted",
379
- formatQueuedTelegramItemsStatus(queuedTelegramItems),
380
- );
381
- ctx.ui.setStatus(
382
- "telegram",
383
- `${label} ${theme.fg("accent", "compacting")}${queued}`,
384
- );
385
- return;
386
- }
387
- if (
388
- activeTelegramTurn ||
389
- telegramTurnDispatchPending ||
390
- queuedTelegramItems.length > 0
391
- ) {
392
- const queued = theme.fg(
393
- "muted",
394
- formatQueuedTelegramItemsStatus(queuedTelegramItems),
395
- );
396
- ctx.ui.setStatus(
397
- "telegram",
398
- `${label} ${theme.fg("accent", "processing")}${queued}`,
399
- );
400
- return;
401
- }
402
- ctx.ui.setStatus(
403
- "telegram",
404
- `${label} ${theme.fg("success", "connected")}`,
405
- );
406
- }
407
92
 
408
93
  // --- Telegram API ---
409
94
 
410
- const telegramApi = createTelegramApiClient(() => config.botToken);
411
-
412
- const callTelegramApi = <TResponse>(
413
- method: string,
414
- body: Record<string, unknown>,
415
- options?: { signal?: AbortSignal },
416
- ): Promise<TResponse> => {
417
- return telegramApi.call<TResponse>(method, body, options);
418
- };
419
-
420
- const callTelegramMultipartApi = <TResponse>(
421
- method: string,
422
- fields: Record<string, string>,
423
- fileField: string,
424
- filePath: string,
425
- fileName: string,
426
- options?: { signal?: AbortSignal },
427
- ): Promise<TResponse> => {
428
- return telegramApi.callMultipart<TResponse>(
429
- method,
430
- fields,
431
- fileField,
432
- filePath,
433
- fileName,
434
- options,
435
- );
436
- };
437
-
438
- const downloadTelegramBridgeFile = (
439
- fileId: string,
440
- suggestedName: string,
441
- ): Promise<string> => {
442
- return telegramApi.downloadFile(fileId, suggestedName, TEMP_DIR);
443
- };
444
-
445
- const answerCallbackQuery = (
446
- callbackQueryId: string,
447
- text?: string,
448
- ): Promise<void> => {
449
- return telegramApi.answerCallbackQuery(callbackQueryId, text);
450
- };
451
-
452
- // --- Message Delivery & Preview ---
453
-
454
- function startTypingLoop(ctx: ExtensionContext, chatId?: number): void {
455
- const targetChatId = chatId ?? activeTelegramTurn?.chatId;
456
- if (typingInterval || targetChatId === undefined) return;
457
-
458
- const sendTyping = async (): Promise<void> => {
459
- try {
460
- await callTelegramApi("sendChatAction", {
461
- chat_id: targetChatId,
462
- action: "typing",
463
- });
464
- } catch (error) {
465
- const message = error instanceof Error ? error.message : String(error);
466
- updateStatus(ctx, `typing failed: ${message}`);
467
- }
468
- };
469
-
470
- void sendTyping();
471
- typingInterval = setInterval(() => {
472
- void sendTyping();
473
- }, 4000);
474
- }
475
-
476
- function stopTypingLoop(): void {
477
- if (!typingInterval) return;
478
- clearInterval(typingInterval);
479
- typingInterval = undefined;
480
- }
481
-
482
- function isAssistantMessage(message: AgentMessage): boolean {
483
- return (message as unknown as { role?: string }).role === "assistant";
484
- }
485
-
486
- function extractTextContent(content: unknown): string {
487
- const blocks = Array.isArray(content) ? content : [];
488
- return blocks
489
- .filter(
490
- (block): block is { type: string; text?: string } =>
491
- typeof block === "object" && block !== null && "type" in block,
492
- )
493
- .filter(
494
- (block) => block.type === "text" && typeof block.text === "string",
495
- )
496
- .map((block) => block.text as string)
497
- .join("")
498
- .trim();
499
- }
500
-
501
- function getMessageText(message: AgentMessage): string {
502
- return extractTextContent(
503
- (message as unknown as Record<string, unknown>).content,
504
- );
505
- }
506
-
507
- function createPreviewState(): TelegramPreviewState {
508
- return {
509
- mode: draftSupport === "unsupported" ? "message" : "draft",
510
- pendingText: "",
511
- lastSentText: "",
512
- };
513
- }
514
-
515
- function isTelegramMessageNotModifiedError(error: unknown): boolean {
516
- return (
517
- error instanceof Error &&
518
- error.message.includes("message is not modified")
519
- );
520
- }
521
-
522
- async function editTelegramMessageText(
523
- body: Record<string, unknown>,
524
- ): Promise<"edited" | "unchanged"> {
525
- try {
526
- await callTelegramApi("editMessageText", body);
527
- return "edited";
528
- } catch (error) {
529
- if (isTelegramMessageNotModifiedError(error)) return "unchanged";
530
- throw error;
531
- }
532
- }
533
-
534
- const replyTransport = buildTelegramReplyTransport<TelegramReplyMarkup>({
535
- sendMessage: async (body) => {
536
- return callTelegramApi<TelegramSentMessage>("sendMessage", body);
537
- },
538
- editMessage: async (body) => {
539
- await editTelegramMessageText(body);
540
- },
95
+ const {
96
+ callMultipart,
97
+ deleteWebhook,
98
+ getUpdates,
99
+ setMyCommands,
100
+ sendTypingAction,
101
+ sendMessageDraft,
102
+ sendMessage,
103
+ downloadFile: downloadTelegramBridgeFile,
104
+ editMessageText: editTelegramMessageText,
105
+ answerCallbackQuery,
106
+ prepareTempDir,
107
+ } = Api.createDefaultTelegramBridgeApiRuntime({
108
+ getBotToken: configStore.getBotToken,
109
+ recordRuntimeEvent: runtimeEvents.record,
541
110
  });
542
111
 
543
- function getPreviewRuntimeDeps() {
544
- return {
545
- getState: () => previewState,
546
- setState: (state: TelegramPreviewState | undefined) => {
547
- previewState = state;
548
- },
549
- clearScheduledFlush: (state: TelegramPreviewState) => {
550
- if (!state.flushTimer) return;
551
- clearTimeout(state.flushTimer);
552
- state.flushTimer = undefined;
553
- },
554
- maxMessageLength: MAX_MESSAGE_LENGTH,
555
- renderPreviewText: renderMarkdownPreviewText,
556
- getDraftSupport: () => draftSupport,
557
- setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
558
- draftSupport = support;
559
- },
560
- allocateDraftId,
561
- sendDraft: async (chatId: number, draftId: number, text: string) => {
562
- await callTelegramApi("sendMessageDraft", {
563
- chat_id: chatId,
564
- draft_id: draftId,
565
- text,
566
- });
567
- },
568
- sendMessage: async (
569
- chatId: number,
570
- text: string,
571
- options?: { parseMode?: "HTML" },
572
- ) => {
573
- return callTelegramApi<TelegramSentMessage>("sendMessage", {
574
- chat_id: chatId,
575
- text,
576
- parse_mode: options?.parseMode,
577
- });
578
- },
579
- editMessageText: async (
580
- chatId: number,
581
- messageId: number,
582
- text: string,
583
- options?: { parseMode?: "HTML" },
584
- ) => {
585
- await editTelegramMessageText({
586
- chat_id: chatId,
587
- message_id: messageId,
588
- text,
589
- parse_mode: options?.parseMode,
590
- });
591
- },
592
- renderTelegramMessage,
593
- sendRenderedChunks: replyTransport.sendRenderedChunks,
594
- editRenderedMessage: replyTransport.editRenderedMessage,
595
- };
596
- }
597
-
598
- async function clearPreview(chatId: number): Promise<void> {
599
- await clearTelegramPreview(chatId, getPreviewRuntimeDeps());
600
- }
601
-
602
- async function flushPreview(chatId: number): Promise<void> {
603
- await flushTelegramPreview(chatId, getPreviewRuntimeDeps());
604
- }
605
-
606
- function schedulePreviewFlush(chatId: number): void {
607
- if (!previewState || previewState.flushTimer) return;
608
- previewState.flushTimer = setTimeout(() => {
609
- void flushPreview(chatId);
610
- }, PREVIEW_THROTTLE_MS);
611
- }
112
+ // --- Message Delivery & Preview ---
612
113
 
613
- async function finalizePreview(chatId: number): Promise<boolean> {
614
- return finalizeTelegramPreview(chatId, getPreviewRuntimeDeps());
615
- }
114
+ const promptDispatchRuntime =
115
+ Runtime.createTelegramPromptDispatchRuntime<Pi.ExtensionContext>({
116
+ lifecycle: bridgeRuntime.lifecycle,
117
+ typing: bridgeRuntime.typing,
118
+ getDefaultChatId: activeTurnRuntime.getChatId,
119
+ sendTypingAction,
120
+ updateStatus,
121
+ recordRuntimeEvent: runtimeEvents.record,
122
+ });
616
123
 
617
- async function finalizeMarkdownPreview(
618
- chatId: number,
619
- markdown: string,
620
- ): Promise<boolean> {
621
- return finalizeTelegramMarkdownPreview(
622
- chatId,
623
- markdown,
624
- getPreviewRuntimeDeps(),
625
- );
626
- }
124
+ // --- Reply Runtime Wiring ---
627
125
 
628
- async function sendTextReply(
629
- chatId: number,
630
- _replyToMessageId: number,
631
- text: string,
632
- options?: { parseMode?: "HTML" },
633
- ): Promise<number | undefined> {
634
- return sendTelegramPlainReply(
635
- text,
126
+ const {
127
+ replyTransport,
128
+ sendTextReply,
129
+ sendMarkdownReply,
130
+ editInteractiveMessage,
131
+ sendInteractiveMessage,
132
+ } =
133
+ Replies.createTelegramRenderedMessageDeliveryRuntime<Menu.TelegramReplyMarkup>(
636
134
  {
637
- renderTelegramMessage,
638
- sendRenderedChunks: async (chunks) =>
639
- replyTransport.sendRenderedChunks(chatId, chunks),
135
+ sendMessage,
136
+ editMessage: editTelegramMessageText,
640
137
  },
641
- options,
642
138
  );
643
- }
644
-
645
- async function sendMarkdownReply(
646
- chatId: number,
647
- replyToMessageId: number,
648
- markdown: string,
649
- ): Promise<number | undefined> {
650
- return sendTelegramMarkdownReply(markdown, {
651
- renderTelegramMessage,
652
- sendRenderedChunks: async (chunks) => {
653
- if (chunks.length === 0) {
654
- return sendTextReply(chatId, replyToMessageId, markdown);
655
- }
656
- return replyTransport.sendRenderedChunks(chatId, chunks);
657
- },
658
- });
659
- }
660
-
661
- async function sendQueuedAttachments(
662
- turn: ActiveTelegramTurn,
663
- ): Promise<void> {
664
- await sendQueuedTelegramAttachments(turn, {
665
- sendMultipart: async (method, fields, fileField, filePath, fileName) => {
666
- await callTelegramMultipartApi<TelegramSentMessage>(
667
- method,
668
- fields,
669
- fileField,
670
- filePath,
671
- fileName,
672
- );
673
- },
139
+ const dispatchNextQueuedTelegramTurn =
140
+ Queue.createTelegramQueueDispatchRuntime<Pi.ExtensionContext>({
141
+ ...telegramQueueStore,
142
+ isCompactionInProgress: bridgeRuntime.lifecycle.isCompactionInProgress,
143
+ hasActiveTurn: activeTurnRuntime.has,
144
+ hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
145
+ isIdle: Pi.isExtensionContextIdle,
146
+ hasPendingMessages: Pi.hasExtensionContextPendingMessages,
147
+ updateStatus,
674
148
  sendTextReply,
675
- });
676
- }
677
-
678
- function extractAssistantText(messages: AgentMessage[]): {
679
- text?: string;
680
- stopReason?: string;
681
- errorMessage?: string;
682
- } {
683
- for (let i = messages.length - 1; i >= 0; i--) {
684
- const message = messages[i] as unknown as Record<string, unknown>;
685
- if (message.role !== "assistant") continue;
686
- const stopReason =
687
- typeof message.stopReason === "string" ? message.stopReason : undefined;
688
- const errorMessage =
689
- typeof message.errorMessage === "string"
690
- ? message.errorMessage
691
- : undefined;
692
- const text = extractTextContent(message.content);
693
- return { text: text || undefined, stopReason, errorMessage };
694
- }
695
- return {};
696
- }
149
+ recordRuntimeEvent: runtimeEvents.record,
150
+ ...promptDispatchRuntime,
151
+ sendUserMessage: piRuntime.sendUserMessage,
152
+ }).dispatchNext;
153
+ const previewRuntime = Preview.createTelegramAssistantPreviewRuntime({
154
+ getActiveTurn: activeTurnRuntime.get,
155
+ isAssistantMessage: Replies.isAssistantAgentMessage,
156
+ getMessageText: Replies.getAgentMessageText,
157
+ getDefaultReplyToMessageId: activeTurnRuntime.getReplyToMessageId,
158
+ sendDraft: sendMessageDraft,
159
+ sendMessage,
160
+ editMessageText: editTelegramMessageText,
161
+ ...replyTransport,
162
+ });
697
163
 
698
164
  // --- Bridge Setup ---
699
165
 
700
- async function promptForConfig(ctx: ExtensionContext): Promise<void> {
701
- if (!ctx.hasUI || setupInProgress) return;
702
- setupInProgress = true;
703
- try {
704
- const tokenPrompt = getTelegramBotTokenPromptSpec(
705
- process.env,
706
- config.botToken,
707
- );
708
- // Use the editor when a real default exists because ctx.ui.input only
709
- // exposes placeholder text, not an editable prefilled value.
710
- const token =
711
- tokenPrompt.method === "editor"
712
- ? await ctx.ui.editor("Telegram bot token", tokenPrompt.value)
713
- : await ctx.ui.input("Telegram bot token", tokenPrompt.value);
714
- if (!token) return;
715
-
716
- const nextConfig: TelegramConfig = { ...config, botToken: token.trim() };
717
- const response = await fetch(
718
- `https://api.telegram.org/bot${nextConfig.botToken}/getMe`,
719
- );
720
- const data = (await response.json()) as TelegramApiResponse<TelegramUser>;
721
- if (!data.ok || !data.result) {
722
- ctx.ui.notify(
723
- data.description || "Invalid Telegram bot token",
724
- "error",
725
- );
726
- return;
727
- }
728
-
729
- nextConfig.botId = data.result.id;
730
- nextConfig.botUsername = data.result.username;
731
- config = nextConfig;
732
- await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config);
733
- ctx.ui.notify(
734
- `Telegram bot connected: @${config.botUsername ?? "unknown"}`,
735
- "info",
736
- );
737
- ctx.ui.notify(
738
- "Send /start to your bot in Telegram to pair this extension with your account.",
739
- "info",
740
- );
741
- await startPolling(ctx);
742
- updateStatus(ctx);
743
- } finally {
744
- setupInProgress = false;
745
- }
746
- }
747
-
748
- async function registerTelegramBotCommands(): Promise<void> {
749
- const commands: TelegramBotCommand[] = [
750
- {
751
- command: "start",
752
- description: "Show help and pair the Telegram bridge",
753
- },
754
- {
755
- command: "status",
756
- description: "Show model, usage, cost, and context status",
757
- },
758
- { command: "model", description: "Open the interactive model selector" },
759
- { command: "compact", description: "Compact the current pi session" },
760
- { command: "stop", description: "Abort the current pi task" },
761
- ];
762
- await callTelegramApi<boolean>("setMyCommands", { commands });
763
- }
764
-
765
- function getCurrentTelegramModel(
766
- ctx: ExtensionContext,
767
- ): Model<any> | undefined {
768
- return currentTelegramModel ?? ctx.model;
769
- }
770
-
771
- // --- Interactive Menu State & Builders ---
772
-
773
- function pruneStoredModelMenus(now = Date.now()): void {
774
- for (const [messageId, entry] of modelMenus.entries()) {
775
- if (now - entry.updatedAt <= TELEGRAM_MODEL_MENU_STATE_TTL_MS) continue;
776
- modelMenus.delete(messageId);
777
- }
778
- while (modelMenus.size > MAX_STORED_TELEGRAM_MODEL_MENUS) {
779
- const oldestMessageId = modelMenus.keys().next().value as
780
- | number
781
- | undefined;
782
- if (oldestMessageId === undefined) return;
783
- modelMenus.delete(oldestMessageId);
784
- }
785
- }
786
-
787
- function storeModelMenuState(state: TelegramModelMenuState): void {
788
- pruneStoredModelMenus();
789
- modelMenus.set(state.messageId, { state, updatedAt: Date.now() });
790
- }
791
-
792
- function getStoredModelMenuState(
793
- messageId: number | undefined,
794
- ): TelegramModelMenuState | undefined {
795
- if (messageId === undefined) return undefined;
796
- pruneStoredModelMenus();
797
- const entry = modelMenus.get(messageId);
798
- if (!entry) return undefined;
799
- modelMenus.delete(messageId);
800
- entry.updatedAt = Date.now();
801
- modelMenus.set(messageId, entry);
802
- return entry.state;
803
- }
804
-
805
- async function getCachedModelMenuInputs(
806
- ctx: ExtensionContext,
807
- ): Promise<CachedTelegramModelMenuInputs> {
808
- const now = Date.now();
809
- if (cachedModelMenuInputs && cachedModelMenuInputs.expiresAt > now) {
810
- return cachedModelMenuInputs;
811
- }
812
- const settingsManager = SettingsManager.create(ctx.cwd);
813
- await settingsManager.reload();
814
- ctx.modelRegistry.refresh();
815
- const availableModels = ctx.modelRegistry.getAvailable();
816
- const cliScopedModelPatterns = getCliScopedModelPatterns();
817
- const configuredScopedModelPatterns =
818
- cliScopedModelPatterns ?? settingsManager.getEnabledModels() ?? [];
819
- cachedModelMenuInputs = {
820
- expiresAt: now + TELEGRAM_MODEL_MENU_CACHE_TTL_MS,
821
- availableModels,
822
- configuredScopedModelPatterns,
823
- cliScopedModelPatterns,
824
- };
825
- return cachedModelMenuInputs;
826
- }
827
-
828
- async function getModelMenuState(
829
- chatId: number,
830
- ctx: ExtensionContext,
831
- ): Promise<TelegramModelMenuState> {
832
- const activeModel = getCurrentTelegramModel(ctx);
833
- const inputs = await getCachedModelMenuInputs(ctx);
834
- return buildTelegramModelMenuState({
835
- chatId,
836
- activeModel,
837
- availableModels: inputs.availableModels,
838
- configuredScopedModelPatterns: inputs.configuredScopedModelPatterns,
839
- cliScopedModelPatterns: inputs.cliScopedModelPatterns,
840
- });
841
- }
842
-
843
- // --- Interactive Menu Actions ---
844
-
845
- async function updateModelMenuMessage(
846
- state: TelegramModelMenuState,
847
- ctx: ExtensionContext,
848
- ): Promise<void> {
849
- await updateTelegramModelMenuMessage(state, getCurrentTelegramModel(ctx), {
850
- editInteractiveMessage,
851
- sendInteractiveMessage,
166
+ const modelSwitchController =
167
+ Model.createTelegramModelSwitchControllerRuntime<
168
+ Pi.ExtensionContext,
169
+ Model.ScopedTelegramModel<ActivePiModel>
170
+ >({
171
+ isIdle: Pi.isExtensionContextIdle,
172
+ getPendingModelSwitch: pendingModelSwitchStore.get,
173
+ setPendingModelSwitch: pendingModelSwitchStore.set,
174
+ getActiveTurn: activeTurnRuntime.get,
175
+ getAbortHandler: bridgeRuntime.abort.getHandler,
176
+ hasAbortHandler: bridgeRuntime.abort.hasHandler,
177
+ getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
178
+ allocateItemOrder: bridgeRuntime.queue.allocateItemOrder,
179
+ allocateControlOrder: bridgeRuntime.queue.allocateControlOrder,
180
+ appendQueuedItem: queueMutationRuntime.append,
181
+ updateStatus,
852
182
  });
853
- }
854
-
855
- async function updateThinkingMenuMessage(
856
- state: TelegramModelMenuState,
857
- ctx: ExtensionContext,
858
- ): Promise<void> {
859
- await updateTelegramThinkingMenuMessage(
860
- state,
861
- getCurrentTelegramModel(ctx),
862
- pi.getThinkingLevel(),
863
- { editInteractiveMessage, sendInteractiveMessage },
864
- );
865
- }
866
-
867
- async function editInteractiveMessage(
868
- chatId: number,
869
- messageId: number,
870
- text: string,
871
- mode: TelegramRenderMode,
872
- replyMarkup: TelegramReplyMarkup,
873
- ): Promise<void> {
874
- await replyTransport.editRenderedMessage(
875
- chatId,
876
- messageId,
877
- renderTelegramMessage(text, { mode }),
878
- { replyMarkup },
879
- );
880
- }
881
-
882
- async function sendInteractiveMessage(
883
- chatId: number,
884
- text: string,
885
- mode: TelegramRenderMode,
886
- replyMarkup: TelegramReplyMarkup,
887
- ): Promise<number | undefined> {
888
- return replyTransport.sendRenderedChunks(
889
- chatId,
890
- renderTelegramMessage(text, { mode }),
891
- { replyMarkup },
892
- );
893
- }
894
-
895
- async function ensureIdleOrNotify(
896
- ctx: ExtensionContext,
897
- chatId: number,
898
- replyToMessageId: number,
899
- busyMessage: string,
900
- ): Promise<boolean> {
901
- if (ctx.isIdle()) return true;
902
- await sendTextReply(chatId, replyToMessageId, busyMessage);
903
- return false;
904
- }
905
-
906
- async function showStatusMessage(
907
- state: TelegramModelMenuState,
908
- ctx: ExtensionContext,
909
- ): Promise<void> {
910
- await updateTelegramStatusMessage(
911
- state,
912
- buildStatusHtml(ctx, getCurrentTelegramModel(ctx)),
913
- getCurrentTelegramModel(ctx),
914
- pi.getThinkingLevel(),
915
- { editInteractiveMessage, sendInteractiveMessage },
916
- );
917
- }
918
-
919
- async function sendStatusMessage(
920
- chatId: number,
921
- replyToMessageId: number,
922
- ctx: ExtensionContext,
923
- ): Promise<void> {
924
- const isIdle = await ensureIdleOrNotify(
925
- ctx,
926
- chatId,
927
- replyToMessageId,
928
- "Cannot open status while pi is busy. Send /stop first.",
929
- );
930
- if (!isIdle) return;
931
- const state = await getModelMenuState(chatId, ctx);
932
- const messageId = await sendTelegramStatusMessage(
933
- state,
934
- buildStatusHtml(ctx, getCurrentTelegramModel(ctx)),
935
- getCurrentTelegramModel(ctx),
936
- pi.getThinkingLevel(),
937
- { editInteractiveMessage, sendInteractiveMessage },
938
- );
939
- if (messageId === undefined) return;
940
- state.messageId = messageId;
941
- state.mode = "status";
942
- storeModelMenuState(state);
943
- }
944
-
945
- function canOfferInFlightTelegramModelSwitch(ctx: ExtensionContext): boolean {
946
- return canRestartTelegramTurnForModelSwitch({
947
- isIdle: ctx.isIdle(),
948
- hasActiveTelegramTurn: !!activeTelegramTurn,
949
- hasAbortHandler: !!currentAbort,
950
- });
951
- }
952
-
953
- function createTelegramControlItem(
954
- chatId: number,
955
- replyToMessageId: number,
956
- controlType: PendingTelegramControlItem["controlType"],
957
- statusSummary: string,
958
- execute: PendingTelegramControlItem["execute"],
959
- ): PendingTelegramControlItem {
960
- const queueOrder = nextQueuedTelegramItemOrder++;
961
- return {
962
- kind: "control",
963
- controlType,
964
- chatId,
965
- replyToMessageId,
966
- queueOrder,
967
- queueLane: "control",
968
- laneOrder: nextQueuedTelegramControlOrder++,
969
- statusSummary,
970
- execute,
971
- };
972
- }
973
-
974
- function enqueueTelegramControlItem(
975
- item: PendingTelegramControlItem,
976
- ctx: ExtensionContext,
977
- ): void {
978
- queuedTelegramItems.push(item);
979
- reorderQueuedTelegramTurns(ctx);
980
- dispatchNextQueuedTelegramTurn(ctx);
981
- }
982
-
983
- function createTelegramModelSwitchContinuationTurn(
984
- turn: ActiveTelegramTurn,
985
- selection: ScopedTelegramModel,
986
- ): PendingTelegramTurn {
987
- const statusLabel = truncateTelegramQueueSummary(
988
- `continue on ${selection.model.id}`,
989
- 4,
990
- 32,
991
- );
992
- return {
993
- kind: "prompt",
994
- chatId: turn.chatId,
995
- replyToMessageId: turn.replyToMessageId,
996
- sourceMessageIds: [],
997
- queueOrder: nextQueuedTelegramItemOrder++,
998
- queueLane: "control",
999
- laneOrder: nextQueuedTelegramControlOrder++,
1000
- queuedAttachments: [],
1001
- content: [
1002
- {
1003
- type: "text",
1004
- text: buildTelegramModelSwitchContinuationText(
1005
- TELEGRAM_PREFIX,
1006
- selection.model,
1007
- selection.thinkingLevel,
1008
- ),
1009
- },
1010
- ],
1011
- historyText: `Continue interrupted Telegram request on ${getCanonicalModelId(selection.model)}`,
1012
- statusSummary: `↻ ${statusLabel || "continue"}`,
1013
- };
1014
- }
1015
-
1016
- function queueTelegramModelSwitchContinuation(
1017
- turn: ActiveTelegramTurn,
1018
- selection: ScopedTelegramModel,
1019
- ctx: ExtensionContext,
1020
- ): void {
1021
- queuedTelegramItems.push(
1022
- createTelegramModelSwitchContinuationTurn(turn, selection),
1023
- );
1024
- reorderQueuedTelegramTurns(ctx);
1025
- }
1026
-
1027
- function triggerPendingTelegramModelSwitchAbort(
1028
- ctx: ExtensionContext,
1029
- ): boolean {
1030
- if (
1031
- !shouldTriggerPendingTelegramModelSwitchAbort({
1032
- hasPendingModelSwitch: !!pendingTelegramModelSwitch,
1033
- hasActiveTelegramTurn: !!activeTelegramTurn,
1034
- hasAbortHandler: !!currentAbort,
1035
- activeToolExecutions: activeTelegramToolExecutions,
1036
- })
1037
- ) {
1038
- return false;
1039
- }
1040
- const selection = pendingTelegramModelSwitch;
1041
- const turn = activeTelegramTurn;
1042
- const abort = currentAbort;
1043
- if (!selection || !turn || !abort) return false;
1044
- pendingTelegramModelSwitch = undefined;
1045
- queueTelegramModelSwitchContinuation(turn, selection, ctx);
1046
- abort();
1047
- return true;
1048
- }
1049
-
1050
- async function openModelMenu(
1051
- chatId: number,
1052
- replyToMessageId: number,
1053
- ctx: ExtensionContext,
1054
- ): Promise<void> {
1055
- if (!ctx.isIdle() && !canOfferInFlightTelegramModelSwitch(ctx)) {
1056
- await sendTextReply(
1057
- chatId,
1058
- replyToMessageId,
1059
- "Cannot switch model while pi is busy. Send /stop first.",
1060
- );
1061
- return;
1062
- }
1063
- const state = await getModelMenuState(chatId, ctx);
1064
- if (state.allModels.length === 0) {
1065
- await sendTextReply(
1066
- chatId,
1067
- replyToMessageId,
1068
- "No available models with configured auth.",
1069
- );
1070
- return;
1071
- }
1072
- const activeModel = getCurrentTelegramModel(ctx);
1073
- const messageId = await sendTelegramModelMenuMessage(state, activeModel, {
1074
- editInteractiveMessage,
1075
- sendInteractiveMessage,
1076
- });
1077
- if (messageId === undefined) return;
1078
- state.messageId = messageId;
1079
- state.mode = "model";
1080
- storeModelMenuState(state);
1081
- }
1082
-
1083
- async function handleStatusCallbackAction(
1084
- query: TelegramCallbackQuery,
1085
- state: TelegramModelMenuState,
1086
- ctx: ExtensionContext,
1087
- ): Promise<boolean> {
1088
- return handleTelegramStatusMenuCallbackAction(
1089
- query.id,
1090
- query.data,
1091
- getCurrentTelegramModel(ctx),
1092
- {
1093
- updateModelMenuMessage: async () => updateModelMenuMessage(state, ctx),
1094
- updateThinkingMenuMessage: async () =>
1095
- updateThinkingMenuMessage(state, ctx),
1096
- answerCallbackQuery,
1097
- },
1098
- );
1099
- }
1100
-
1101
- async function handleThinkingCallbackAction(
1102
- query: TelegramCallbackQuery,
1103
- state: TelegramModelMenuState,
1104
- ctx: ExtensionContext,
1105
- ): Promise<boolean> {
1106
- return handleTelegramThinkingMenuCallbackAction(
1107
- query.id,
1108
- query.data,
1109
- getCurrentTelegramModel(ctx),
1110
- {
1111
- setThinkingLevel: (level) => {
1112
- pi.setThinkingLevel(level);
1113
- updateStatus(ctx);
1114
- },
1115
- getCurrentThinkingLevel: () => pi.getThinkingLevel(),
1116
- updateStatusMessage: async () => showStatusMessage(state, ctx),
1117
- answerCallbackQuery,
1118
- },
1119
- );
1120
- }
1121
-
1122
- async function handleModelCallbackAction(
1123
- query: TelegramCallbackQuery,
1124
- state: TelegramModelMenuState,
1125
- ctx: ExtensionContext,
1126
- ): Promise<boolean> {
1127
- try {
1128
- return await handleTelegramModelMenuCallbackAction(
1129
- query.id,
1130
- {
1131
- data: query.data,
1132
- state,
1133
- activeModel: getCurrentTelegramModel(ctx),
1134
- currentThinkingLevel: pi.getThinkingLevel(),
1135
- isIdle: ctx.isIdle(),
1136
- canRestartBusyRun: !!activeTelegramTurn && !!currentAbort,
1137
- hasActiveToolExecutions: activeTelegramToolExecutions > 0,
1138
- },
1139
- {
1140
- updateModelMenuMessage: async () =>
1141
- updateModelMenuMessage(state, ctx),
1142
- updateStatusMessage: async () => showStatusMessage(state, ctx),
1143
- answerCallbackQuery,
1144
- setModel: (model) => pi.setModel(model),
1145
- setCurrentModel: (model) => {
1146
- currentTelegramModel = model;
1147
- updateStatus(ctx);
1148
- },
1149
- setThinkingLevel: (level) => {
1150
- pi.setThinkingLevel(level);
1151
- updateStatus(ctx);
1152
- },
1153
- stagePendingModelSwitch: (selection) => {
1154
- pendingTelegramModelSwitch = selection;
1155
- updateStatus(ctx);
1156
- },
1157
- restartInterruptedTelegramTurn: (selection) => {
1158
- return restartTelegramModelSwitchContinuation({
1159
- activeTurn: activeTelegramTurn,
1160
- abort: currentAbort,
1161
- selection,
1162
- queueContinuation: (turn, nextSelection) => {
1163
- queueTelegramModelSwitchContinuation(turn, nextSelection, ctx);
1164
- },
1165
- });
1166
- },
1167
- },
1168
- );
1169
- } catch (error) {
1170
- const message = error instanceof Error ? error.message : String(error);
1171
- await answerCallbackQuery(query.id, message);
1172
- return true;
1173
- }
1174
- }
1175
-
1176
- async function handleAuthorizedTelegramCallbackQuery(
1177
- query: TelegramCallbackQuery,
1178
- ctx: ExtensionContext,
1179
- ): Promise<void> {
1180
- const messageId = query.message?.message_id;
1181
- const state = getStoredModelMenuState(messageId);
1182
- await handleTelegramMenuCallbackEntry(query.id, query.data, state, {
1183
- handleStatusAction: async () => {
1184
- if (!state) return false;
1185
- return handleStatusCallbackAction(query, state, ctx);
1186
- },
1187
- handleThinkingAction: async () => {
1188
- if (!state) return false;
1189
- return handleThinkingCallbackAction(query, state, ctx);
1190
- },
1191
- handleModelAction: async () => {
1192
- if (!state) return false;
1193
- return handleModelCallbackAction(query, state, ctx);
1194
- },
1195
- answerCallbackQuery,
1196
- });
1197
- }
1198
-
1199
- // --- Turn Queue & Message Dispatch ---
1200
-
1201
- async function buildTelegramFiles(
1202
- messages: TelegramMessage[],
1203
- ): Promise<DownloadedTelegramFile[]> {
1204
- const downloaded: DownloadedTelegramFile[] = [];
1205
- for (const file of collectTelegramFileInfos(messages)) {
1206
- const path = await downloadTelegramBridgeFile(
1207
- file.file_id,
1208
- file.fileName,
1209
- );
1210
- downloaded.push({
1211
- path,
1212
- fileName: file.fileName,
1213
- isImage: file.isImage,
1214
- mimeType: file.mimeType,
1215
- });
1216
- }
1217
- return downloaded;
1218
- }
1219
-
1220
- function reorderQueuedTelegramTurns(ctx: ExtensionContext): void {
1221
- queuedTelegramItems.sort(compareTelegramQueueItems);
1222
- updateStatus(ctx);
1223
- }
1224
-
1225
- function removePendingMediaGroupMessages(messageIds: number[]): void {
1226
- removePendingTelegramMediaGroupMessages(
1227
- mediaGroups,
1228
- messageIds,
1229
- clearTimeout,
1230
- );
1231
- }
1232
-
1233
- function removeQueuedTelegramTurnsByMessageIds(
1234
- messageIds: number[],
1235
- ctx: ExtensionContext,
1236
- ): number {
1237
- const result = removeTelegramQueueItemsByMessageIds(
1238
- queuedTelegramItems,
1239
- messageIds,
1240
- );
1241
- if (result.removedCount === 0) return 0;
1242
- queuedTelegramItems = result.items;
1243
- updateStatus(ctx);
1244
- return result.removedCount;
1245
- }
1246
-
1247
- function clearQueuedTelegramTurnPriorityByMessageId(
1248
- messageId: number,
1249
- ctx: ExtensionContext,
1250
- ): boolean {
1251
- const result = clearTelegramQueuePromptPriority(
1252
- queuedTelegramItems,
1253
- messageId,
1254
- );
1255
- if (!result.changed) return false;
1256
- queuedTelegramItems = result.items;
1257
- reorderQueuedTelegramTurns(ctx);
1258
- return true;
1259
- }
1260
-
1261
- function prioritizeQueuedTelegramTurnByMessageId(
1262
- messageId: number,
1263
- ctx: ExtensionContext,
1264
- ): boolean {
1265
- const result = prioritizeTelegramQueuePrompt(
1266
- queuedTelegramItems,
1267
- messageId,
1268
- nextPriorityReactionOrder,
1269
- );
1270
- if (!result.changed) return false;
1271
- queuedTelegramItems = result.items;
1272
- nextPriorityReactionOrder += 1;
1273
- reorderQueuedTelegramTurns(ctx);
1274
- return true;
1275
- }
1276
-
1277
- async function handleAuthorizedTelegramReactionUpdate(
1278
- reactionUpdate: TelegramMessageReactionUpdated,
1279
- ctx: ExtensionContext,
1280
- ): Promise<void> {
1281
- const reactionUser = reactionUpdate.user;
1282
- if (
1283
- reactionUpdate.chat.type !== "private" ||
1284
- !reactionUser ||
1285
- reactionUser.is_bot ||
1286
- reactionUser.id !== config.allowedUserId
1287
- ) {
1288
- return;
1289
- }
1290
- const oldEmojis = collectTelegramReactionEmojis(
1291
- reactionUpdate.old_reaction,
1292
- );
1293
- const newEmojis = collectTelegramReactionEmojis(
1294
- reactionUpdate.new_reaction,
1295
- );
1296
- const dislikeAdded = !oldEmojis.has("👎") && newEmojis.has("👎");
1297
- if (dislikeAdded) {
1298
- removePendingMediaGroupMessages([reactionUpdate.message_id]);
1299
- removeQueuedTelegramTurnsByMessageIds([reactionUpdate.message_id], ctx);
1300
- return;
1301
- }
1302
- const likeRemoved = oldEmojis.has("👍") && !newEmojis.has("👍");
1303
- if (likeRemoved) {
1304
- clearQueuedTelegramTurnPriorityByMessageId(
1305
- reactionUpdate.message_id,
1306
- ctx,
1307
- );
1308
- }
1309
- const likeAdded = !oldEmojis.has("👍") && newEmojis.has("👍");
1310
- if (!likeAdded) return;
1311
- prioritizeQueuedTelegramTurnByMessageId(reactionUpdate.message_id, ctx);
1312
- }
1313
-
1314
- async function createTelegramTurn(
1315
- messages: TelegramMessage[],
1316
- historyTurns: PendingTelegramTurn[] = [],
1317
- ): Promise<PendingTelegramTurn> {
1318
- return buildTelegramPromptTurn({
1319
- telegramPrefix: TELEGRAM_PREFIX,
1320
- messages,
1321
- historyTurns,
1322
- queueOrder: nextQueuedTelegramItemOrder++,
1323
- rawText: extractTelegramMessagesText(messages),
1324
- files: await buildTelegramFiles(messages),
1325
- readBinaryFile: async (path) => readFile(path),
1326
- inferImageMimeType: guessMediaType,
1327
- });
1328
- }
1329
-
1330
- async function handleStopCommand(
1331
- message: TelegramMessage,
1332
- ctx: ExtensionContext,
1333
- ): Promise<void> {
1334
- if (currentAbort) {
1335
- pendingTelegramModelSwitch = undefined;
1336
- if (queuedTelegramItems.length > 0) {
1337
- preserveQueuedTurnsAsHistory = true;
1338
- }
1339
- currentAbort();
1340
- updateStatus(ctx);
1341
- await sendTextReply(
1342
- message.chat.id,
1343
- message.message_id,
1344
- "Aborted current turn.",
1345
- );
1346
- return;
1347
- }
1348
- await sendTextReply(message.chat.id, message.message_id, "No active turn.");
1349
- }
1350
-
1351
- async function handleCompactCommand(
1352
- message: TelegramMessage,
1353
- ctx: ExtensionContext,
1354
- ): Promise<void> {
1355
- if (
1356
- !ctx.isIdle() ||
1357
- ctx.hasPendingMessages() ||
1358
- activeTelegramTurn ||
1359
- telegramTurnDispatchPending ||
1360
- queuedTelegramItems.length > 0 ||
1361
- compactionInProgress
1362
- ) {
1363
- await sendTextReply(
1364
- message.chat.id,
1365
- message.message_id,
1366
- "Cannot compact while pi or the Telegram queue is busy. Wait for queued turns to finish or send /stop first.",
1367
- );
1368
- return;
1369
- }
1370
- compactionInProgress = true;
1371
- updateStatus(ctx);
1372
- try {
1373
- ctx.compact({
1374
- onComplete: () => {
1375
- compactionInProgress = false;
1376
- updateStatus(ctx);
1377
- dispatchNextQueuedTelegramTurn(ctx);
1378
- void sendTextReply(
1379
- message.chat.id,
1380
- message.message_id,
1381
- "Compaction completed.",
1382
- );
1383
- },
1384
- onError: (error) => {
1385
- compactionInProgress = false;
1386
- updateStatus(ctx);
1387
- dispatchNextQueuedTelegramTurn(ctx);
1388
- const errorMessage =
1389
- error instanceof Error ? error.message : String(error);
1390
- void sendTextReply(
1391
- message.chat.id,
1392
- message.message_id,
1393
- `Compaction failed: ${errorMessage}`,
1394
- );
1395
- },
1396
- });
1397
- } catch (error) {
1398
- compactionInProgress = false;
1399
- updateStatus(ctx);
1400
- const errorMessage =
1401
- error instanceof Error ? error.message : String(error);
1402
- await sendTextReply(
1403
- message.chat.id,
1404
- message.message_id,
1405
- `Compaction failed: ${errorMessage}`,
1406
- );
1407
- return;
1408
- }
1409
- await sendTextReply(
1410
- message.chat.id,
1411
- message.message_id,
1412
- "Compaction started.",
1413
- );
1414
- }
1415
-
1416
- async function handleStatusCommand(
1417
- message: TelegramMessage,
1418
- ctx: ExtensionContext,
1419
- ): Promise<void> {
1420
- enqueueTelegramControlItem(
1421
- createTelegramControlItem(
1422
- message.chat.id,
1423
- message.message_id,
1424
- "status",
1425
- "⚡ status",
1426
- async (controlCtx) => {
1427
- await sendStatusMessage(
1428
- message.chat.id,
1429
- message.message_id,
1430
- controlCtx,
1431
- );
1432
- },
1433
- ),
1434
- ctx,
1435
- );
1436
- }
1437
-
1438
- async function handleModelCommand(
1439
- message: TelegramMessage,
1440
- ctx: ExtensionContext,
1441
- ): Promise<void> {
1442
- enqueueTelegramControlItem(
1443
- createTelegramControlItem(
1444
- message.chat.id,
1445
- message.message_id,
1446
- "model",
1447
- "⚡ model",
1448
- async (controlCtx) => {
1449
- await openModelMenu(message.chat.id, message.message_id, controlCtx);
1450
- },
1451
- ),
1452
- ctx,
1453
- );
1454
- }
1455
-
1456
- async function handleHelpCommand(
1457
- message: TelegramMessage,
1458
- commandName: string,
1459
- ctx: ExtensionContext,
1460
- ): Promise<void> {
1461
- let helpText =
1462
- "Send me a message and I will forward it to pi. Commands: /status, /model, /compact, /stop.";
1463
- if (commandName === "start") {
1464
- try {
1465
- await registerTelegramBotCommands();
1466
- } catch (error) {
1467
- const errorMessage =
1468
- error instanceof Error ? error.message : String(error);
1469
- helpText += `\n\nWarning: failed to register bot commands menu: ${errorMessage}`;
1470
- }
1471
- }
1472
- await sendTextReply(message.chat.id, message.message_id, helpText);
1473
- if (config.allowedUserId === undefined && message.from) {
1474
- config.allowedUserId = message.from.id;
1475
- await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config);
1476
- updateStatus(ctx);
1477
- }
1478
- }
1479
-
1480
- async function handleTelegramCommand(
1481
- commandName: string | undefined,
1482
- message: TelegramMessage,
1483
- ctx: ExtensionContext,
1484
- ): Promise<boolean> {
1485
- return executeTelegramCommandAction(
1486
- buildTelegramCommandAction(commandName),
1487
- message,
1488
- ctx,
1489
- {
1490
- handleStop: handleStopCommand,
1491
- handleCompact: handleCompactCommand,
1492
- handleStatus: handleStatusCommand,
1493
- handleModel: handleModelCommand,
1494
- handleHelp: handleHelpCommand,
1495
- },
1496
- );
1497
- }
1498
-
1499
- async function enqueueTelegramTurn(
1500
- messages: TelegramMessage[],
1501
- ctx: ExtensionContext,
1502
- ): Promise<void> {
1503
- const historyResult = preserveQueuedTurnsAsHistory
1504
- ? partitionTelegramQueueItemsForHistory(queuedTelegramItems)
1505
- : { historyTurns: [], remainingItems: queuedTelegramItems };
1506
- queuedTelegramItems = historyResult.remainingItems;
1507
- preserveQueuedTurnsAsHistory = false;
1508
- const turn = await createTelegramTurn(messages, historyResult.historyTurns);
1509
- queuedTelegramItems.push(turn);
1510
- updateStatus(ctx);
1511
- dispatchNextQueuedTelegramTurn(ctx);
1512
- }
1513
-
1514
- async function dispatchAuthorizedTelegramMessages(
1515
- messages: TelegramMessage[],
1516
- ctx: ExtensionContext,
1517
- ): Promise<void> {
1518
- const firstMessage = messages[0];
1519
- if (!firstMessage) return;
1520
- const rawText = extractFirstTelegramMessageText(messages);
1521
- const commandName = parseTelegramCommand(rawText)?.name;
1522
- const handled = await handleTelegramCommand(commandName, firstMessage, ctx);
1523
- if (handled) return;
1524
- await enqueueTelegramTurn(messages, ctx);
1525
- }
1526
-
1527
- function updateQueuedTelegramTurnFromEditedMessage(
1528
- message: TelegramMessage,
1529
- ctx: ExtensionContext,
1530
- ): boolean {
1531
- const messageText = extractTelegramMessagesText([message]);
1532
- let changed = false;
1533
- queuedTelegramItems = queuedTelegramItems.map((item) => {
1534
- if (
1535
- item.kind !== "prompt" ||
1536
- message.message_id === undefined ||
1537
- !item.sourceMessageIds.includes(message.message_id)
1538
- ) {
1539
- return item;
1540
- }
1541
- changed = true;
1542
- return updateTelegramPromptTurnText({
1543
- turn: item,
1544
- telegramPrefix: TELEGRAM_PREFIX,
1545
- rawText: messageText,
1546
- });
1547
- });
1548
- if (changed) updateStatus(ctx);
1549
- return changed;
1550
- }
1551
-
1552
- async function handleAuthorizedTelegramEditedMessage(
1553
- message: TelegramMessage,
1554
- ctx: ExtensionContext,
1555
- ): Promise<void> {
1556
- updateQueuedTelegramTurnFromEditedMessage(message, ctx);
1557
- }
1558
-
1559
- async function handleAuthorizedTelegramMessage(
1560
- message: TelegramMessage,
1561
- ctx: ExtensionContext,
1562
- ): Promise<void> {
1563
- const queuedMediaGroup = queueTelegramMediaGroupMessage({
1564
- message,
1565
- groups: mediaGroups,
1566
- debounceMs: TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS,
1567
- setTimer: setTimeout,
1568
- clearTimer: clearTimeout,
1569
- dispatchMessages: (messages) => {
1570
- void dispatchAuthorizedTelegramMessages(messages, ctx);
1571
- },
1572
- });
1573
- if (queuedMediaGroup) return;
1574
- await dispatchAuthorizedTelegramMessages([message], ctx);
1575
- }
183
+ const menuActions = Menu.createTelegramMenuActionRuntimeWithStateBuilder<
184
+ ActivePiModel,
185
+ Pi.ExtensionContext
186
+ >({
187
+ runtime: modelMenuRuntime,
188
+ createSettingsManager: Pi.createSettingsManager,
189
+ getActiveModel: currentModelRuntime.get,
190
+ getThinkingLevel: piRuntime.getThinkingLevel,
191
+ buildStatusHtml: Status.createTelegramStatusHtmlBuilder({
192
+ getActiveModel: currentModelRuntime.get,
193
+ }),
194
+ storeModelMenuState: modelMenuRuntime.storeState,
195
+ isIdle: Pi.isExtensionContextIdle,
196
+ canOfferInFlightModelSwitch: modelSwitchController.canOfferInFlightSwitch,
197
+ sendTextReply,
198
+ editInteractiveMessage,
199
+ sendInteractiveMessage,
200
+ });
1576
201
 
1577
- async function pairTelegramUserIfNeeded(
1578
- userId: number,
1579
- ctx: ExtensionContext,
1580
- ): Promise<boolean> {
1581
- const authorization = getTelegramAuthorizationState(
1582
- userId,
1583
- config.allowedUserId,
1584
- );
1585
- if (authorization.kind !== "pair") return false;
1586
- config.allowedUserId = authorization.userId;
1587
- await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config);
1588
- updateStatus(ctx);
1589
- return true;
1590
- }
202
+ // --- Polling ---
1591
203
 
1592
- async function handleUpdate(
1593
- update: TelegramUpdate,
1594
- ctx: ExtensionContext,
1595
- ): Promise<void> {
1596
- await executeTelegramUpdate(update, config.allowedUserId, {
1597
- ctx,
1598
- removePendingMediaGroupMessages,
1599
- removeQueuedTelegramTurnsByMessageIds,
1600
- handleAuthorizedTelegramReactionUpdate: async (
1601
- reactionUpdate,
1602
- nextCtx,
1603
- ) => {
1604
- await handleAuthorizedTelegramReactionUpdate(
1605
- reactionUpdate as TelegramMessageReactionUpdated,
1606
- nextCtx,
1607
- );
1608
- },
1609
- pairTelegramUserIfNeeded,
204
+ const pollingRuntime = Polling.createTelegramPollingControllerRuntime<
205
+ Api.TelegramUpdate,
206
+ Pi.ExtensionContext
207
+ >({
208
+ state: pollingControllerState,
209
+ getConfig: configStore.get,
210
+ hasBotToken: configStore.hasBotToken,
211
+ deleteWebhook,
212
+ getUpdates,
213
+ persistConfig: configStore.persist,
214
+ handleUpdate: Routing.createTelegramInboundRouteRuntime<
215
+ Api.TelegramUpdate,
216
+ Api.TelegramMessage,
217
+ Api.TelegramCallbackQuery,
218
+ Pi.ExtensionContext,
219
+ ActivePiModel
220
+ >({
221
+ configStore,
222
+ bridgeRuntime,
223
+ activeTurnRuntime,
224
+ mediaGroupRuntime,
225
+ telegramQueueStore,
226
+ queueMutationRuntime,
227
+ modelMenuRuntime,
228
+ currentModelRuntime,
229
+ modelSwitchController,
230
+ menuActions,
231
+ attachmentHandlerRuntime,
232
+ updateStatus,
233
+ dispatchNextQueuedTelegramTurn,
1610
234
  answerCallbackQuery,
1611
- handleAuthorizedTelegramCallbackQuery: async (query, nextCtx) => {
1612
- await handleAuthorizedTelegramCallbackQuery(
1613
- query as TelegramCallbackQuery,
1614
- nextCtx,
1615
- );
1616
- },
1617
235
  sendTextReply,
1618
- handleAuthorizedTelegramMessage: async (message, nextCtx) => {
1619
- await handleAuthorizedTelegramMessage(
1620
- message as TelegramMessage,
1621
- nextCtx,
1622
- );
1623
- },
1624
- handleAuthorizedTelegramEditedMessage: async (message, nextCtx) => {
1625
- await handleAuthorizedTelegramEditedMessage(
1626
- message as TelegramMessage,
1627
- nextCtx,
1628
- );
1629
- },
1630
- });
1631
- }
1632
-
1633
- // --- Polling ---
1634
-
1635
- async function stopPolling(): Promise<void> {
1636
- stopTypingLoop();
1637
- pollingController?.abort();
1638
- pollingController = undefined;
1639
- await pollingPromise?.catch(() => undefined);
1640
- pollingPromise = undefined;
1641
- }
1642
-
1643
- async function pollLoop(
1644
- ctx: ExtensionContext,
1645
- signal: AbortSignal,
1646
- ): Promise<void> {
1647
- await runTelegramPollLoop<TelegramUpdate>({
1648
- ctx,
1649
- signal,
1650
- config,
1651
- deleteWebhook: async (pollSignal) => {
1652
- await callTelegramApi(
1653
- "deleteWebhook",
1654
- { drop_pending_updates: false },
1655
- { signal: pollSignal },
1656
- );
1657
- },
1658
- getUpdates: async (body, pollSignal) => {
1659
- return callTelegramApi<TelegramUpdate[]>("getUpdates", body, {
1660
- signal: pollSignal,
1661
- });
1662
- },
1663
- persistConfig: async () => {
1664
- await writeTelegramConfig(AGENT_DIR, CONFIG_PATH, config);
1665
- },
1666
- handleUpdate: async (update, loopCtx) => {
1667
- await handleUpdate(update, loopCtx);
1668
- },
1669
- onErrorStatus: (message) => {
1670
- updateStatus(ctx, message);
1671
- },
1672
- onStatusReset: () => {
1673
- updateStatus(ctx);
1674
- },
1675
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1676
- });
1677
- }
1678
-
1679
- async function startPolling(ctx: ExtensionContext): Promise<void> {
1680
- if (
1681
- !shouldStartTelegramPolling({
1682
- hasBotToken: !!config.botToken,
1683
- hasPollingPromise: !!pollingPromise,
1684
- })
1685
- ) {
1686
- return;
1687
- }
1688
- pollingController = new AbortController();
1689
- pollingPromise = pollLoop(ctx, pollingController.signal).finally(() => {
1690
- pollingPromise = undefined;
1691
- pollingController = undefined;
1692
- updateStatus(ctx);
1693
- });
1694
- updateStatus(ctx);
1695
- }
236
+ setMyCommands,
237
+ downloadFile: downloadTelegramBridgeFile,
238
+ getThinkingLevel: piRuntime.getThinkingLevel,
239
+ setThinkingLevel: piRuntime.setThinkingLevel,
240
+ setModel: piRuntime.setModel,
241
+ isIdle: Pi.isExtensionContextIdle,
242
+ hasPendingMessages: Pi.hasExtensionContextPendingMessages,
243
+ compact: Pi.compactExtensionContext,
244
+ recordRuntimeEvent: runtimeEvents.record,
245
+ }).handleUpdate,
246
+ stopTypingLoop: bridgeRuntime.typing.stop,
247
+ updateStatus,
248
+ recordRuntimeEvent: runtimeEvents.record,
249
+ });
250
+ const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
251
+ lock: lockRuntime,
252
+ hasBotToken: configStore.hasBotToken,
253
+ startPolling: pollingRuntime.start,
254
+ stopPolling: pollingRuntime.stop,
255
+ updateStatus,
256
+ recordRuntimeEvent: runtimeEvents.record,
257
+ });
258
+ const sessionLifecycleRuntime = Registration.appendTelegramLifecycleHooks(
259
+ Queue.createTelegramSessionLifecycleRuntime<
260
+ Pi.ExtensionContext,
261
+ RuntimeTelegramQueueItem,
262
+ ActivePiModel
263
+ >({
264
+ getCurrentModel: Pi.getExtensionContextModel,
265
+ loadConfig: configStore.load,
266
+ setQueuedItems: telegramQueueStore.setQueuedItems,
267
+ setCurrentModel: currentModelRuntime.set,
268
+ setPendingModelSwitch: pendingModelSwitchStore.set,
269
+ syncCounters: bridgeRuntime.queue.syncCounters,
270
+ syncFlags: bridgeRuntime.lifecycle.syncFlags,
271
+ prepareTempDir,
272
+ updateStatus,
273
+ clearPendingMediaGroups: mediaGroupRuntime.clear,
274
+ clearModelMenuState: modelMenuRuntime.clear,
275
+ getActiveTurnChatId: activeTurnRuntime.getChatId,
276
+ clearPreview: previewRuntime.clear,
277
+ clearActiveTurn: activeTurnRuntime.clear,
278
+ clearAbort: bridgeRuntime.abort.clearHandler,
279
+ stopPolling: lockedPollingRuntime.suspend,
280
+ recordRuntimeEvent: runtimeEvents.record,
281
+ }),
282
+ { onSessionStart: lockedPollingRuntime.onSessionStart },
283
+ );
1696
284
 
1697
285
  // --- Extension Registration ---
1698
286
 
1699
- registerTelegramAttachmentTool(pi, {
1700
- maxAttachmentsPerTurn: MAX_ATTACHMENTS_PER_TURN,
1701
- getActiveTurn: () => activeTelegramTurn,
1702
- statPath: stat,
287
+ Registration.registerTelegramAttachmentTool(pi, {
288
+ getActiveTurn: activeTurnRuntime.get,
289
+ recordRuntimeEvent: runtimeEvents.record,
1703
290
  });
1704
291
 
1705
- registerTelegramCommands(pi, {
1706
- promptForConfig,
1707
- getStatusLines: () => {
1708
- return [
1709
- `bot: ${config.botUsername ? `@${config.botUsername}` : "not configured"}`,
1710
- `allowed user: ${config.allowedUserId ?? "not paired"}`,
1711
- `polling: ${pollingPromise ? "running" : "stopped"}`,
1712
- `active telegram turn: ${activeTelegramTurn ? "yes" : "no"}`,
1713
- `queued telegram turns: ${queuedTelegramItems.length}`,
1714
- ];
1715
- },
1716
- reloadConfig: async () => {
1717
- config = await readTelegramConfig(CONFIG_PATH);
1718
- },
1719
- hasBotToken: () => !!config.botToken,
1720
- startPolling,
1721
- stopPolling,
292
+ Registration.registerTelegramCommands(pi, {
293
+ promptForConfig: Setup.createTelegramSetupPromptRuntime({
294
+ getConfig: configStore.get,
295
+ setConfig: configStore.set,
296
+ setupGuard: bridgeRuntime.setup,
297
+ getMe: Api.fetchTelegramBotIdentity,
298
+ persistConfig: configStore.persist,
299
+ startPolling: lockedPollingRuntime.start,
300
+ updateStatus,
301
+ recordRuntimeEvent: runtimeEvents.record,
302
+ }),
303
+ getStatusLines,
304
+ reloadConfig: configStore.load,
305
+ hasBotToken: configStore.hasBotToken,
306
+ startPolling: lockedPollingRuntime.start,
307
+ stopPolling: lockedPollingRuntime.stop,
1722
308
  updateStatus,
1723
309
  });
1724
310
 
1725
311
  // --- Lifecycle Hooks ---
1726
312
 
1727
- registerTelegramLifecycleHooks(pi, {
1728
- onSessionStart: async (_event, ctx) => {
1729
- config = await readTelegramConfig(CONFIG_PATH);
1730
- const sessionStartState = buildTelegramSessionStartState(ctx.model);
1731
- currentTelegramModel = sessionStartState.currentTelegramModel;
1732
- activeTelegramToolExecutions =
1733
- sessionStartState.activeTelegramToolExecutions;
1734
- pendingTelegramModelSwitch = sessionStartState.pendingTelegramModelSwitch;
1735
- nextQueuedTelegramItemOrder =
1736
- sessionStartState.nextQueuedTelegramItemOrder;
1737
- nextQueuedTelegramControlOrder =
1738
- sessionStartState.nextQueuedTelegramControlOrder;
1739
- telegramTurnDispatchPending =
1740
- sessionStartState.telegramTurnDispatchPending;
1741
- compactionInProgress = sessionStartState.compactionInProgress;
1742
- await mkdir(TEMP_DIR, { recursive: true });
1743
- await cleanupTelegramTempFiles(TEMP_DIR, TELEGRAM_TEMP_FILE_MAX_AGE_MS);
1744
- updateStatus(ctx);
1745
- },
1746
- onSessionShutdown: async (_event, _ctx) => {
1747
- const shutdownState =
1748
- buildTelegramSessionShutdownState<TelegramQueueItem>();
1749
- queuedTelegramItems = shutdownState.queuedTelegramItems;
1750
- nextQueuedTelegramItemOrder = shutdownState.nextQueuedTelegramItemOrder;
1751
- nextQueuedTelegramControlOrder =
1752
- shutdownState.nextQueuedTelegramControlOrder;
1753
- nextPriorityReactionOrder = shutdownState.nextPriorityReactionOrder;
1754
- currentTelegramModel = shutdownState.currentTelegramModel;
1755
- activeTelegramToolExecutions = shutdownState.activeTelegramToolExecutions;
1756
- pendingTelegramModelSwitch = shutdownState.pendingTelegramModelSwitch;
1757
- telegramTurnDispatchPending = shutdownState.telegramTurnDispatchPending;
1758
- compactionInProgress = shutdownState.compactionInProgress;
1759
- for (const state of mediaGroups.values()) {
1760
- if (state.flushTimer) clearTimeout(state.flushTimer);
1761
- }
1762
- mediaGroups.clear();
1763
- modelMenus.clear();
1764
- cachedModelMenuInputs = undefined;
1765
- if (activeTelegramTurn) {
1766
- await clearPreview(activeTelegramTurn.chatId);
1767
- }
1768
- activeTelegramTurn = undefined;
1769
- currentAbort = undefined;
1770
- preserveQueuedTurnsAsHistory = false;
1771
- await stopPolling();
1772
- },
1773
- onBeforeAgentStart: (event) => {
1774
- const nextEvent = event as { prompt: string; systemPrompt: string };
1775
- const suffix = isTelegramPrompt(nextEvent.prompt)
1776
- ? `${SYSTEM_PROMPT_SUFFIX}\n- The current user message came from Telegram.`
1777
- : SYSTEM_PROMPT_SUFFIX;
1778
- return {
1779
- systemPrompt: nextEvent.systemPrompt + suffix,
1780
- };
1781
- },
1782
- onModelSelect: (event, ctx) => {
1783
- currentTelegramModel = (event as { model: Model<any> }).model;
1784
- updateStatus(ctx);
1785
- },
1786
- onAgentStart: async (_event, ctx) => {
1787
- currentAbort = () => ctx.abort();
1788
- const startPlan = buildTelegramAgentStartPlan({
1789
- queuedItems: queuedTelegramItems,
1790
- hasPendingDispatch: telegramTurnDispatchPending,
1791
- hasActiveTurn: !!activeTelegramTurn,
1792
- });
1793
- if (startPlan.shouldResetToolExecutions) {
1794
- activeTelegramToolExecutions = 0;
1795
- }
1796
- if (startPlan.shouldResetPendingModelSwitch) {
1797
- pendingTelegramModelSwitch = undefined;
1798
- }
1799
- queuedTelegramItems = startPlan.remainingItems;
1800
- if (startPlan.shouldClearDispatchPending) {
1801
- telegramTurnDispatchPending = false;
1802
- }
1803
- if (startPlan.activeTurn) {
1804
- activeTelegramTurn = { ...startPlan.activeTurn };
1805
- previewState = createPreviewState();
1806
- startTypingLoop(ctx);
1807
- }
1808
- updateStatus(ctx);
1809
- },
1810
- onToolExecutionStart: () => {
1811
- activeTelegramToolExecutions = getNextTelegramToolExecutionCount({
1812
- hasActiveTurn: !!activeTelegramTurn,
1813
- currentCount: activeTelegramToolExecutions,
1814
- event: "start",
1815
- });
1816
- },
1817
- onToolExecutionEnd: (_event, ctx) => {
1818
- activeTelegramToolExecutions = getNextTelegramToolExecutionCount({
1819
- hasActiveTurn: !!activeTelegramTurn,
1820
- currentCount: activeTelegramToolExecutions,
1821
- event: "end",
1822
- });
1823
- if (!activeTelegramTurn) return;
1824
- triggerPendingTelegramModelSwitchAbort(ctx);
1825
- },
1826
- onMessageStart: async (event, _ctx) => {
1827
- const nextEvent = event as { message: AgentMessage };
1828
- if (!activeTelegramTurn || !isAssistantMessage(nextEvent.message)) return;
1829
- if (
1830
- previewState &&
1831
- (previewState.pendingText.trim().length > 0 ||
1832
- previewState.lastSentText.trim().length > 0)
1833
- ) {
1834
- const previousText = previewState.pendingText.trim();
1835
- if (previousText.length > 0) {
1836
- await finalizeMarkdownPreview(
1837
- activeTelegramTurn.chatId,
1838
- previousText,
1839
- );
1840
- } else {
1841
- await finalizePreview(activeTelegramTurn.chatId);
1842
- }
1843
- }
1844
- previewState = createPreviewState();
1845
- },
1846
- onMessageUpdate: async (event, _ctx) => {
1847
- const nextEvent = event as { message: AgentMessage };
1848
- if (!activeTelegramTurn || !isAssistantMessage(nextEvent.message)) return;
1849
- if (!previewState) {
1850
- previewState = createPreviewState();
1851
- }
1852
- previewState.pendingText = getMessageText(nextEvent.message);
1853
- schedulePreviewFlush(activeTelegramTurn.chatId);
1854
- },
1855
- onAgentEnd: async (event, ctx) => {
1856
- const turn = activeTelegramTurn;
1857
- currentAbort = undefined;
1858
- stopTypingLoop();
1859
- activeTelegramTurn = undefined;
1860
- activeTelegramToolExecutions = 0;
1861
- pendingTelegramModelSwitch = undefined;
1862
- telegramTurnDispatchPending = false;
1863
- updateStatus(ctx);
1864
- const assistant = turn
1865
- ? extractAssistantText((event as { messages: AgentMessage[] }).messages)
1866
- : {};
1867
- const finalText = assistant.text;
1868
- const endPlan = buildTelegramAgentEndPlan({
1869
- hasTurn: !!turn,
1870
- stopReason: assistant.stopReason,
1871
- hasFinalText: !!finalText,
1872
- hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
1873
- preserveQueuedTurnsAsHistory,
1874
- });
1875
- if (!turn) {
1876
- if (endPlan.shouldDispatchNext) {
1877
- dispatchNextQueuedTelegramTurn(ctx);
1878
- }
1879
- return;
1880
- }
1881
- if (endPlan.shouldClearPreview) {
1882
- await clearPreview(turn.chatId);
1883
- }
1884
- if (endPlan.shouldSendErrorMessage) {
1885
- await sendTextReply(
1886
- turn.chatId,
1887
- turn.replyToMessageId,
1888
- assistant.errorMessage ||
1889
- "Telegram bridge: pi failed while processing the request.",
1890
- );
1891
- if (endPlan.shouldDispatchNext) {
1892
- dispatchNextQueuedTelegramTurn(ctx);
1893
- }
1894
- return;
1895
- }
1896
- if (previewState) {
1897
- previewState.pendingText = finalText ?? previewState.pendingText;
1898
- }
1899
- if (endPlan.kind === "text" && finalText) {
1900
- const finalized = await finalizeMarkdownPreview(turn.chatId, finalText);
1901
- if (!finalized) {
1902
- await clearPreview(turn.chatId);
1903
- await sendMarkdownReply(
1904
- turn.chatId,
1905
- turn.replyToMessageId,
1906
- finalText,
1907
- );
1908
- }
1909
- }
1910
- if (endPlan.shouldSendAttachmentNotice) {
1911
- await sendTextReply(
1912
- turn.chatId,
1913
- turn.replyToMessageId,
1914
- "Attached requested file(s).",
1915
- );
1916
- }
1917
- await sendQueuedAttachments(turn);
1918
- if (endPlan.shouldDispatchNext) {
1919
- dispatchNextQueuedTelegramTurn(ctx);
1920
- }
1921
- },
313
+ Registration.registerTelegramLifecycleHooks(pi, {
314
+ ...sessionLifecycleRuntime,
315
+ onBeforeAgentStart: Registration.createTelegramBeforeAgentStartHook(),
316
+ onModelSelect: currentModelRuntime.onModelSelect,
317
+ ...Queue.createTelegramAgentLifecycleHooks<
318
+ Queue.PendingTelegramTurn,
319
+ Pi.ExtensionContext,
320
+ unknown
321
+ >({
322
+ setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(
323
+ bridgeRuntime.abort,
324
+ ),
325
+ getQueuedItems: telegramQueueStore.getQueuedItems,
326
+ hasPendingDispatch: bridgeRuntime.lifecycle.hasDispatchPending,
327
+ hasActiveTurn: activeTurnRuntime.has,
328
+ resetToolExecutions: bridgeRuntime.lifecycle.resetActiveToolExecutions,
329
+ resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
330
+ setQueuedItems: telegramQueueStore.setQueuedItems,
331
+ clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
332
+ setActiveTurn: activeTurnRuntime.set,
333
+ createPreviewState: previewRuntime.resetState,
334
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
335
+ updateStatus,
336
+ getActiveTurn: activeTurnRuntime.get,
337
+ extractAssistant: Replies.extractLatestAssistantMessageText,
338
+ getPreserveQueuedTurnsAsHistory:
339
+ bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
340
+ resetRuntimeState: Runtime.createTelegramAgentEndResetter({
341
+ abort: bridgeRuntime.abort,
342
+ typing: bridgeRuntime.typing,
343
+ clearActiveTurn: activeTurnRuntime.clear,
344
+ resetToolExecutions: bridgeRuntime.lifecycle.resetActiveToolExecutions,
345
+ clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
346
+ clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
347
+ }),
348
+ dispatchNextQueuedTelegramTurn,
349
+ clearPreview: previewRuntime.clear,
350
+ setPreviewPendingText: previewRuntime.setPendingText,
351
+ finalizeMarkdownPreview: previewRuntime.finalizeMarkdown,
352
+ sendMarkdownReply,
353
+ sendTextReply,
354
+ sendQueuedAttachments: Attachments.createTelegramQueuedAttachmentSender({
355
+ sendMultipart: callMultipart,
356
+ sendTextReply,
357
+ recordRuntimeEvent: runtimeEvents.record,
358
+ }),
359
+ getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
360
+ setActiveToolExecutions: bridgeRuntime.lifecycle.setActiveToolExecutions,
361
+ triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
362
+ }),
363
+ onMessageStart: previewRuntime.onMessageStart,
364
+ onMessageUpdate: previewRuntime.onMessageUpdate,
1922
365
  });
1923
366
  }