@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/lib/routing.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Telegram inbound routing composition
3
+ * Wires authorized updates into menus, commands, media grouping, and prompt queueing
4
+ */
5
+
6
+ import * as Commands from "./commands.ts";
7
+ import type { TelegramConfigStore } from "./config.ts";
8
+ import type { TelegramAttachmentHandlerRuntime } from "./handlers.ts";
9
+ import * as Media from "./media.ts";
10
+ import * as Menu from "./menu.ts";
11
+ import * as Model from "./model.ts";
12
+ import * as Queue from "./queue.ts";
13
+ import type { TelegramBridgeRuntime } from "./runtime.ts";
14
+ import * as Turns from "./turns.ts";
15
+ import * as Updates from "./updates.ts";
16
+
17
+ export type TelegramRoutedMessage = Updates.TelegramUpdateMessage &
18
+ Media.TelegramMediaMessage &
19
+ Media.TelegramMediaGroupMessage &
20
+ Commands.TelegramCommandRuntimeMessage &
21
+ Turns.TelegramTurnMessage;
22
+
23
+ export type TelegramRoutedCallbackQuery = Updates.TelegramCallbackQuery &
24
+ Menu.MenuCallbackQuery;
25
+
26
+ export interface TelegramInboundRouteRuntimeDeps<
27
+ TUpdate extends Updates.TelegramUpdateFlow & {
28
+ message?: TMessage;
29
+ edited_message?: TMessage;
30
+ callback_query?: TCallbackQuery;
31
+ },
32
+ TMessage extends TelegramRoutedMessage,
33
+ TCallbackQuery extends TelegramRoutedCallbackQuery,
34
+ TContext,
35
+ TModel extends Model.MenuModel,
36
+ > {
37
+ configStore: Pick<
38
+ TelegramConfigStore,
39
+ "getAllowedUserId" | "setAllowedUserId" | "persist"
40
+ >;
41
+ bridgeRuntime: TelegramBridgeRuntime;
42
+ activeTurnRuntime: Queue.TelegramActiveTurnStore;
43
+ mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage>;
44
+ telegramQueueStore: Queue.TelegramQueueStateStore<TContext>;
45
+ queueMutationRuntime: Queue.TelegramQueueMutationController<TContext>;
46
+ modelMenuRuntime: Menu.TelegramModelMenuRuntime<TModel>;
47
+ currentModelRuntime: Model.CurrentModelRuntime<TContext, TModel>;
48
+ modelSwitchController: Model.TelegramModelSwitchController<
49
+ TContext,
50
+ Model.ScopedTelegramModel<TModel>
51
+ >;
52
+ menuActions: Menu.TelegramMenuActionRuntime<TContext, TModel>;
53
+ attachmentHandlerRuntime: TelegramAttachmentHandlerRuntime<TContext>;
54
+ updateStatus: (ctx: TContext, error?: string) => void;
55
+ dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
56
+ answerCallbackQuery: (
57
+ callbackQueryId: string,
58
+ text?: string,
59
+ ) => Promise<void>;
60
+ sendTextReply: (
61
+ chatId: number,
62
+ replyToMessageId: number,
63
+ text: string,
64
+ ) => Promise<number | undefined>;
65
+ setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"];
66
+ downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
67
+ getThinkingLevel: () => Model.ThinkingLevel;
68
+ setThinkingLevel: (level: Model.ThinkingLevel) => void;
69
+ setModel: (model: TModel) => Promise<boolean>;
70
+ isIdle: (ctx: TContext) => boolean;
71
+ hasPendingMessages: (ctx: TContext) => boolean;
72
+ compact: (
73
+ ctx: TContext,
74
+ callbacks: { onComplete: () => void; onError: (error: unknown) => void },
75
+ ) => void;
76
+ recordRuntimeEvent?: (
77
+ category: string,
78
+ error: unknown,
79
+ details?: Record<string, unknown>,
80
+ ) => void;
81
+ }
82
+
83
+ export function createTelegramInboundRouteRuntime<
84
+ TUpdate extends Updates.TelegramUpdateFlow & {
85
+ message?: TMessage;
86
+ edited_message?: TMessage;
87
+ callback_query?: TCallbackQuery;
88
+ },
89
+ TMessage extends TelegramRoutedMessage,
90
+ TCallbackQuery extends TelegramRoutedCallbackQuery,
91
+ TContext,
92
+ TModel extends Model.MenuModel,
93
+ >(
94
+ deps: TelegramInboundRouteRuntimeDeps<
95
+ TUpdate,
96
+ TMessage,
97
+ TCallbackQuery,
98
+ TContext,
99
+ TModel
100
+ >,
101
+ ): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
102
+ const callbackHandler = Menu.createTelegramMenuCallbackHandlerForContext<
103
+ TCallbackQuery,
104
+ TContext,
105
+ TModel
106
+ >({
107
+ getStoredModelMenuState: deps.modelMenuRuntime.getState,
108
+ getActiveModel: deps.currentModelRuntime.get,
109
+ getThinkingLevel: deps.getThinkingLevel,
110
+ setThinkingLevel: deps.setThinkingLevel,
111
+ updateStatus: deps.updateStatus,
112
+ updateModelMenuMessage: deps.menuActions.updateModelMenuMessage,
113
+ updateThinkingMenuMessage: deps.menuActions.updateThinkingMenuMessage,
114
+ updateStatusMessage: deps.menuActions.updateStatusMessage,
115
+ answerCallbackQuery: deps.answerCallbackQuery,
116
+ isIdle: deps.isIdle,
117
+ hasActiveTelegramTurn: deps.activeTurnRuntime.has,
118
+ hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
119
+ getActiveToolExecutions:
120
+ deps.bridgeRuntime.lifecycle.getActiveToolExecutions,
121
+ setModel: deps.setModel,
122
+ setCurrentModel: deps.currentModelRuntime.setCurrentModel,
123
+ stagePendingModelSwitch: deps.modelSwitchController.stagePendingSwitch,
124
+ restartInterruptedTelegramTurn:
125
+ deps.modelSwitchController.restartInterruptedTurn,
126
+ });
127
+ const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime<
128
+ TMessage,
129
+ TContext
130
+ >({
131
+ hasAbortHandler: deps.bridgeRuntime.abort.hasHandler,
132
+ clearPendingModelSwitch: deps.modelSwitchController.clearPendingSwitch,
133
+ hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems,
134
+ setPreserveQueuedTurnsAsHistory:
135
+ deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
136
+ abortCurrentTurn: deps.bridgeRuntime.abort.abortTurn,
137
+ isIdle: deps.isIdle,
138
+ hasPendingMessages: deps.hasPendingMessages,
139
+ hasActiveTelegramTurn: deps.activeTurnRuntime.has,
140
+ hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending,
141
+ isCompactionInProgress: deps.bridgeRuntime.lifecycle.isCompactionInProgress,
142
+ setCompactionInProgress:
143
+ deps.bridgeRuntime.lifecycle.setCompactionInProgress,
144
+ updateStatus: deps.updateStatus,
145
+ dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
146
+ compact: deps.compact,
147
+ allocateItemOrder: deps.bridgeRuntime.queue.allocateItemOrder,
148
+ allocateControlOrder: deps.bridgeRuntime.queue.allocateControlOrder,
149
+ appendControlItem: deps.queueMutationRuntime.append,
150
+ showStatus: deps.menuActions.sendStatusMessage,
151
+ openModelMenu: deps.menuActions.openModelMenu,
152
+ getAllowedUserId: deps.configStore.getAllowedUserId,
153
+ setAllowedUserId: deps.configStore.setAllowedUserId,
154
+ setMyCommands: deps.setMyCommands,
155
+ persistConfig: deps.configStore.persist,
156
+ sendTextReply: deps.sendTextReply,
157
+ recordRuntimeEvent: deps.recordRuntimeEvent,
158
+ });
159
+ const promptEnqueue = Queue.createTelegramPromptEnqueueController<
160
+ TMessage,
161
+ TContext
162
+ >({
163
+ ...deps.telegramQueueStore,
164
+ getPreserveQueuedTurnsAsHistory:
165
+ deps.bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
166
+ setPreserveQueuedTurnsAsHistory:
167
+ deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory,
168
+ createTurn: Turns.createTelegramPromptTurnRuntimeBuilder<
169
+ TMessage,
170
+ TContext
171
+ >({
172
+ allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
173
+ downloadFile: deps.downloadFile,
174
+ processAttachments: deps.attachmentHandlerRuntime.process,
175
+ }),
176
+ updateStatus: deps.updateStatus,
177
+ dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
178
+ }).enqueue;
179
+ const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime<
180
+ TMessage,
181
+ TContext
182
+ >({
183
+ extractRawText: Media.extractFirstTelegramMessageText,
184
+ handleCommand: commandHandler,
185
+ enqueueTurn: promptEnqueue,
186
+ });
187
+ const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime<
188
+ TMessage,
189
+ TContext
190
+ >({
191
+ mediaGroups: deps.mediaGroupRuntime,
192
+ dispatchMessages: commandOrPrompt.dispatchMessages,
193
+ });
194
+ const editRuntime = Turns.createTelegramQueuedPromptEditRuntime<
195
+ TMessage,
196
+ TContext
197
+ >({
198
+ ...deps.telegramQueueStore,
199
+ updateStatus: deps.updateStatus,
200
+ });
201
+ return Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
202
+ getAllowedUserId: deps.configStore.getAllowedUserId,
203
+ setAllowedUserId: deps.configStore.setAllowedUserId,
204
+ persistConfig: deps.configStore.persist,
205
+ updateStatus: deps.updateStatus,
206
+ removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages,
207
+ removeQueuedTelegramTurnsByMessageIds:
208
+ deps.queueMutationRuntime.removeByMessageIds,
209
+ clearQueuedTelegramTurnPriorityByMessageId:
210
+ deps.queueMutationRuntime.clearPriorityByMessageId,
211
+ prioritizeQueuedTelegramTurnByMessageId:
212
+ deps.queueMutationRuntime.prioritizeByMessageId,
213
+ answerCallbackQuery: deps.answerCallbackQuery,
214
+ handleAuthorizedTelegramCallbackQuery: callbackHandler,
215
+ sendTextReply: deps.sendTextReply,
216
+ handleAuthorizedTelegramMessage: mediaDispatch.handleMessage,
217
+ handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
218
+ });
219
+ }
package/lib/runtime.ts CHANGED
@@ -327,8 +327,9 @@ export interface TelegramRuntimeEventRecorderPort {
327
327
  ) => void;
