@llblab/pi-telegram 0.3.0 → 0.5.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.
package/index.ts CHANGED
@@ -7,20 +7,22 @@ import * as Api from "./lib/api.ts";
7
7
  import * as Attachments from "./lib/attachments.ts";
8
8
  import * as Commands from "./lib/commands.ts";
9
9
  import * as Config from "./lib/config.ts";
10
+ import * as Handlers from "./lib/handlers.ts";
11
+ import * as Lifecycle from "./lib/lifecycle.ts";
12
+ import * as Locks from "./lib/locks.ts";
10
13
  import * as Media from "./lib/media.ts";
11
14
  import * as Menu from "./lib/menu.ts";
12
15
  import * as Model from "./lib/model.ts";
13
16
  import * as Pi from "./lib/pi.ts";
14
17
  import * as Polling from "./lib/polling.ts";
15
18
  import * as Preview from "./lib/preview.ts";
19
+ import * as Prompts from "./lib/prompts.ts";
16
20
  import * as Queue from "./lib/queue.ts";
17
- import * as Registration from "./lib/registration.ts";
18
21
  import * as Replies from "./lib/replies.ts";
19
22
  import * as Runtime from "./lib/runtime.ts";
23
+ import * as Routing from "./lib/routing.ts";
20
24
  import * as Setup from "./lib/setup.ts";
21
25
  import * as Status from "./lib/status.ts";
22
- import * as Turns from "./lib/turns.ts";
23
- import * as Updates from "./lib/updates.ts";
24
26
 
25
27
  type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
26
28
  type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
@@ -31,6 +33,7 @@ export default function (pi: Pi.ExtensionAPI) {
31
33
  const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
32
34
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
33
35
  const configStore = Config.createTelegramConfigStore();
36
+ const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
34
37
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
35
38
  const pendingModelSwitchStore =
36
39
  Model.createPendingModelSwitchStore<
@@ -63,6 +66,7 @@ export default function (pi: Pi.ExtensionAPI) {
63
66
  getQueuedItems: telegramQueueStore.getQueuedItems,
64
67
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
65
68
  getRecentRuntimeEvents: runtimeEvents.getEvents,
69
+ getRuntimeLockState: lockRuntime.getStatusLabel,
66
70
  });
67
71
  const currentModelRuntime = Model.createCurrentModelRuntime<
68
72
  Pi.ExtensionContext,
@@ -80,6 +84,13 @@ export default function (pi: Pi.ExtensionAPI) {
80
84
  bridgeRuntime.queue.incrementNextPriorityReactionOrder,
81
85
  updateStatus,
82
86
  });
87
+ const attachmentHandlerRuntime =
88
+ Handlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>({
89
+ getHandlers: configStore.getAttachmentHandlers,
90
+ execCommand: piRuntime.exec,
91
+ getCwd: Pi.getExtensionContextCwd,
92
+ recordRuntimeEvent: runtimeEvents.record,
93
+ });
83
94
 
84
95
  // --- Telegram API ---
85
96
 
