@llblab/pi-telegram 0.7.2 → 0.8.1

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/menu-queue.ts CHANGED
@@ -11,11 +11,14 @@ import * as Queue from "./queue.ts";
11
11
 
12
12
  // --- Queue Menu ---
13
13
 
14
+ const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
15
+ const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
14
16
  type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
15
17
  interface TelegramQueueMenuItem {
16
18
  chatId: number;
17
19
  replyToMessageId: number;
18
20
  isPriority: boolean;
21
+ priorityEmoji?: string;
19
22
  hasAttachments: boolean;
20
23
  statusSummary: string;
21
24
  promptText: string;
@@ -40,6 +43,7 @@ function toTelegramQueueMenuItems<Context>(
40
43
  chatId: item.chatId,
41
44
  replyToMessageId: item.replyToMessageId,
42
45
  isPriority: item.queueLane === "priority",
46
+ priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
43
47
  hasAttachments:
44
48
  item.kind === "prompt" && item.queuedAttachments.length > 0,
45
49
  statusSummary: item.statusSummary,
@@ -53,7 +57,11 @@ function buildTelegramQueueMenuReplyMarkup(
53
57
  const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
54
58
  if (items.length === 0) return { inline_keyboard: [backRow] };
55
59
  const rows = items.map(function buildTelegramQueueMenuRow(item, index) {
56
- const prefix = item.isPriority ? "⚡ " : item.hasAttachments ? "📎 " : "";
60
+ const prefix = item.isPriority
61
+ ? `${item.priorityEmoji ?? "⚡"} `
62
+ : item.hasAttachments
63
+ ? "📎 "
64
+ : "";
57
65
  const label = `${index + 1}. ${prefix}${item.statusSummary}`;
58
66
  return [
59
67
  {
@@ -82,21 +90,44 @@ function findTelegramQueueMenuItem(
82
90
  return item.chatId === chatId && item.replyToMessageId === replyToMessageId;
83
91
  });
84
92
  }
93
+ function escapeTelegramQueueMenuHtmlChar(char: string): string {
94
+ if (char === "&") return "&amp;";
95
+ if (char === "<") return "&lt;";
96
+ if (char === ">") return "&gt;";
97
+ return char;
98
+ }
85
99
  function escapeTelegramQueueMenuHtml(text: string): string {
86
- return text
87
- .replace(/&/g, "&amp;")
88
- .replace(/</g, "&lt;")
89
- .replace(/>/g, "&gt;");
100
+ return Array.from(text).map(escapeTelegramQueueMenuHtmlChar).join("");
101
+ }
102
+ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
103
+ const suffix = escapeTelegramQueueMenuHtml(QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX);
104
+ let escaped = "";
105
+ let truncated = false;
106
+ for (const char of text) {
107
+ const next = escapeTelegramQueueMenuHtmlChar(char);
108
+ if (
109
+ escaped.length + next.length + suffix.length >
110
+ QUEUE_ITEM_PROMPT_HTML_LIMIT
111
+ ) {
112
+ truncated = true;
113
+ break;
114
+ }
115
+ escaped += next;
116
+ }
117
+ return truncated ? escaped + suffix : escaped;
90
118
  }
91
119
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
92
- return escapeTelegramQueueMenuHtml(item.promptText);
120
+ return `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
93
121
  }
94
122
  function buildTelegramQueueItemSubmenuReplyMarkup(
95
123
  chatId: number,
96
124
  replyToMessageId: number,
97
125
  isPriority: boolean,
126
+ priorityEmoji?: string,
98
127
  ): TelegramQueueMenuReplyMarkup {
99
- const priorityLabel = isPriority ? "🐢 Deprioritize" : "⚡ Prioritize";
128
+ const priorityLabel = isPriority
129
+ ? `🐢 Deprioritize ${priorityEmoji ?? "⚡"}`
130
+ : "⚡ Prioritize";
100
131
  return {
101
132
  inline_keyboard: [
102
133
  [{ text: "⬆️ Back", callback_data: "queue:list" }],
@@ -108,8 +139,29 @@ function buildTelegramQueueItemSubmenuReplyMarkup(
108
139
  ],
109
140
  [
110
141
  {
111
- text: " Cancel",
112
- callback_data: `queue:cancel:${chatId}:${replyToMessageId}`,
142
+ text: "🗑 Delete",
143
+ callback_data: `queue:delete:${chatId}:${replyToMessageId}`,
144
+ },
145
+ ],
146
+ ],
147
+ };
148
+ }
149
+ function buildTelegramQueueDeleteConfirmationReplyMarkup(
150
+ chatId: number,
151
+ replyToMessageId: number,
152
+ ): TelegramQueueMenuReplyMarkup {
153
+ return {
154
+ inline_keyboard: [
155
+ [
156
+ {
157
+ text: "🗑 Yes, delete",
158
+ callback_data: `queue:confirm-delete:${chatId}:${replyToMessageId}`,
159
+ },
160
+ ],
161
+ [
162
+ {
163
+ text: "❌ No",
164
+ callback_data: `queue:keep:${chatId}:${replyToMessageId}`,
113
165
  },
114
166
  ],
115
167
  ],
@@ -186,14 +238,38 @@ async function handleTelegramQueueMenuCallback<Context>(
186
238
  );
187
239
  return true;
188
240
  }
189
- const cancelMatch = data.match(/^queue:cancel:(\d+):(\d+)$/);
190
- if (cancelMatch) {
191
- await handleTelegramQueueMenuCancel(
241
+ const deleteMatch = data.match(/^queue:(?:delete|cancel):(\d+):(\d+)$/);
242
+ if (deleteMatch) {
243
+ await handleTelegramQueueMenuDeleteRequest(
192
244
  callbackQueryId,
193
245
  replyChatId,
194
246
  replyMessageId,
195
- Number(cancelMatch[1]),
196
- Number(cancelMatch[2]),
247
+ Number(deleteMatch[1]),
248
+ Number(deleteMatch[2]),
249
+ deps,
250
+ );
251
+ return true;
252
+ }
253
+ const keepMatch = data.match(/^queue:keep:(\d+):(\d+)$/);
254
+ if (keepMatch) {
255
+ await handleTelegramQueueMenuKeep(
256
+ callbackQueryId,
257
+ replyChatId,
258
+ replyMessageId,
259
+ Number(keepMatch[1]),
260
+ Number(keepMatch[2]),
261
+ deps,
262
+ );
263
+ return true;
264
+ }
265
+ const confirmDeleteMatch = data.match(/^queue:confirm-delete:(\d+):(\d+)$/);
266
+ if (confirmDeleteMatch) {
267
+ await handleTelegramQueueMenuConfirmDelete(
268
+ callbackQueryId,
269
+ replyChatId,
270
+ replyMessageId,
271
+ Number(confirmDeleteMatch[1]),
272
+ Number(confirmDeleteMatch[2]),
197
273
  ctx,
198
274
  deps,
199
275
  );
@@ -204,8 +280,8 @@ async function handleTelegramQueueMenuCallback<Context>(
204
280
  function getTelegramQueueMenuListText(
205
281
  items: readonly TelegramQueueMenuItem[],
206
282
  ): string {
207
- if (items.length === 0) return "<b>Queue is empty.</b>";
208
- return "<b>Queue:</b>";
283
+ if (items.length === 0) return "<b>⏳ Queue is empty.</b>";
284
+ return "<b>⏳ Queue:</b>";
209
285
  }
210
286
  async function updateTelegramQueueMenuList<Context>(
211
287
  callbackQueryId: string,
@@ -258,7 +334,12 @@ async function handleTelegramQueueMenuPick<Context>(
258
334
  replyChatId,
259
335
  replyMessageId,
260
336
  getTelegramQueueMenuItemText(item),
261
- buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, item.isPriority),
337
+ buildTelegramQueueItemSubmenuReplyMarkup(
338
+ chatId,
339
+ msgId,
340
+ item.isPriority,
341
+ item.priorityEmoji,
342
+ ),
262
343
  );
263
344
  await deps.answerCallbackQuery(callbackQueryId);
264
345
  }
@@ -288,14 +369,74 @@ async function handleTelegramQueueMenuPriority<Context>(
288
369
  replyChatId,
289
370
  replyMessageId,
290
371
  getTelegramQueueMenuItemText(item),
291
- buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, newPriority),
372
+ buildTelegramQueueItemSubmenuReplyMarkup(
373
+ chatId,
374
+ msgId,
375
+ newPriority,
376
+ updated?.priorityEmoji ?? item.priorityEmoji,
377
+ ),
292
378
  );
293
379
  await deps.answerCallbackQuery(
294
380
  callbackQueryId,
295
381
  newPriority ? "Prioritized." : "Deprioritized.",
296
382
  );
297
383
  }
298
- async function handleTelegramQueueMenuCancel<Context>(
384
+ async function handleTelegramQueueMenuDeleteRequest<Context>(
385
+ callbackQueryId: string,
386
+ replyChatId: number,
387
+ replyMessageId: number,
388
+ chatId: number,
389
+ msgId: number,
390
+ deps: TelegramQueueMenuCallbackDeps<Context>,
391
+ ): Promise<void> {
392
+ const item = deps.findItem(chatId, msgId);
393
+ if (!item) {
394
+ return refreshStaleTelegramQueueMenuItem(
395
+ callbackQueryId,
396
+ replyChatId,
397
+ replyMessageId,
398
+ deps,
399
+ );
400
+ }
401
+ await deps.updateQueueMessage(
402
+ replyChatId,
403
+ replyMessageId,
404
+ "<b>Delete this queued prompt?</b>",
405
+ buildTelegramQueueDeleteConfirmationReplyMarkup(chatId, msgId),
406
+ );
407
+ await deps.answerCallbackQuery(callbackQueryId);
408
+ }
409
+ async function handleTelegramQueueMenuKeep<Context>(
410
+ callbackQueryId: string,
411
+ replyChatId: number,
412
+ replyMessageId: number,
413
+ chatId: number,
414
+ msgId: number,
415
+ deps: TelegramQueueMenuCallbackDeps<Context>,
416
+ ): Promise<void> {
417
+ const item = deps.findItem(chatId, msgId);
418
+ if (!item) {
419
+ return refreshStaleTelegramQueueMenuItem(
420
+ callbackQueryId,
421
+ replyChatId,
422
+ replyMessageId,
423
+ deps,
424
+ );
425
+ }
426
+ await deps.updateQueueMessage(
427
+ replyChatId,
428
+ replyMessageId,
429
+ getTelegramQueueMenuItemText(item),
430
+ buildTelegramQueueItemSubmenuReplyMarkup(
431
+ chatId,
432
+ msgId,
433
+ item.isPriority,
434
+ item.priorityEmoji,
435
+ ),
436
+ );
437
+ await deps.answerCallbackQuery(callbackQueryId, "Kept in queue.");
438
+ }
439
+ async function handleTelegramQueueMenuConfirmDelete<Context>(
299
440
  callbackQueryId: string,
300
441
  replyChatId: number,
301
442
  replyMessageId: number,
@@ -311,7 +452,7 @@ async function handleTelegramQueueMenuCancel<Context>(
311
452
  replyChatId,
312
453
  replyMessageId,
313
454
  deps,
314
- removed ? "Removed from queue." : "Item not found.",
455
+ removed ? "Deleted from queue." : "Item not found.",
315
456
  );
316
457
  }
317
458
 
@@ -160,7 +160,7 @@ export function buildStatusReplyMarkup(
160
160
  }
161
161
  rows.push([
162
162
  {
163
- text: `🔢 Queue: ${queueItemCount}`,
163
+ text: `⏳ Queue: ${queueItemCount}`,
164
164
  callback_data: "menu:queue",
165
165
  },
166
166
  ]);
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Telegram attachment domain helpers
2
+ * Telegram outbound attachment helpers
3
3
  * Zones: telegram outbound, pi agent tool, filesystem
4
- * Owns telegram_attach registration, attachment queueing, and attachment delivery so Telegram file output stays in one domain module
4
+ * Owns telegram_attach registration, outbound attachment queueing, and delivery so Telegram file output stays in one domain module
5
5
  */
6
6
 
7
7
  import { stat } from "node:fs/promises";
@@ -16,7 +16,7 @@ const MAX_ATTACHMENTS_PER_TURN = 10;
16
16
 
17
17
  export const TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
18
18
 
19
- export function getTelegramAttachmentByteLimitFromEnv(
19
+ export function getTelegramOutboundAttachmentByteLimitFromEnv(
20
20
  env: NodeJS.ProcessEnv,
21
21
  names: string[],
22
22
  defaultValue = TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
@@ -31,17 +31,17 @@ export function getTelegramAttachmentByteLimitFromEnv(
31
31
  }
32
32
 
33
33
  export const TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES =
34
- getTelegramAttachmentByteLimitFromEnv(process.env, [
34
+ getTelegramOutboundAttachmentByteLimitFromEnv(process.env, [
35
35
  "PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
36
36
  "TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
37
37
  ]);
38
38
 
39
- export interface TelegramAttachmentToolResult {
39
+ export interface TelegramOutboundAttachmentToolResult {
40
40
  content: Array<{ type: "text"; text: string }>;
41
41
  details: { paths: string[] };
42
42
  }
43
43
 
44
- export interface TelegramAttachmentRuntimeEventRecorderPort {
44
+ export interface TelegramOutboundAttachmentRuntimeEventRecorderPort {
45
45
  recordRuntimeEvent?: (
46
46
  category: string,
47
47
  error: unknown,
@@ -49,28 +49,28 @@ export interface TelegramAttachmentRuntimeEventRecorderPort {
49
49
  ) => void;
50
50
  }
51
51
 
52
- export interface TelegramAttachmentToolRegistrationDeps extends TelegramAttachmentRuntimeEventRecorderPort {
52
+ export interface TelegramOutboundAttachmentToolRegistrationDeps extends TelegramOutboundAttachmentRuntimeEventRecorderPort {
53
53
  maxAttachmentsPerTurn?: number;
54
54
  maxAttachmentSizeBytes?: number;
55
- getActiveTurn: () => TelegramAttachmentQueueTargetView | undefined;
55
+ getActiveTurn: () => TelegramOutboundAttachmentQueueTargetView | undefined;
56
56
  statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
57
57
  }
58
58
 
59
- export interface TelegramQueuedAttachmentView {
59
+ export interface TelegramQueuedOutboundAttachmentView {
60
60
  path: string;
61
61
  fileName: string;
62
62
  }
63
63
 
64
- export interface TelegramAttachmentQueueTargetView {
65
- queuedAttachments: TelegramQueuedAttachmentView[];
64
+ export interface TelegramOutboundAttachmentQueueTargetView {
65
+ queuedAttachments: TelegramQueuedOutboundAttachmentView[];
66
66
  }
67
67
 
68
- export interface TelegramQueuedAttachmentTurnView extends TelegramAttachmentQueueTargetView {
68
+ export interface TelegramQueuedOutboundAttachmentTurnView extends TelegramOutboundAttachmentQueueTargetView {
69
69
  chatId: number;
70
70
  replyToMessageId: number;
71
71
  }
72
72
 
73
- function isTelegramPhotoAttachmentPath(path: string): boolean {
73
+ function isTelegramOutboundPhotoAttachmentPath(path: string): boolean {
74
74
  const normalized = path.toLowerCase();
75
75
  return (
76
76
  normalized.endsWith(".jpg") ||
@@ -81,7 +81,7 @@ function isTelegramPhotoAttachmentPath(path: string): boolean {
81
81
  );
82
82
  }
83
83
 
84
- function formatTelegramAttachmentSizeLimitError(
84
+ function formatTelegramOutboundAttachmentSizeLimitError(
85
85
  size: number,
86
86
  maxSize: number,
87
87
  path?: string,
@@ -90,14 +90,14 @@ function formatTelegramAttachmentSizeLimitError(
90
90
  return path ? `${message}: ${path}` : message;
91
91
  }
92
92
 
93
- function formatTelegramAttachmentToolResultText(count: number): string {
93
+ function formatTelegramOutboundAttachmentToolResultText(count: number): string {
94
94
  // Pi's compact tool rows need an empty first line to visually separate header and result
95
95
  return ["", `Queued ${count} Telegram attachment(s).`].join("\n");
96
96
  }
97
97
 
98
- export function registerTelegramAttachmentTool(
98
+ export function registerTelegramOutboundAttachmentTool(
99
99
  pi: ExtensionAPI,
100
- deps: TelegramAttachmentToolRegistrationDeps,
100
+ deps: TelegramOutboundAttachmentToolRegistrationDeps,
101
101
  ): void {
102
102
  const maxAttachmentsPerTurn =
103
103
  deps.maxAttachmentsPerTurn ?? MAX_ATTACHMENTS_PER_TURN;
@@ -120,7 +120,7 @@ export function registerTelegramAttachmentTool(
120
120
  }),
121
121
  async execute(_toolCallId, params) {
122
122
  try {
123
- return await queueTelegramAttachments({
123
+ return await queueTelegramOutboundAttachments({
124
124
  activeTurn: deps.getActiveTurn(),
125
125
  paths: params.paths,
126
126
  maxAttachmentsPerTurn,
@@ -138,7 +138,7 @@ export function registerTelegramAttachmentTool(
138
138
  });
139
139
  }
140
140
 
141
- export interface TelegramQueuedAttachmentDeliveryDeps {
141
+ export interface TelegramQueuedOutboundAttachmentDeliveryDeps {
142
142
  sendMultipart: (
143
143
  method: string,
144
144
  fields: Record<string, string>,
@@ -160,13 +160,13 @@ export interface TelegramQueuedAttachmentDeliveryDeps {
160
160
  maxAttachmentSizeBytes?: number;
161
161
  }
162
162
 
163
- export async function queueTelegramAttachments(options: {
164
- activeTurn: TelegramAttachmentQueueTargetView | undefined;
163
+ export async function queueTelegramOutboundAttachments(options: {
164
+ activeTurn: TelegramOutboundAttachmentQueueTargetView | undefined;
165
165
  paths: string[];
166
166
  maxAttachmentsPerTurn: number;
167
167
  maxAttachmentSizeBytes?: number;
168
168
  statPath?: (path: string) => Promise<{ isFile(): boolean; size?: number }>;
169
- }): Promise<TelegramAttachmentToolResult> {
169
+ }): Promise<TelegramOutboundAttachmentToolResult> {
170
170
  if (!options.activeTurn) {
171
171
  throw new Error(
172
172
  "telegram_attach can only be used while replying to an active Telegram turn",
@@ -180,7 +180,7 @@ export async function queueTelegramAttachments(options: {
180
180
  `Attachment limit reached (${options.maxAttachmentsPerTurn})`,
181
181
  );
182
182
  }
183
- const pendingAttachments: TelegramQueuedAttachmentView[] = [];
183
+ const pendingAttachments: TelegramQueuedOutboundAttachmentView[] = [];
184
184
  for (const inputPath of options.paths) {
185
185
  const stats = await (options.statPath ?? stat)(inputPath);
186
186
  if (!stats.isFile()) {
@@ -192,7 +192,7 @@ export async function queueTelegramAttachments(options: {
192
192
  stats.size > options.maxAttachmentSizeBytes
193
193
  ) {
194
194
  throw new Error(
195
- formatTelegramAttachmentSizeLimitError(
195
+ formatTelegramOutboundAttachmentSizeLimitError(
196
196
  stats.size,
197
197
  options.maxAttachmentSizeBytes,
198
198
  inputPath,
@@ -210,20 +210,20 @@ export async function queueTelegramAttachments(options: {
210
210
  content: [
211
211
  {
212
212
  type: "text",
213
- text: formatTelegramAttachmentToolResultText(added.length),
213
+ text: formatTelegramOutboundAttachmentToolResultText(added.length),
214
214
  },
215
215
  ],
216
216
  details: { paths: added },
217
217
  };
218
218
  }
219
219
 
220
- export function createTelegramQueuedAttachmentSender(
221
- deps: TelegramQueuedAttachmentDeliveryDeps,
220
+ export function createTelegramQueuedOutboundAttachmentSender(
221
+ deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
222
222
  ) {
223
223
  return async function sendQueuedAttachments(
224
- turn: TelegramQueuedAttachmentTurnView,
224
+ turn: TelegramQueuedOutboundAttachmentTurnView,
225
225
  ): Promise<void> {
226
- await sendQueuedTelegramAttachments(turn, {
226
+ await sendQueuedTelegramOutboundAttachments(turn, {
227
227
  ...deps,
228
228
  maxAttachmentSizeBytes:
229
229
  deps.maxAttachmentSizeBytes ?? TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES,
@@ -231,9 +231,9 @@ export function createTelegramQueuedAttachmentSender(
231
231
  };
232
232
  }
233
233
 
234
- export async function sendQueuedTelegramAttachments(
235
- turn: TelegramQueuedAttachmentTurnView,
236
- deps: TelegramQueuedAttachmentDeliveryDeps,
234
+ export async function sendQueuedTelegramOutboundAttachments(
235
+ turn: TelegramQueuedOutboundAttachmentTurnView,
236
+ deps: TelegramQueuedOutboundAttachmentDeliveryDeps,
237
237
  ): Promise<void> {
238
238
  for (const attachment of turn.queuedAttachments) {
239
239
  try {
@@ -241,14 +241,14 @@ export async function sendQueuedTelegramAttachments(
241
241
  const stats = await (deps.statPath ?? stat)(attachment.path);
242
242
  if (stats.size > deps.maxAttachmentSizeBytes) {
243
243
  throw new Error(
244
- formatTelegramAttachmentSizeLimitError(
244
+ formatTelegramOutboundAttachmentSizeLimitError(
245
245
  stats.size,
246
246
  deps.maxAttachmentSizeBytes,
247
247
  ),
248
248
  );
249
249
  }
250
250
  }
251
- const isPhoto = isTelegramPhotoAttachmentPath(attachment.path);
251
+ const isPhoto = isTelegramOutboundPhotoAttachmentPath(attachment.path);
252
252
  const method = isPhoto ? "sendPhoto" : "sendDocument";
253
253
  const fieldName = isPhoto ? "photo" : "document";
254
254
  const replyParameters = buildTelegramMultipartReplyParameters(