328
328
  }
329
329
 
330
- export interface TelegramTypingLoopStarterDeps<TContext>
331
- extends TelegramRuntimeEventRecorderPort {
330
+ export interface TelegramTypingLoopStarterDeps<
331
+ TContext,
332
+ > extends TelegramRuntimeEventRecorderPort {
332
333
  typing: TelegramRuntimeTypingPort;
333
334
  getDefaultChatId: () => number | undefined;
334
335
  sendTypingAction: (chatId: number) => Promise<unknown>;
@@ -413,8 +414,9 @@ export function createTelegramAgentEndResetter(
413
414
  };
414
415
  }
415
416
 
416
- export interface TelegramPromptDispatchLifecycleDeps<TContext>
417
- extends TelegramRuntimeEventRecorderPort {
417
+ export interface TelegramPromptDispatchLifecycleDeps<
418
+ TContext,
419
+ > extends TelegramRuntimeEventRecorderPort {
418
420
  lifecycle: Pick<
419
421
  TelegramRuntimeLifecyclePort,
420
422
  "setDispatchPending" | "clearDispatchPending"
@@ -424,8 +426,9 @@ export interface TelegramPromptDispatchLifecycleDeps<TContext>
424
426
  updateStatus: (ctx: TContext, error?: string) => void;
425
427
  }
426
428
 
427
- export interface TelegramPromptDispatchRuntimeDeps<TContext>
428
- extends TelegramRuntimeEventRecorderPort {
429
+ export interface TelegramPromptDispatchRuntimeDeps<
430
+ TContext,
431
+ > extends TelegramRuntimeEventRecorderPort {
429
432
  lifecycle: TelegramPromptDispatchLifecycleDeps<TContext>["lifecycle"];
430
433
  typing: TelegramRuntimeTypingPort;
431
434
  getDefaultChatId: () => number | undefined;
package/lib/setup.ts CHANGED
@@ -21,6 +21,11 @@ export interface TelegramSetupUser {
21
21
  username?: string;
22
22
  }
23
23
 
24
+ export interface TelegramPollingStartResult {
25
+ ok: boolean;
26
+ message?: string;
27
+ }
28
+
24
29
  export interface TelegramSetupDeps {
25
30
  hasUI: boolean;
26
31
  env: NodeJS.ProcessEnv;
@@ -34,7 +39,7 @@ export interface TelegramSetupDeps {
34
39
  }>;
35
40
  persistConfig: (config: TelegramSetupConfig) => Promise<void>;
36
41
  notify: (message: string, level: "info" | "error") => void;
37
- startPolling: () => void | Promise<void>;
42
+ startPolling: () => unknown | Promise<unknown>;
38
43
  updateStatus: () => void;
39
44
  }
40
45
 
@@ -61,7 +66,7 @@ export interface TelegramSetupPromptRuntimeDeps<
61
66
  setupGuard: TelegramSetupGuard;
62
67
  getMe: TelegramSetupDeps["getMe"];
63
68
  persistConfig: (config: TelegramSetupConfig) => Promise<void>;
64
- startPolling: (ctx: TContext) => void | Promise<void>;
69
+ startPolling: (ctx: TContext) => unknown | Promise<unknown>;
65
70
  updateStatus: (ctx: TContext) => void;
66
71
  recordRuntimeEvent?: (
67
72
  category: string,
@@ -78,6 +83,16 @@ const TELEGRAM_BOT_TOKEN_ENV_VARS = [
78
83
  "TELEGRAM_KEY",
79
84
  ] as const;
80
85
 
86
+ function isTelegramPollingStartResult(
87
+ value: unknown,
88
+ ): value is TelegramPollingStartResult {
89
+ return (
90
+ !!value &&
91
+ typeof value === "object" &&
92
+ typeof (value as { ok?: unknown }).ok === "boolean"
93
+ );
94
+ }
95
+
81
96
  export function getTelegramBotTokenInputDefault(
82
97
  env: NodeJS.ProcessEnv = process.env,
83
98
  configToken?: string,
@@ -135,7 +150,10 @@ export async function runTelegramSetup(
135
150
  "Send /start to your bot in Telegram to pair this extension with your account.",
136
151
  "info",
137
152
  );
138
- await deps.startPolling();
153
+ const startResult = await deps.startPolling();
154
+ if (isTelegramPollingStartResult(startResult) && startResult.message) {
155
+ deps.notify(startResult.message, startResult.ok ? "info" : "error");
156
+ }
139
157
  deps.updateStatus();
140
158
  return nextConfig;
141
159
  }
package/lib/status.ts CHANGED
@@ -83,6 +83,7 @@ export interface TelegramRuntimeEventRecorderOptions {
83
83
  export interface TelegramBridgeStatusLineState {
84
84
  botUsername?: string;
85
85
  allowedUserId?: number;
86
+ lockState?: string;
86
87
  pollingActive: boolean;
87
88
  lastUpdateId?: number;
88
89
  activeSourceMessageIds?: number[];
@@ -107,6 +108,7 @@ export interface TelegramStatusBarState {
107
108
  paired: boolean;
108
109
  compactionInProgress: boolean;
109
110
  processing: boolean;
111
+ processingStatus?: string;
110
112
  queuedStatus: string;
111
113
  error?: string;
112
114
  }
@@ -148,6 +150,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
148
150
  getQueuedItems: () => TQueueItem[];
149
151
  formatQueuedStatus: (items: TQueueItem[]) => string;
150
152
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
153
+ getRuntimeLockState?: () => string;
151
154
  }
152
155
 
153
156
  export interface TelegramStatusRuntime<
@@ -331,6 +334,10 @@ export function createTelegramBridgeStatusRuntime<
331
334
  getStatusBarState: (_ctx, error) => {
332
335
  const config = deps.getConfig();
333
336
  const queuedItems = deps.getQueuedItems();
337
+ const hasActiveTurn = deps.hasActiveTurn();
338
+ const hasPendingDispatch = deps.hasDispatchPending();
339
+ const hasPendingModelSwitch = deps.hasPendingModelSwitch();
340
+ const activeToolExecutions = deps.getActiveToolExecutions();
334
341
  const compactionInProgress = deps.isCompactionInProgress();
335
342
  return {
336
343
  hasBotToken: !!config.botToken,
@@ -338,9 +345,14 @@ export function createTelegramBridgeStatusRuntime<
338
345
  paired: !!config.allowedUserId,
339
346
  compactionInProgress,
340
347
  processing:
341
- deps.hasActiveTurn() ||
342
- deps.hasDispatchPending() ||
343
- queuedItems.length > 0,
348
+ hasActiveTurn || hasPendingDispatch || queuedItems.length > 0,
349
+ processingStatus: getTelegramStatusBarProcessingStatus({
350
+ hasActiveTurn,
351
+ hasPendingDispatch,
352
+ hasPendingModelSwitch,
353
+ activeToolExecutions,
354
+ queuedItems: queuedItems.length,
355
+ }),
344
356
  queuedStatus: deps.formatQueuedStatus(queuedItems),
345
357
  error,
346
358
  };
@@ -350,6 +362,7 @@ export function createTelegramBridgeStatusRuntime<
350
362
  return {
351
363
  botUsername: config.botUsername,
352
364
  allowedUserId: config.allowedUserId,
365
+ lockState: deps.getRuntimeLockState?.(),
353
366
  pollingActive: deps.isPollingActive(),
354
367
  lastUpdateId: config.lastUpdateId,
355
368
  activeSourceMessageIds: deps.getActiveSourceMessageIds(),
@@ -364,6 +377,21 @@ export function createTelegramBridgeStatusRuntime<
364
377
  });
365
378
  }
366
379
 
380
+ export function getTelegramStatusBarProcessingStatus(state: {
381
+ hasActiveTurn: boolean;
382
+ hasPendingDispatch: boolean;
383
+ hasPendingModelSwitch: boolean;
384
+ activeToolExecutions: number;
385
+ queuedItems: number;
386
+ }): string | undefined {
387
+ if (state.hasPendingModelSwitch) return "model";
388
+ if (state.activeToolExecutions > 0) return "tool running";
389
+ if (state.hasActiveTurn) return "active";
390
+ if (state.hasPendingDispatch) return "dispatching";
391
+ if (state.queuedItems > 0) return "queued";
392
+ return undefined;
393
+ }
394
+
367
395
  export function buildTelegramStatusBarText(
368
396
  theme: TelegramStatusBarTheme,
369
397
  state: TelegramStatusBarState,
@@ -383,7 +411,7 @@ export function buildTelegramStatusBarText(
383
411
  return `${label} ${theme.fg("accent", "compacting")}${queued}`;
384
412
  }
385
413
  if (state.processing) {
386
- return `${label} ${theme.fg("accent", "processing")}${queued}`;
414
+ return `${label} ${theme.fg("accent", state.processingStatus ?? "processing")}${queued}`;
387
415
  }
388
416
  return `${label} ${theme.fg("success", "connected")}`;
389
417
  }
@@ -404,6 +432,7 @@ export function buildTelegramBridgeStatusLines(
404
432
  "connection:",
405
433
  `- bot: ${state.botUsername ? `@${state.botUsername}` : "not configured"}`,
406
434
  `- allowed user: ${state.allowedUserId ?? "not paired"}`,
435
+ ...(state.lockState ? [`- owner: ${state.lockState}`] : []),
407
436
  "",
408
437
  "polling:",
409
438
  `- state: ${state.pollingActive ? "running" : "stopped"}`,
package/lib/turns.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readFile } from "node:fs/promises";
7
- import { basename } from "node:path";
7
+ import { basename, dirname, join } from "node:path";
8
8
 
9
9
  import {
10
10
  collectTelegramMessageIds,
@@ -53,9 +53,12 @@ export function truncateTelegramQueueSummary(
53
53
  export function formatTelegramTurnStatusSummary(
54
54
  rawText: string,
55
55
  files: DownloadedTelegramTurnFile[],
56
+ handlerOutputs: string[] = [],
56
57
  ): string {
57
58
  const textSummary = truncateTelegramQueueSummary(rawText);
58
59
  if (textSummary) return textSummary;
60
+ const handlerSummary = truncateTelegramQueueSummary(handlerOutputs.join(" "));
61
+ if (handlerSummary) return handlerSummary;
59
62
  if (files.length === 1) {
60
63
  const fileName = basename(
61
64
  files[0]?.fileName || files[0]?.path || "attachment",
@@ -66,10 +69,37 @@ export function formatTelegramTurnStatusSummary(
66
69
  return "(empty message)";
67
70
  }
68
71
 
72
+ function appendTelegramListSection(
73
+ text: string,
74
+ title: string,
75
+ items: string[],
76
+ ): string {
77
+ if (items.length === 0) return text;
78
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
79
+ return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
80
+ }
81
+
82
+ function appendTelegramAttachmentSection(
83
+ text: string,
84
+ files: Pick<DownloadedTelegramTurnFile, "path">[],
85
+ ): string {
86
+ if (files.length === 0) return text;
87
+ const dirs = [...new Set(files.map((file) => dirname(file.path)))];
88
+ const sameDir = dirs.length === 1;
89
+ const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
90
+ const items = sameDir
91
+ ? files.map((file) => `/${basename(file.path)}`)
92
+ : files.map((file) => file.path);
93
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
94
+ return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
95
+ }
96
+
69
97
  export function buildTelegramTurnPrompt(options: {
70
98
  telegramPrefix: string;
71
99
  rawText: string;
72
100
  files: DownloadedTelegramTurnFile[];
101
+ promptFiles?: DownloadedTelegramTurnFile[];
102
+ handlerOutputs?: string[];
73
103
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
74
104
  }): string {
75
105
  let prompt = options.telegramPrefix;
@@ -87,12 +117,13 @@ export function buildTelegramTurnPrompt(options: {
87
117
  ? `\n${options.rawText}`
88
118
  : ` ${options.rawText}`;
89
119
  }
90
- if (options.files.length > 0) {
91
- prompt += "\n\nTelegram attachments were saved locally:";
92
- for (const file of options.files) {
93
- prompt += `\n- ${file.path}`;
94
- }
95
- }
120
+ const promptFiles = options.promptFiles ?? options.files;
121
+ prompt = appendTelegramAttachmentSection(prompt, promptFiles);
122
+ prompt = appendTelegramListSection(
123
+ prompt,
124
+ "outputs",
125
+ options.handlerOutputs ?? [],
126
+ );
96
127
  return prompt;
97
128
  }
98
129
 
@@ -101,7 +132,7 @@ function splitTelegramPromptAttachmentSuffix(prompt: string): {
101
132
  attachmentSuffix: string;
102
133
  attachmentFiles: DownloadedTelegramTurnFile[];
103
134
  } {
104
- const marker = "\n\nTelegram attachments were saved locally:";
135
+ const marker = "\n\n[attachments]";
105
136
  const markerIndex = prompt.indexOf(marker);
106
137
  if (markerIndex === -1) {
107
138
  return {
@@ -112,11 +143,32 @@ function splitTelegramPromptAttachmentSuffix(prompt: string): {
112
143
  }
113
144
  const promptWithoutAttachments = prompt.slice(0, markerIndex);
114
145
  const attachmentSuffix = prompt.slice(markerIndex);
115
- const attachmentFiles = attachmentSuffix
116
- .split("\n")
146
+ const attachmentLines: string[] = [];
147
+ let readingAttachments = false;
148
+ let attachmentDir: string | undefined;
149
+ for (const line of attachmentSuffix.split("\n")) {
150
+ const trimmed = line.trim();
151
+ const attachmentMatch = trimmed.match(/^\[attachments\](?:\s+(.+))?$/);
152
+ if (attachmentMatch) {
153
+ readingAttachments = true;
154
+ attachmentDir = attachmentMatch[1]?.trim();
155
+ continue;
156
+ }
157
+ if (readingAttachments && /^\[[^\]]+\](?:\s+.*)?$/.test(trimmed)) break;
158
+ if (readingAttachments) attachmentLines.push(line);
159
+ }
160
+ const attachmentFiles = attachmentLines
117
161
  .map((line) => line.match(/^- (.+)$/)?.[1]?.trim())
118
162
  .filter((path): path is string => !!path)
119
- .map((path) => ({ path, fileName: basename(path), isImage: false }));
163
+ .map((path) =>
164
+ attachmentDir ? join(attachmentDir, path.replace(/^\/+/, "")) : path,
165
+ )
166
+ .map((path) => ({
167
+ path,
168
+ fileName: basename(path),
169
+ isImage: false,
170
+ kind: "document" as const,
171
+ }));
120
172
  return { promptWithoutAttachments, attachmentSuffix, attachmentFiles };
121
173
  }
122
174
 
@@ -242,6 +294,8 @@ export interface BuildTelegramPromptTurnOptions {
242
294
  queueOrder: number;
243
295
  rawText: string;
244
296
  files: DownloadedTelegramTurnFile[];
297
+ promptFiles?: DownloadedTelegramTurnFile[];
298
+ handlerOutputs?: string[];
245
299
  readBinaryFile: (path: string) => Promise<Uint8Array>;
246
300
  inferImageMimeType: (path: string) => string | undefined;
247
301
  }
@@ -251,30 +305,51 @@ export type BuildTelegramPromptTurnRuntimeOptions = Omit<
251
305
  "readBinaryFile"
252
306
  >;
253
307
 
254
- export interface TelegramPromptTurnRuntimeBuilderDeps extends DownloadTelegramMessageFilesDeps {
308
+ export interface TelegramPromptTurnRuntimeBuilderDeps<
309
+ TContext = unknown,
310
+ > extends DownloadTelegramMessageFilesDeps {
255
311
  allocateQueueOrder: () => number;
312
+ processAttachments?: (
313
+ files: DownloadedTelegramTurnFile[],
314
+ rawText: string,
315
+ ctx: TContext,
316
+ ) => Promise<{
317
+ rawText: string;
318
+ promptFiles?: DownloadedTelegramTurnFile[];
319
+ handlerOutputs?: string[];
320
+ }>;
256
321
  }
257
322
 
258
323
  export function createTelegramPromptTurnRuntimeBuilder<
259
324
  TMessage extends TelegramTurnMessage & TelegramMediaMessage,
325
+ TContext = unknown,
260
326
  >(
261
- deps: TelegramPromptTurnRuntimeBuilderDeps,
327
+ deps: TelegramPromptTurnRuntimeBuilderDeps<TContext>,
262
328
  ): (
263
329
  messages: TMessage[],
264
330
  historyTurns?: PendingTelegramTurn[],
331
+ ctx?: TContext,
265
332
  ) => Promise<PendingTelegramTurn> {
266
- return async (messages, historyTurns = []) =>
267
- buildTelegramPromptTurnRuntime({
333
+ return async (messages, historyTurns = [], ctx) => {
334
+ const rawText = extractTelegramMessagesText(messages);
335
+ const files = await downloadTelegramMessageFiles(messages, {
336
+ downloadFile: deps.downloadFile,
337
+ });
338
+ const processed = deps.processAttachments
339
+ ? await deps.processAttachments(files, rawText, ctx as TContext)
340
+ : { rawText, promptFiles: files };
341
+ return buildTelegramPromptTurnRuntime({
268
342
  telegramPrefix: TELEGRAM_PREFIX,
269
343
  messages,
270
344
  historyTurns,
271
345
  queueOrder: deps.allocateQueueOrder(),
272
- rawText: extractTelegramMessagesText(messages),
273
- files: await downloadTelegramMessageFiles(messages, {
274
- downloadFile: deps.downloadFile,
275
- }),
346
+ rawText: processed.rawText,
347
+ files,
348
+ promptFiles: processed.promptFiles,
349
+ handlerOutputs: processed.handlerOutputs,
276
350
  inferImageMimeType: guessMediaType,
277
351
  });
352
+ };
278
353
  }
279
354
 
280
355
  export async function buildTelegramPromptTurn(
@@ -291,6 +366,8 @@ export async function buildTelegramPromptTurn(
291
366
  telegramPrefix: options.telegramPrefix,
292
367
  rawText: options.rawText,
293
368
  files: options.files,
369
+ promptFiles: options.promptFiles,
370
+ handlerOutputs: options.handlerOutputs,
294
371
  historyTurns: options.historyTurns,
295
372
  }),
296
373
  },
@@ -316,10 +393,15 @@ export async function buildTelegramPromptTurn(
316
393
  laneOrder: options.queueOrder,
317
394
  queuedAttachments: [],
318
395
  content,
319
- historyText: formatTelegramHistoryText(options.rawText, options.files),
396
+ historyText: formatTelegramHistoryText(
397
+ options.rawText,
398
+ options.promptFiles ?? options.files,
399
+ options.handlerOutputs,
400
+ ),
320
401
  statusSummary: formatTelegramTurnStatusSummary(
321
402
  options.rawText,
322
- options.files,
403
+ options.promptFiles ?? options.files,
404
+ options.handlerOutputs,
323
405
  ),
324
406
  };
325
407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for pi",
6
6
  "type": "module",