@@ -202,158 +213,52 @@ export default function (pi: Pi.ExtensionAPI) {
202
213
  deleteWebhook,
203
214
  getUpdates,
204
215
  persistConfig: configStore.persist,
205
- handleUpdate: Updates.createTelegramPairedUpdateRuntime<
216
+ handleUpdate: Routing.createTelegramInboundRouteRuntime<
217
+ Api.TelegramUpdate,
218
+ Api.TelegramMessage,
219
+ Api.TelegramCallbackQuery,
206
220
  Pi.ExtensionContext,
207
- Api.TelegramUpdate
221
+ ActivePiModel
208
222
  >({
209
- getAllowedUserId: configStore.getAllowedUserId,
210
- setAllowedUserId: configStore.setAllowedUserId,
211
- persistConfig: configStore.persist,
223
+ configStore,
224
+ bridgeRuntime,
225
+ activeTurnRuntime,
226
+ mediaGroupRuntime,
227
+ telegramQueueStore,
228
+ queueMutationRuntime,
229
+ modelMenuRuntime,
230
+ currentModelRuntime,
231
+ modelSwitchController,
232
+ menuActions,
233
+ attachmentHandlerRuntime,
212
234
  updateStatus,
213
- removePendingMediaGroupMessages: mediaGroupRuntime.removeMessages,
214
- removeQueuedTelegramTurnsByMessageIds:
215
- queueMutationRuntime.removeByMessageIds,
216
- clearQueuedTelegramTurnPriorityByMessageId:
217
- queueMutationRuntime.clearPriorityByMessageId,
218
- prioritizeQueuedTelegramTurnByMessageId:
219
- queueMutationRuntime.prioritizeByMessageId,
235
+ dispatchNextQueuedTelegramTurn,
220
236
  answerCallbackQuery,
221
- handleAuthorizedTelegramCallbackQuery:
222
- Menu.createTelegramMenuCallbackHandlerForContext<
223
- Api.TelegramCallbackQuery,
224
- Pi.ExtensionContext,
225
- ActivePiModel
226
- >({
227
- getStoredModelMenuState: modelMenuRuntime.getState,
228
- getActiveModel: currentModelRuntime.get,
229
- getThinkingLevel: piRuntime.getThinkingLevel,
230
- setThinkingLevel: piRuntime.setThinkingLevel,
231
- updateStatus,
232
- updateModelMenuMessage: menuActions.updateModelMenuMessage,
233
- updateThinkingMenuMessage: menuActions.updateThinkingMenuMessage,
234
- updateStatusMessage: menuActions.updateStatusMessage,
235
- answerCallbackQuery,
236
- isIdle: Pi.isExtensionContextIdle,
237
- hasActiveTelegramTurn: activeTurnRuntime.has,
238
- hasAbortHandler: bridgeRuntime.abort.hasHandler,
239
- getActiveToolExecutions:
240
- bridgeRuntime.lifecycle.getActiveToolExecutions,
241
- setModel: piRuntime.setModel,
242
- setCurrentModel: currentModelRuntime.setCurrentModel,
243
- stagePendingModelSwitch: modelSwitchController.stagePendingSwitch,
244
- restartInterruptedTelegramTurn:
245
- modelSwitchController.restartInterruptedTurn,
246
- }),
247
237
  sendTextReply,
248
- handleAuthorizedTelegramMessage:
249
- Media.createTelegramMediaGroupDispatchRuntime<
250
- Api.TelegramMessage,
251
- Pi.ExtensionContext
252
- >({
253
- mediaGroups: mediaGroupRuntime,
254
- dispatchMessages: Commands.createTelegramCommandOrPromptRuntime<
255
- Api.TelegramMessage,
256
- Pi.ExtensionContext
257
- >({
258
- extractRawText: Media.extractFirstTelegramMessageText,
259
- handleCommand: Commands.createTelegramCommandHandlerTargetRuntime<
260
- Api.TelegramMessage,
261
- Pi.ExtensionContext
262
- >({
263
- hasAbortHandler: bridgeRuntime.abort.hasHandler,
264
- clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
265
- hasQueuedTelegramItems: telegramQueueStore.hasQueuedItems,
266
- setPreserveQueuedTurnsAsHistory:
267
- bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
268
- abortCurrentTurn: bridgeRuntime.abort.abortTurn,
269
- isIdle: Pi.isExtensionContextIdle,
270
- hasPendingMessages: Pi.hasExtensionContextPendingMessages,
271
- hasActiveTelegramTurn: activeTurnRuntime.has,
272
- hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
273
- isCompactionInProgress:
274
- bridgeRuntime.lifecycle.isCompactionInProgress,
275
- setCompactionInProgress:
276
- bridgeRuntime.lifecycle.setCompactionInProgress,
277
- updateStatus,
278
- dispatchNextQueuedTelegramTurn,
279
- compact: Pi.compactExtensionContext,
280
- allocateItemOrder: bridgeRuntime.queue.allocateItemOrder,
281
- allocateControlOrder: bridgeRuntime.queue.allocateControlOrder,
282
- appendControlItem: queueMutationRuntime.append,
283
- showStatus: menuActions.sendStatusMessage,
284
- openModelMenu: menuActions.openModelMenu,
285
- getAllowedUserId: configStore.getAllowedUserId,
286
- setAllowedUserId: configStore.setAllowedUserId,
287
- setMyCommands,
288
- persistConfig: configStore.persist,
289
- sendTextReply,
290
- recordRuntimeEvent: runtimeEvents.record,
291
- }),
292
- enqueueTurn: Queue.createTelegramPromptEnqueueController<
293
- Api.TelegramMessage,
294
- Pi.ExtensionContext
295
- >({
296
- ...telegramQueueStore,
297
- getPreserveQueuedTurnsAsHistory:
298
- bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
299
- setPreserveQueuedTurnsAsHistory:
300
- bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
301
- createTurn:
302
- Turns.createTelegramPromptTurnRuntimeBuilder<Api.TelegramMessage>(
303
- {
304
- allocateQueueOrder: bridgeRuntime.queue.allocateItemOrder,
305
- downloadFile: downloadTelegramBridgeFile,
306
- },
307
- ),
308
- updateStatus,
309
- dispatchNextQueuedTelegramTurn,
310
- }).enqueue,
311
- }).dispatchMessages,
312
- }).handleMessage,
313
- handleAuthorizedTelegramEditedMessage:
314
- Turns.createTelegramQueuedPromptEditRuntime<
315
- Api.TelegramMessage,
316
- Pi.ExtensionContext
317
- >({
318
- ...telegramQueueStore,
319
- updateStatus,
320
- }).updateFromEditedMessage,
238
+ setMyCommands,
239
+ downloadFile: downloadTelegramBridgeFile,
240
+ getThinkingLevel: piRuntime.getThinkingLevel,
241
+ setThinkingLevel: piRuntime.setThinkingLevel,
242
+ setModel: piRuntime.setModel,
243
+ isIdle: Pi.isExtensionContextIdle,
244
+ hasPendingMessages: Pi.hasExtensionContextPendingMessages,
245
+ compact: Pi.compactExtensionContext,
246
+ recordRuntimeEvent: runtimeEvents.record,
321
247
  }).handleUpdate,
322
248
  stopTypingLoop: bridgeRuntime.typing.stop,
323
249
  updateStatus,
324
250
  recordRuntimeEvent: runtimeEvents.record,
325
251
  });
