@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/lib/turns.ts CHANGED
@@ -3,27 +3,34 @@
3
3
  * Owns prompt-turn summary and content construction so queued Telegram turns are assembled consistently
4
4
  */
5
5
 
6
- import { basename } from "node:path";
7
-
8
- import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
6
+ import { readFile } from "node:fs/promises";
7
+ import { basename, dirname, join } from "node:path";
9
8
 
10
9
  import {
11
10
  collectTelegramMessageIds,
11
+ type DownloadedTelegramMessageFile,
12
+ type DownloadTelegramMessageFilesDeps,
13
+ downloadTelegramMessageFiles,
14
+ extractTelegramMessagesText,
12
15
  formatTelegramHistoryText,
16
+ guessMediaType,
17
+ type TelegramMediaMessage,
13
18
  } from "./media.ts";
14
- import type { PendingTelegramTurn } from "./queue.ts";
19
+ import type {
20
+ PendingTelegramTurn,
21
+ TelegramPromptContent,
22
+ TelegramQueueItem,
23
+ TelegramQueueStore,
24
+ } from "./queue.ts";
25
+
26
+ export const TELEGRAM_PREFIX = "[telegram]";
15
27
 
16
- export interface TelegramTurnMessageLike {
28
+ export interface TelegramTurnMessage {
17
29
  message_id: number;
18
30
  chat: { id: number };
19
31
  }
20
32
 
21
- export interface DownloadedTelegramTurnFileLike {
22
- path: string;
23
- fileName: string;
24
- isImage: boolean;
25
- mimeType?: string;
26
- }
33
+ export type DownloadedTelegramTurnFile = DownloadedTelegramMessageFile;
27
34
 
28
35
  export function truncateTelegramQueueSummary(
29
36
  text: string,
@@ -45,10 +52,15 @@ export function truncateTelegramQueueSummary(
45
52
 
46
53
  export function formatTelegramTurnStatusSummary(
47
54
  rawText: string,
48
- files: DownloadedTelegramTurnFileLike[],
55
+ files: DownloadedTelegramTurnFile[],
56
+ handlerOutputs: string[] = [],
49
57
  ): string {
50
58
  const textSummary = truncateTelegramQueueSummary(rawText);
51
59
  if (textSummary) return textSummary;
60
+ const handlerSummary = truncateTelegramQueueSummary(
61
+ handlerOutputs.join(" "),
62
+ );
63
+ if (handlerSummary) return handlerSummary;
52
64
  if (files.length === 1) {
53
65
  const fileName = basename(
54
66
  files[0]?.fileName || files[0]?.path || "attachment",
@@ -59,10 +71,37 @@ export function formatTelegramTurnStatusSummary(
59
71
  return "(empty message)";
60
72
  }
61
73
 
74
+ function appendTelegramListSection(
75
+ text: string,
76
+ title: string,
77
+ items: string[],
78
+ ): string {
79
+ if (items.length === 0) return text;
80
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
81
+ return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
82
+ }
83
+
84
+ function appendTelegramAttachmentSection(
85
+ text: string,
86
+ files: Pick<DownloadedTelegramTurnFile, "path">[],
87
+ ): string {
88
+ if (files.length === 0) return text;
89
+ const dirs = [...new Set(files.map((file) => dirname(file.path)))];
90
+ const sameDir = dirs.length === 1;
91
+ const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
92
+ const items = sameDir
93
+ ? files.map((file) => `/${basename(file.path)}`)
94
+ : files.map((file) => file.path);
95
+ const prefix = text.length > 0 ? `${text}\n\n` : "";
96
+ return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
97
+ }
98
+
62
99
  export function buildTelegramTurnPrompt(options: {
63
100
  telegramPrefix: string;
64
101
  rawText: string;
65
- files: DownloadedTelegramTurnFileLike[];
102
+ files: DownloadedTelegramTurnFile[];
103
+ promptFiles?: DownloadedTelegramTurnFile[];
104
+ handlerOutputs?: string[];
66
105
  historyTurns?: Pick<PendingTelegramTurn, "historyText">[];
67
106
  }): string {
68
107
  let prompt = options.telegramPrefix;
@@ -80,21 +119,18 @@ export function buildTelegramTurnPrompt(options: {
80
119
  ? `\n${options.rawText}`
81
120
  : ` ${options.rawText}`;
82
121
  }
83
- if (options.files.length > 0) {
84
- prompt += "\n\nTelegram attachments were saved locally:";
85
- for (const file of options.files) {
86
- prompt += `\n- ${file.path}`;
87
- }
88
- }
122
+ const promptFiles = options.promptFiles ?? options.files;
123
+ prompt = appendTelegramAttachmentSection(prompt, promptFiles);
124
+ prompt = appendTelegramListSection(prompt, "outputs", options.handlerOutputs ?? []);
89
125
  return prompt;
90
126
  }
91
127
 
92
128
  function splitTelegramPromptAttachmentSuffix(prompt: string): {
93
129
  promptWithoutAttachments: string;
94
130
  attachmentSuffix: string;
95
- attachmentFiles: DownloadedTelegramTurnFileLike[];
131
+ attachmentFiles: DownloadedTelegramTurnFile[];
96
132
  } {
97
- const marker = "\n\nTelegram attachments were saved locally:";
133
+ const marker = "\n\n[attachments]";
98
134
  const markerIndex = prompt.indexOf(marker);
99
135
  if (markerIndex === -1) {
100
136
  return {
@@ -105,11 +141,30 @@ function splitTelegramPromptAttachmentSuffix(prompt: string): {
105
141
  }
106
142
  const promptWithoutAttachments = prompt.slice(0, markerIndex);
107
143
  const attachmentSuffix = prompt.slice(markerIndex);
108
- const attachmentFiles = attachmentSuffix
109
- .split("\n")
144
+ const attachmentLines: string[] = [];
145
+ let readingAttachments = false;
146
+ let attachmentDir: string | undefined;
147
+ for (const line of attachmentSuffix.split("\n")) {
148
+ const trimmed = line.trim();
149
+ const attachmentMatch = trimmed.match(/^\[attachments\](?:\s+(.+))?$/);
150
+ if (attachmentMatch) {
151
+ readingAttachments = true;
152
+ attachmentDir = attachmentMatch[1]?.trim();
153
+ continue;
154
+ }
155
+ if (readingAttachments && /^\[[^\]]+\](?:\s+.*)?$/.test(trimmed)) break;
156
+ if (readingAttachments) attachmentLines.push(line);
157
+ }
158
+ const attachmentFiles = attachmentLines
110
159
  .map((line) => line.match(/^- (.+)$/)?.[1]?.trim())
111
160
  .filter((path): path is string => !!path)
112
- .map((path) => ({ path, fileName: basename(path), isImage: false }));
161
+ .map((path) => (attachmentDir ? join(attachmentDir, path.replace(/^\/+/, "")) : path))
162
+ .map((path) => ({
163
+ path,
164
+ fileName: basename(path),
165
+ isImage: false,
166
+ kind: "document" as const,
167
+ }));
113
168
  return { promptWithoutAttachments, attachmentSuffix, attachmentFiles };
114
169
  }
115
170
 
@@ -117,13 +172,12 @@ function buildEditedTelegramPromptText(options: {
117
172
  existingPrompt: string;
118
173
  telegramPrefix: string;
119
174
  rawText: string;
120
- }): { text: string; attachmentFiles: DownloadedTelegramTurnFileLike[] } {
175
+ }): { text: string; attachmentFiles: DownloadedTelegramTurnFile[] } {
121
176
  const { promptWithoutAttachments, attachmentSuffix, attachmentFiles } =
122
177
  splitTelegramPromptAttachmentSuffix(options.existingPrompt);
123
178
  const currentMessageMarker = "Current Telegram message:";
124
- const currentMessageIndex = promptWithoutAttachments.lastIndexOf(
125
- currentMessageMarker,
126
- );
179
+ const currentMessageIndex =
180
+ promptWithoutAttachments.lastIndexOf(currentMessageMarker);
127
181
  if (currentMessageIndex !== -1) {
128
182
  const prefix = promptWithoutAttachments.slice(
129
183
  0,
@@ -150,7 +204,7 @@ export function updateTelegramPromptTurnText(options: {
150
204
  telegramPrefix: string;
151
205
  rawText: string;
152
206
  }): PendingTelegramTurn {
153
- let attachmentFiles: DownloadedTelegramTurnFileLike[] = [];
207
+ let attachmentFiles: DownloadedTelegramTurnFile[] = [];
154
208
  const nextContent = options.turn.content.map((block, index) => {
155
209
  if (index !== 0 || block.type !== "text") return block;
156
210
  const updated = buildEditedTelegramPromptText({
@@ -175,27 +229,140 @@ export function updateTelegramPromptTurnText(options: {
175
229
  };
176
230
  }
177
231
 
178
- export async function buildTelegramPromptTurn(options: {
232
+ export function updateQueuedTelegramPromptTurnText<
233
+ TContext = unknown,
234
+ >(options: {
235
+ items: TelegramQueueItem<TContext>[];
236
+ sourceMessageId: number | undefined;
179
237
  telegramPrefix: string;
180
- messages: TelegramTurnMessageLike[];
238
+ rawText: string;
239
+ }): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
240
+ if (options.sourceMessageId === undefined) {
241
+ return { items: options.items, changed: false };
242
+ }
243
+ let changed = false;
244
+ const items = options.items.map((item) => {
245
+ if (
246
+ item.kind !== "prompt" ||
247
+ !item.sourceMessageIds.includes(options.sourceMessageId as number)
248
+ ) {
249
+ return item;
250
+ }
251
+ changed = true;
252
+ return updateTelegramPromptTurnText({
253
+ turn: item,
254
+ telegramPrefix: options.telegramPrefix,
255
+ rawText: options.rawText,
256
+ });
257
+ });
258
+ return { items, changed };
259
+ }
260
+
261
+ export interface TelegramQueuedPromptEditRuntimeDeps<
262
+ TContext = unknown,
263
+ > extends TelegramQueueStore<TContext> {
264
+ updateStatus: (ctx: TContext) => void;
265
+ }
266
+
267
+ export function createTelegramQueuedPromptEditRuntime<
268
+ TMessage extends TelegramMediaMessage,
269
+ TContext = unknown,
270
+ >(deps: TelegramQueuedPromptEditRuntimeDeps<TContext>) {
271
+ return {
272
+ updateFromEditedMessage: (message: TMessage, ctx: TContext): boolean => {
273
+ const { changed, items } = updateQueuedTelegramPromptTurnText({
274
+ items: deps.getQueuedItems(),
275
+ sourceMessageId: message.message_id,
276
+ telegramPrefix: TELEGRAM_PREFIX,
277
+ rawText: extractTelegramMessagesText([message]),
278
+ });
279
+ deps.setQueuedItems(items);
280
+ if (changed) deps.updateStatus(ctx);
281
+ return changed;
282
+ },
283
+ };
284
+ }
285
+
286
+ export interface BuildTelegramPromptTurnOptions {
287
+ telegramPrefix: string;
288
+ messages: TelegramTurnMessage[];
181
289
  historyTurns?: PendingTelegramTurn[];
182
290
  queueOrder: number;
183
291
  rawText: string;
184
- files: DownloadedTelegramTurnFileLike[];
292
+ files: DownloadedTelegramTurnFile[];
293
+ promptFiles?: DownloadedTelegramTurnFile[];
294
+ handlerOutputs?: string[];
185
295
  readBinaryFile: (path: string) => Promise<Uint8Array>;
186
296
  inferImageMimeType: (path: string) => string | undefined;
187
- }): Promise<PendingTelegramTurn> {
297
+ }
298
+
299
+ export type BuildTelegramPromptTurnRuntimeOptions = Omit<
300
+ BuildTelegramPromptTurnOptions,
301
+ "readBinaryFile"
302
+ >;
303
+
304
+ export interface TelegramPromptTurnRuntimeBuilderDeps<TContext = unknown>
305
+ extends DownloadTelegramMessageFilesDeps {
306
+ allocateQueueOrder: () => number;
307
+ processAttachments?: (
308
+ files: DownloadedTelegramTurnFile[],
309
+ rawText: string,
310
+ ctx: TContext,
311
+ ) => Promise<{
312
+ rawText: string;
313
+ promptFiles?: DownloadedTelegramTurnFile[];
314
+ handlerOutputs?: string[];
315
+ }>;
316
+ }
317
+
318
+ export function createTelegramPromptTurnRuntimeBuilder<
319
+ TMessage extends TelegramTurnMessage & TelegramMediaMessage,
320
+ TContext = unknown,
321
+ >(
322
+ deps: TelegramPromptTurnRuntimeBuilderDeps<TContext>,
323
+ ): (
324
+ messages: TMessage[],
325
+ historyTurns?: PendingTelegramTurn[],
326
+ ctx?: TContext,
327
+ ) => Promise<PendingTelegramTurn> {
328
+ return async (messages, historyTurns = [], ctx) => {
329
+ const rawText = extractTelegramMessagesText(messages);
330
+ const files = await downloadTelegramMessageFiles(messages, {
331
+ downloadFile: deps.downloadFile,
332
+ });
333
+ const processed = deps.processAttachments
334
+ ? await deps.processAttachments(files, rawText, ctx as TContext)
335
+ : { rawText, promptFiles: files };
336
+ return buildTelegramPromptTurnRuntime({
337
+ telegramPrefix: TELEGRAM_PREFIX,
338
+ messages,
339
+ historyTurns,
340
+ queueOrder: deps.allocateQueueOrder(),
341
+ rawText: processed.rawText,
342
+ files,
343
+ promptFiles: processed.promptFiles,
344
+ handlerOutputs: processed.handlerOutputs,
345
+ inferImageMimeType: guessMediaType,
346
+ });
347
+ };
348
+ }
349
+
350
+ export async function buildTelegramPromptTurn(
351
+ options: BuildTelegramPromptTurnOptions,
352
+ ): Promise<PendingTelegramTurn> {
188
353
  const firstMessage = options.messages[0];
189
354
  if (!firstMessage) {
190
355
  throw new Error("Missing Telegram message for turn creation");
191
356
  }
192
- const content: Array<TextContent | ImageContent> = [
357
+ const content: TelegramPromptContent[] = [
193
358
  {
194
359
  type: "text",
195
360
  text: buildTelegramTurnPrompt({
196
361
  telegramPrefix: options.telegramPrefix,
197
362
  rawText: options.rawText,
198
363
  files: options.files,
364
+ promptFiles: options.promptFiles,
365
+ handlerOutputs: options.handlerOutputs,
199
366
  historyTurns: options.historyTurns,
200
367
  }),
201
368
  },
@@ -221,10 +388,24 @@ export async function buildTelegramPromptTurn(options: {
221
388
  laneOrder: options.queueOrder,
222
389
  queuedAttachments: [],
223
390
  content,
224
- historyText: formatTelegramHistoryText(options.rawText, options.files),
391
+ historyText: formatTelegramHistoryText(
392
+ options.rawText,
393
+ options.promptFiles ?? options.files,
394
+ options.handlerOutputs,
395
+ ),
225
396
  statusSummary: formatTelegramTurnStatusSummary(
226
397
  options.rawText,
227
- options.files,
398
+ options.promptFiles ?? options.files,
399
+ options.handlerOutputs,
228
400
  ),
229
401
  };
230
402
  }
403
+
404
+ export async function buildTelegramPromptTurnRuntime(
405
+ options: BuildTelegramPromptTurnRuntimeOptions,
406
+ ): Promise<PendingTelegramTurn> {
407
+ return buildTelegramPromptTurn({
408
+ ...options,
409
+ readBinaryFile: readFile,
410
+ });
411
+ }