326
-
327
- // --- Extension Registration ---
328
-
329
- Registration.registerTelegramAttachmentTool(pi, {
330
- getActiveTurn: activeTurnRuntime.get,
331
- recordRuntimeEvent: runtimeEvents.record,
332
- });
333
-
334
- Registration.registerTelegramCommands(pi, {
335
- promptForConfig: Setup.createTelegramSetupPromptRuntime({
336
- getConfig: configStore.get,
337
- setConfig: configStore.set,
338
- setupGuard: bridgeRuntime.setup,
339
- getMe: Api.fetchTelegramBotIdentity,
340
- persistConfig: configStore.persist,
341
- startPolling: pollingRuntime.start,
342
- updateStatus,
343
- recordRuntimeEvent: runtimeEvents.record,
344
- }),
345
- getStatusLines,
346
- reloadConfig: configStore.load,
252
+ const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
253
+ lock: lockRuntime,
347
254
  hasBotToken: configStore.hasBotToken,
348
255
  startPolling: pollingRuntime.start,
349
256
  stopPolling: pollingRuntime.stop,
350
257
  updateStatus,
258
+ recordRuntimeEvent: runtimeEvents.record,
351
259
  });
352
-
353
- // --- Lifecycle Hooks ---
354
-
355
- Registration.registerTelegramLifecycleHooks(pi, {
356
- ...Queue.createTelegramSessionLifecycleRuntime<
260
+ const sessionLifecycleRuntime = Lifecycle.appendTelegramLifecycleHooks(
261
+ Queue.createTelegramSessionLifecycleRuntime<
357
262
  Pi.ExtensionContext,
358
263
  RuntimeTelegramQueueItem,
359
264
  ActivePiModel
@@ -373,10 +278,43 @@ export default function (pi: Pi.ExtensionAPI) {
373
278
  clearPreview: previewRuntime.clear,
374
279
  clearActiveTurn: activeTurnRuntime.clear,
375
280
  clearAbort: bridgeRuntime.abort.clearHandler,
376
- stopPolling: pollingRuntime.stop,
281
+ stopPolling: lockedPollingRuntime.suspend,
282
+ recordRuntimeEvent: runtimeEvents.record,
283
+ }),
284
+ { onSessionStart: lockedPollingRuntime.onSessionStart },
285
+ );
286
+
287
+ // --- Extension API Bindings ---
288
+
289
+ Attachments.registerTelegramAttachmentTool(pi, {
290
+ getActiveTurn: activeTurnRuntime.get,
291
+ recordRuntimeEvent: runtimeEvents.record,
292
+ });
293
+
294
+ Commands.registerTelegramBridgeCommands(pi, {
295
+ promptForConfig: Setup.createTelegramSetupPromptRuntime({
296
+ getConfig: configStore.get,
297
+ setConfig: configStore.set,
298
+ setupGuard: bridgeRuntime.setup,
299
+ getMe: Api.fetchTelegramBotIdentity,
300
+ persistConfig: configStore.persist,
301
+ startPolling: lockedPollingRuntime.start,
302
+ updateStatus,
377
303
  recordRuntimeEvent: runtimeEvents.record,
378
304
  }),
379
- onBeforeAgentStart: Registration.createTelegramBeforeAgentStartHook(),
305
+ getStatusLines,
306
+ reloadConfig: configStore.load,
307
+ hasBotToken: configStore.hasBotToken,
308
+ startPolling: lockedPollingRuntime.start,
309
+ stopPolling: lockedPollingRuntime.stop,
310
+ updateStatus,
311
+ });
312
+
313
+ // --- Lifecycle Hooks ---
314
+
315
+ Lifecycle.registerTelegramLifecycleHooks(pi, {
316
+ ...sessionLifecycleRuntime,
317
+ onBeforeAgentStart: Prompts.createTelegramBeforeAgentStartHook(),
380
318
  onModelSelect: currentModelRuntime.onModelSelect,
381
319
  ...Queue.createTelegramAgentLifecycleHooks<
382
320
  Queue.PendingTelegramTurn,
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * Telegram attachment domain helpers
3
- * Owns attachment queueing and attachment delivery so Telegram file output stays in one domain module
3
+ * Owns telegram_attach registration, attachment queueing, and attachment delivery so Telegram file output stays in one domain module
4
4
  */
5
5
 
6
6
  import { stat } from "node:fs/promises";
7
7
  import { basename } from "node:path";
8
8
 
9
+ import { Type } from "@sinclair/typebox";
10
+
11
+ import type { ExtensionAPI } from "./pi.ts";
9
12
  import { buildTelegramMultipartReplyParameters } from "./replies.ts";
10
13
 
14
+ const MAX_ATTACHMENTS_PER_TURN = 10;
15
+
11
16
  export const TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
12
17
 
13
18
  export function getTelegramAttachmentByteLimitFromEnv(
@@ -35,6 +40,21 @@ export interface TelegramAttachmentToolResult {
35
40
  details: { paths: string[] };
36
41
  }
37
42
 
43
+ export interface TelegramAttachmentRuntimeEventRecorderPort {
44
+ recordRuntimeEvent?: (
45
+ category: string,
46
+ error: unknown,
47
+ details?: Record<string, unknown>,
48
+ ) => void;
49
+ }
50
+
51
+ export interface TelegramAttachmentToolRegistrationDeps extends TelegramAttachmentRuntimeEventRecorderPort {
52
+ maxAttachmentsPerTurn?: number;
53
+ maxAttachmentSizeBytes?: number;
54
+ getActiveTurn: () => TelegramAttachmentQueueTargetView | undefined;
55
+ statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
56
+ }
57
+
38
58
  export interface TelegramQueuedAttachmentView {
39
59
  path: string;
40
60
  fileName: string;
@@ -69,6 +89,54 @@ function formatTelegramAttachmentSizeLimitError(
69
89
  return path ? `${message}: ${path}` : message;
70
90
  }
71
91
 
92
+ function formatTelegramAttachmentToolResultText(count: number): string {
93
+ // Pi's compact tool rows need an empty first line to visually separate header and result
94
+ return ["", `Queued ${count} Telegram attachment(s).`].join("\n");
95
+ }
96
+
97
+ export function registerTelegramAttachmentTool(
98
+ pi: ExtensionAPI,
99
+ deps: TelegramAttachmentToolRegistrationDeps,
100
+ ): void {
101
+ const maxAttachmentsPerTurn =
102
+ deps.maxAttachmentsPerTurn ?? MAX_ATTACHMENTS_PER_TURN;
103
+ const maxAttachmentSizeBytes =
104
+ deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES;
105
+ pi.registerTool({
106
+ name: "telegram_attach",
107
+ label: "Telegram Attach",
108
+ description:
109
+ "Queue one or more local files to be sent with the next Telegram reply.",
110
+ promptSnippet: "Queue local files to be sent with the next Telegram reply.",
111
+ promptGuidelines: [
112
+ "When handling a [telegram] message and the user asked for a file or generated artifact, call telegram_attach with the local path instead of only mentioning the path in text.",
113
+ ],
114
+ parameters: Type.Object({
115
+ paths: Type.Array(
116
+ Type.String({ description: "Local file path to attach" }),
117
+ { minItems: 1, maxItems: maxAttachmentsPerTurn },
118
+ ),
119
+ }),
120
+ async execute(_toolCallId, params) {
121
+ try {
122
+ return await queueTelegramAttachments({
123
+ activeTurn: deps.getActiveTurn(),
124
+ paths: params.paths,
125
+ maxAttachmentsPerTurn,
126
+ maxAttachmentSizeBytes,
127
+ statPath: deps.statPath,
128
+ });
129
+ } catch (error) {
130
+ deps.recordRuntimeEvent?.("attachment", error, {
131
+ phase: "queue",
132
+ count: params.paths.length,
133
+ });
134
+ throw error;
135
+ }
136
+ },
137
+ });
138
+ }
139
+
72
140
  export interface TelegramQueuedAttachmentDeliveryDeps {
73
141
  sendMultipart: (
74
142
  method: string,
@@ -141,7 +209,7 @@ export async function queueTelegramAttachments(options: {
141
209
  content: [
142
210
  {
143
211
  type: "text",
144
- text: `Queued ${added.length} Telegram attachment(s).`,
212
+ text: formatTelegramAttachmentToolResultText(added.length),
145
213
  },
146
214
  ],
147
215
  details: { paths: added },
package/lib/commands.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Telegram command routing helpers
3
- * Owns slash-command normalization and command side-effect branching behind runtime ports
3
+ * Owns Telegram slash-command normalization, bot command metadata, and pi-side command registration behind runtime ports
4
4
  */
5
5
 
6
6
  import { pairTelegramUserIfNeeded } from "./config.ts";
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
7
8
  import {
8
9
  createTelegramControlItemBuilder,
9
10
  createTelegramControlQueueController,
@@ -52,22 +53,115 @@ export function createTelegramBotCommandRegistrar(
52
53
  return () => registerTelegramBotCommands(deps);
53
54
  }
54
55
 
56
+ export interface TelegramBridgeCommandStartPollingOptions {
57
+ force?: boolean;
58
+ }
59
+
60
+ export interface TelegramBridgeCommandStartPollingResult {
61
+ ok: boolean;
62
+ message?: string;
63
+ canTakeover?: boolean;
64
+ owner?: string;
65
+ }
66
+
67
+ export interface TelegramBridgeCommandRegistrationDeps {
68
+ promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
69
+ getStatusLines: () => string[];
70
+ reloadConfig: () => Promise<void>;
71
+ hasBotToken: () => boolean;
72
+ startPolling: (
73
+ ctx: ExtensionCommandContext,
74
+ options?: TelegramBridgeCommandStartPollingOptions,
75
+ ) =>
76
+ | void
77
+ | Promise<void | TelegramBridgeCommandStartPollingResult>
78
+ | TelegramBridgeCommandStartPollingResult;
79
+ stopPolling: () => Promise<void | string>;
80
+ updateStatus: (ctx: ExtensionCommandContext) => void;
81
+ }
82
+
83
+ function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
84
+ return ctx.ui.theme.fg("accent", "pi-telegram");
85
+ }
86
+
87
+ function formatTelegramTakeoverPrompt(
88
+ ctx: ExtensionCommandContext,
89
+ owner?: string,
90
+ ): string {
91
+ const theme = ctx.ui.theme;
92
+ const action = theme.fg("warning", "move singleton lock here?");
93
+ const from = theme.fg("muted", "from:");
94
+ const to = theme.fg("muted", "to:");
95
+ const source = owner ?? "another pi instance";
96
+ return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
97
+ }
98
+
99
+ export function registerTelegramBridgeCommands(
100
+ pi: ExtensionAPI,
101
+ deps: TelegramBridgeCommandRegistrationDeps,
102
+ ): void {
103
+ pi.registerCommand("telegram-setup", {
104
+ description: "Configure Telegram bot token",
105
+ handler: async (_args, ctx) => {
106
+ await deps.promptForConfig(ctx);
107
+ },
108
+ });
109
+ pi.registerCommand("telegram-status", {
110
+ description: "Show Telegram bridge status",
111
+ handler: async (_args, ctx) => {
112
+ ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
113
+ },
114
+ });
115
+ pi.registerCommand("telegram-connect", {
116
+ description: "Start the Telegram bridge in this pi session",
117
+ handler: async (_args, ctx) => {
118
+ await deps.reloadConfig();
119
+ if (!deps.hasBotToken()) {
120
+ await deps.promptForConfig(ctx);
121
+ return;
122
+ }
123
+ let result = await deps.startPolling(ctx);
124
+ if (result && !result.ok && result.canTakeover) {
125
+ const confirmed = await ctx.ui.confirm(
126
+ formatTelegramTakeoverTitle(ctx),
127
+ formatTelegramTakeoverPrompt(ctx, result.owner),
128
+ );
129
+ if (!confirmed) {
130
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
131
+ deps.updateStatus(ctx);
132
+ return;
133
+ }
134
+ result = await deps.startPolling(ctx, { force: true });
135
+ }
136
+ if (result?.message) {
137
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
138
+ }
139
+ deps.updateStatus(ctx);
140
+ },
141
+ });
142
+ pi.registerCommand("telegram-disconnect", {
143
+ description: "Stop the Telegram bridge in this pi session",
144
+ handler: async (_args, ctx) => {
145
+ const message = await deps.stopPolling();
146
+ if (message) ctx.ui.notify(message, "info");
147
+ deps.updateStatus(ctx);
148
+ },
149
+ });
150
+ }
151
+
55
152
  export type TelegramCommandAction =
56
153
  | { kind: "ignore"; executionMode: "ignored" }
57
154
  | { kind: "stop"; executionMode: "immediate" }
58
155
  | { kind: "compact"; executionMode: "immediate" }
59
- | { kind: "status"; executionMode: "control-queue" }
60
- | { kind: "model"; executionMode: "control-queue" }
156
+ | { kind: "status"; executionMode: "immediate" }
157
+ | { kind: "model"; executionMode: "immediate" }
61
158
  | {
62
159
  kind: "help";
63
160
  commandName: "help" | "start";
64
161
  executionMode: "immediate";
65
162
  };
66
163
 
67
- export type TelegramCommandExecutionMode =
68
- | "ignored"
69
- | "immediate"
70
- | "control-queue";
164
+ export type TelegramCommandExecutionMode = "ignored" | "immediate";
71
165
 
72
166
  export interface TelegramCommandActionDeps<TMessage, TContext> {
73
167
  handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
@@ -99,8 +193,7 @@ export interface TelegramRuntimeEventRecorderPort {
99
193
  ) => void;
100
194
  }
101
195
 
102
- export interface TelegramCompactCommandDeps
103
- extends TelegramRuntimeEventRecorderPort {
196
+ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorderPort {
104
197
  isIdle: () => boolean;
105
198
  hasPendingMessages: () => boolean;
106
199
  hasActiveTelegramTurn: () => boolean;
@@ -130,14 +223,6 @@ export interface TelegramHelpCommandDeps {
130
223
  export type TelegramControlCommandType =
131
224
  PendingTelegramControlItem<unknown>["controlType"];
132
225
 
133
- export interface TelegramQueuedControlCommandDeps<TContext> {
134
- enqueueControlItem: (
135
- controlType: TelegramControlCommandType,
136
- statusSummary: string,
137
- execute: (ctx: TContext) => Promise<void>,
138
- ) => void;
139
- }
140
-
141
226
  export interface TelegramCommandRuntimeMessage {
142
227
  chat: { id: number };
143
228
  message_id: number;
@@ -380,9 +465,9 @@ export function buildTelegramCommandAction(
380
465
  case "compact":
381
466
  return { kind: "compact", executionMode: "immediate" };
382
467
  case "status":
383
- return { kind: "status", executionMode: "control-queue" };
468
+ return { kind: "status", executionMode: "immediate" };
384
469
  case "model":
385
- return { kind: "model", executionMode: "control-queue" };
470
+ return { kind: "model", executionMode: "immediate" };
386
471
  case "help":
387
472
  case "start":
388
473
  return { kind: "help", commandName, executionMode: "immediate" };
@@ -483,20 +568,18 @@ export async function handleTelegramHelpCommand(
483
568
  });
484
569
  }
485
570
 
486
- export async function handleTelegramStatusCommand<TContext>(
487
- deps: TelegramQueuedControlCommandDeps<TContext> & {
488
- showStatus: (ctx: TContext) => Promise<void>;
489
- },
490
- ): Promise<void> {
491
- deps.enqueueControlItem("status", "⚡ status", deps.showStatus);
571
+ export async function handleTelegramStatusCommand<TContext>(deps: {
572
+ ctx: TContext;
573
+ showStatus: (ctx: TContext) => Promise<void>;
574
+ }): Promise<void> {
575
+ await deps.showStatus(deps.ctx);
492
576
  }
493
577
 
494
- export async function handleTelegramModelCommand<TContext>(
495
- deps: TelegramQueuedControlCommandDeps<TContext> & {
496
- openModelMenu: (ctx: TContext) => Promise<void>;
497
- },
498
- ): Promise<void> {
499
- deps.enqueueControlItem("model", "⚡ model", deps.openModelMenu);
578
+ export async function handleTelegramModelCommand<TContext>(deps: {
579
+ ctx: TContext;
580
+ openModelMenu: (ctx: TContext) => Promise<void>;
581
+ }): Promise<void> {
582
+ await deps.openModelMenu(deps.ctx);
500
583
  }
501
584
 
502
585
  export async function executeTelegramCommandAction<TMessage, TContext>(
@@ -644,21 +727,6 @@ async function handleTelegramCommandRuntime<
644
727
  deps.sendTextReply(nextMessage, text);
645
728
  const updateStatusFor = (commandCtx: TContext) => () =>
646
729
  deps.updateStatus(commandCtx);
647
- const enqueueControlFor =
648
- (nextMessage: TMessage, commandCtx: TContext) =>
649
- (
650
- controlType: TelegramControlCommandType,
651
- statusSummary: string,
652
- execute: (ctx: TContext) => Promise<void>,
653
- ) => {
654
- deps.enqueueControlItem(
655
- nextMessage,
656
- commandCtx,
657
- controlType,
658
- statusSummary,
659
- execute,
660
- );
661
- };
662
730
  return executeTelegramCommandAction(
663
731
  buildTelegramCommandAction(commandName),
664
732
  message,
@@ -694,13 +762,13 @@ async function handleTelegramCommandRuntime<
694
762
  },
695
763
  handleStatus: async (nextMessage, commandCtx) => {
696
764
  await handleTelegramStatusCommand<TContext>({
697
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
765
+ ctx: commandCtx,
698
766
  showStatus: (controlCtx) => deps.showStatus(nextMessage, controlCtx),
699
767
  });
700
768
  },
701
769
  handleModel: async (nextMessage, commandCtx) => {
702
770
  await handleTelegramModelCommand<TContext>({
703
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
771
+ ctx: commandCtx,
704
772
  openModelMenu: (controlCtx) =>
705
773
  deps.openModelMenu(nextMessage, controlCtx),
706
774
  });
package/lib/config.ts CHANGED
@@ -5,10 +5,19 @@
5
5
 
6
6
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
- import { join } from "node:path";
8
+ import { join, resolve } from "node:path";
9
9
 
10
- const AGENT_DIR = join(homedir(), ".pi", "agent");
11
- const CONFIG_PATH = join(AGENT_DIR, "telegram.json");
10
+ import type { TelegramAttachmentHandlerConfig } from "./handlers.ts";
11
+
12
+ function getAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getConfigPath(): string {
19
+ return join(getAgentDir(), "telegram.json");
20
+ }
12
21
 
13
22
  export interface TelegramConfig {
14
23
  botToken?: string;
@@ -16,6 +25,7 @@ export interface TelegramConfig {
16
25
  botId?: number;
17
26
  allowedUserId?: number;
18
27
  lastUpdateId?: number;
28
+ attachmentHandlers?: TelegramAttachmentHandlerConfig[];
19
29
  }
20
30
 
21
31
  export interface TelegramConfigStore {
@@ -25,6 +35,7 @@ export interface TelegramConfigStore {
25
35
  getBotToken: () => string | undefined;
26
36
  hasBotToken: () => boolean;
27
37
  getAllowedUserId: () => number | undefined;
38
+ getAttachmentHandlers: () => TelegramAttachmentHandlerConfig[] | undefined;
28
39
  setAllowedUserId: (userId: number) => void;
29
40
  load: () => Promise<void>;
30
41
  persist: (config?: TelegramConfig) => Promise<void>;
@@ -64,8 +75,8 @@ export function createTelegramConfigStore(
64
75
  options: TelegramConfigStoreOptions = {},
65
76
  ): TelegramConfigStore {
66
77
  let config: TelegramConfig = options.initialConfig ?? {};
67
- const agentDir = options.agentDir ?? AGENT_DIR;
68
- const configPath = options.configPath ?? CONFIG_PATH;
78
+ const agentDir = options.agentDir ?? getAgentDir();
79
+ const configPath = options.configPath ?? getConfigPath();
69
80
  return {
70
81
  get: () => config,
71
82
  set: (nextConfig) => {
@@ -77,6 +88,7 @@ export function createTelegramConfigStore(
77
88
  getBotToken: () => config.botToken,
78
89
  hasBotToken: () => !!config.botToken,
79
90
  getAllowedUserId: () => config.allowedUserId,
91
+ getAttachmentHandlers: () => config.attachmentHandlers,
80
92
  setAllowedUserId: (userId) => {
81
93
  config.allowedUserId = userId;
82
94
  },