@llblab/pi-telegram 0.9.3 → 0.9.5

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.
@@ -10,17 +10,23 @@ import { isAbsolute, resolve } from "node:path";
10
10
 
11
11
  export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
12
12
 
13
+ export type CommandTemplateMode = "sequence" | "parallel";
14
+
13
15
  export interface CommandTemplateObjectConfig {
16
+ label?: string;
17
+ mode?: CommandTemplateMode;
14
18
  template?: CommandTemplateValue;
15
19
  args?: string[];
16
20
  defaults?: Record<string, unknown>;
17
21
  timeout?: number;
22
+ delay?: number;
18
23
  output?: string;
19
24
  retry?: number;
20
25
  critical?: boolean;
26
+ repeat?: number;
21
27
  }
22
28
 
23
- export type CommandTemplateValue = string | CommandTemplateConfig[];
29
+ export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
24
30
 
25
31
  export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
26
32
 
@@ -78,6 +84,61 @@ function normalizeCommandTemplateDefaults(
78
84
  return normalized;
79
85
  }
80
86
 
87
+ function normalizeRepeat(value: number | undefined): number | undefined {
88
+ if (value === undefined) return undefined;
89
+ if (!Number.isInteger(value) || value < 1)
90
+ throw new Error("Command template repeat must be a positive integer.");
91
+ return value;
92
+ }
93
+
94
+ function pad(value: number, width: number): string {
95
+ return String(value).padStart(width, "0");
96
+ }
97
+
98
+ export function isCommandTemplateRepeatPlaceholder(name: string): boolean {
99
+ return /^_{0,6}(?:index|prev|next|repeat)$/.test(name);
100
+ }
101
+
102
+ export function getCommandTemplateRepeatDefaults(
103
+ index: number,
104
+ repeat: number,
105
+ ): Record<string, string> {
106
+ const prev = (index - 1 + repeat) % repeat;
107
+ const next = (index + 1) % repeat;
108
+ const values: Record<string, string> = {
109
+ index: String(index),
110
+ next: String(next),
111
+ prev: String(prev),
112
+ repeat: String(repeat),
113
+ };
114
+ for (const name of ["index", "prev", "next", "repeat"]) {
115
+ const numeric = Number(values[name]);
116
+ for (let underscores = 1; underscores <= 6; underscores += 1) {
117
+ values[`${"_".repeat(underscores)}${name}`] = pad(numeric, underscores + 1);
118
+ }
119
+ }
120
+ return values;
121
+ }
122
+
123
+ function expandRepeatConfig(
124
+ config: CommandTemplateObjectConfig,
125
+ context: Pick<CommandTemplateObjectConfig, "args" | "defaults">,
126
+ ): CommandTemplateObjectConfig[] | undefined {
127
+ const repeat = normalizeRepeat(config.repeat);
128
+ if (repeat === undefined) return undefined;
129
+ return Array.from({ length: repeat }, (_unused, index0) => {
130
+ const { repeat: _repeat, ...rest } = config;
131
+ return {
132
+ ...rest,
133
+ defaults: {
134
+ ...(context.defaults ?? {}),
135
+ ...(rest.defaults ?? {}),
136
+ ...getCommandTemplateRepeatDefaults(index0, repeat),
137
+ },
138
+ };
139
+ });
140
+ }
141
+
81
142
  export function expandCommandTemplateConfigs(
82
143
  config: CommandTemplateConfig,
83
144
  inherited: Pick<CommandTemplateObjectConfig, "args" | "defaults"> = {},
@@ -99,6 +160,10 @@ export function expandCommandTemplateConfigs(
99
160
  ? { defaults: { ...(inheritedDefaults ?? {}), ...ownDefaults } }
100
161
  : {}),
101
162
  };
163
+ const repeated = expandRepeatConfig(normalizedConfig, context);
164
+ if (repeated) {
165
+ return repeated.flatMap((step) => expandCommandTemplateConfigs(step, context));
166
+ }
102
167
  if (Array.isArray(normalizedConfig.template)) {
103
168
  return normalizedConfig.template.flatMap((step) =>
104
169
  expandCommandTemplateConfigs(step, context),
@@ -189,17 +254,92 @@ export function expandCommandTemplateExecutable(
189
254
  return command;
190
255
  }
191
256
 
257
+ function evaluateCommandTemplateExpression(
258
+ expression: string,
259
+ values: Record<string, string>,
260
+ ): number {
261
+ let index = 0;
262
+ const source = expression.replace(/\s+/g, "");
263
+ function peek(): string | undefined {
264
+ return source[index];
265
+ }
266
+ function consume(char: string): boolean {
267
+ if (peek() !== char) return false;
268
+ index += 1;
269
+ return true;
270
+ }
271
+ function parsePrimary(): number {
272
+ if (consume("(")) {
273
+ const value = parseExpression();
274
+ if (!consume(")")) throw new Error(`Invalid command template expression: ${expression}`);
275
+ return value;
276
+ }
277
+ const numberMatch = source.slice(index).match(/^\d+/);
278
+ if (numberMatch) {
279
+ index += numberMatch[0].length;
280
+ return Number(numberMatch[0]);
281
+ }
282
+ const nameMatch = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_-]*/);
283
+ if (nameMatch) {
284
+ index += nameMatch[0].length;
285
+ const value = values[nameMatch[0]];
286
+ if (value === undefined || !/^-?\d+$/.test(value))
287
+ throw new Error(`Invalid command template expression variable: ${nameMatch[0]}`);
288
+ return Number(value);
289
+ }
290
+ throw new Error(`Invalid command template expression: ${expression}`);
291
+ }
292
+ function parseTerm(): number {
293
+ let value = parsePrimary();
294
+ while (true) {
295
+ if (consume("*")) value *= parsePrimary();
296
+ else if (consume("/")) value = Math.trunc(value / parsePrimary());
297
+ else if (consume("%")) value %= parsePrimary();
298
+ else return value;
299
+ }
300
+ }
301
+ function parseExpression(): number {
302
+ let value = parseTerm();
303
+ while (true) {
304
+ if (consume("+")) value += parseTerm();
305
+ else if (consume("-")) value -= parseTerm();
306
+ else return value;
307
+ }
308
+ }
309
+ const value = parseExpression();
310
+ if (index !== source.length) throw new Error(`Invalid command template expression: ${expression}`);
311
+ return value;
312
+ }
313
+
314
+ function substituteCommandTemplateExpression(
315
+ content: string,
316
+ values: Record<string, string>,
317
+ ): string | undefined {
318
+ const padded = content.match(/^(_{1,6})\((.+)\)$/);
319
+ if (padded) {
320
+ return pad(evaluateCommandTemplateExpression(padded[2], values), padded[1].length + 1);
321
+ }
322
+ if (!/[()+\-*\/%]/.test(content)) return undefined;
323
+ return String(evaluateCommandTemplateExpression(content, values));
324
+ }
325
+
192
326
  export function substituteCommandTemplateToken(
193
327
  token: string,
194
328
  values: Record<string, string>,
195
329
  missingLabel = "command template",
196
330
  ): string {
197
331
  return token.replace(
198
- /\{([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?\}/g,
199
- (_match, name, inlineDefault: string | undefined) => {
200
- if (Object.hasOwn(values, name)) return values[name] ?? "";
201
- if (inlineDefault !== undefined) return inlineDefault;
202
- throw new Error(`Missing ${missingLabel} value: ${name}`);
332
+ /\{([^{}]+)\}/g,
333
+ (_match, content: string) => {
334
+ const simple = content.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:=([^}]*))?$/);
335
+ if (simple) {
336
+ const [, name, inlineDefault] = simple;
337
+ if (Object.hasOwn(values, name)) return values[name] ?? "";
338
+ if (inlineDefault !== undefined) return inlineDefault;
339
+ }
340
+ const expression = substituteCommandTemplateExpression(content, values);
341
+ if (expression !== undefined) return expression;
342
+ throw new Error(`Missing ${missingLabel} value: ${content}`);
203
343
  },
204
344
  );
205
345
  }
@@ -300,6 +440,12 @@ export function buildCommandTemplateInvocation(
300
440
  }
301
441
  if (!normalizedConfig.template)
302
442
  throw new Error(options.emptyMessage ?? "Command template is required");
443
+ if (typeof normalizedConfig.template !== "string") {
444
+ throw new Error(
445
+ options.emptyMessage ??
446
+ "Command template object cannot be executed as one command",
447
+ );
448
+ }
303
449
  const parts = splitCommandTemplate(normalizedConfig.template);
304
450
  const commandPart = parts[0];
305
451
  if (!commandPart)
package/lib/menu-queue.ts CHANGED
@@ -13,6 +13,12 @@ import * as Queue from "./queue.ts";
13
13
 
14
14
  const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
15
15
  const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
16
+ const EMPTY_QUEUE_REFRESH_TITLES = [
17
+ "<b>⌛ Queue is still empty.</b>",
18
+ "<b>🫙 Still nothing in queue.</b>",
19
+ "<b>🍃 Queue remains empty.</b>",
20
+ "<b>🕳 Nothing queued yet.</b>",
21
+ ] as const;
16
22
  type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
17
23
  interface TelegramQueueMenuItem {
18
24
  chatId: number;
@@ -55,9 +61,16 @@ function toTelegramQueueMenuItems<Context>(
55
61
  }
56
62
  function buildTelegramQueueMenuReplyMarkup(
57
63
  items: readonly TelegramQueueMenuItem[],
64
+ emptyRefreshIndex = 0,
58
65
  ): TelegramQueueMenuReplyMarkup {
59
66
  const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
60
- const refreshRow = [{ text: "🌀 Refresh", callback_data: "queue:refresh" }];
67
+ const nextEmptyRefreshIndex =
68
+ (emptyRefreshIndex + 1) % EMPTY_QUEUE_REFRESH_TITLES.length;
69
+ const refreshData =
70
+ items.length === 0
71
+ ? `queue:refresh:${nextEmptyRefreshIndex}`
72
+ : "queue:refresh";
73
+ const refreshRow = [{ text: "🌀 Refresh", callback_data: refreshData }];
61
74
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
62
75
  const rows = items.map(function buildTelegramQueueMenuRow(item, index) {
63
76
  const prefix = item.isPriority
@@ -73,7 +86,7 @@ function buildTelegramQueueMenuReplyMarkup(
73
86
  },
74
87
  ];
75
88
  });
76
- return { inline_keyboard: [backRow, ...rows, refreshRow] };
89
+ return { inline_keyboard: [backRow, refreshRow, ...rows] };
77
90
  }
78
91
  function findTelegramQueueItem<Context>(
79
92
  items: readonly Queue.TelegramQueueItem<Context>[],
@@ -215,7 +228,7 @@ async function handleTelegramQueueMenuCallback<Context>(
215
228
  await deps.answerCallbackQuery(callbackQueryId);
216
229
  return true;
217
230
  }
218
- if (data === "queue:list" || data === "queue:refresh") {
231
+ if (data === "queue:list") {
219
232
  await updateTelegramQueueMenuList(
220
233
  callbackQueryId,
221
234
  replyChatId,
@@ -224,6 +237,18 @@ async function handleTelegramQueueMenuCallback<Context>(
224
237
  );
225
238
  return true;
226
239
  }
240
+ const refreshMatch = data.match(/^queue:refresh(?::(\d+))?$/);
241
+ if (refreshMatch) {
242
+ await updateTelegramQueueMenuList(
243
+ callbackQueryId,
244
+ replyChatId,
245
+ replyMessageId,
246
+ deps,
247
+ undefined,
248
+ refreshMatch[1] === undefined ? 0 : Number(refreshMatch[1]),
249
+ );
250
+ return true;
251
+ }
227
252
  const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/);
228
253
  if (pickMatch) {
229
254
  await handleTelegramQueueMenuPick(
@@ -306,9 +331,13 @@ async function handleTelegramQueueMenuCallback<Context>(
306
331
  }
307
332
  function getTelegramQueueMenuListText(
308
333
  items: readonly TelegramQueueMenuItem[],
334
+ emptyRefreshIndex?: number,
309
335
  ): string {
310
- if (items.length === 0) return "<b>⌛ Queue is empty.</b>";
311
- return "<b>⏳ Queue:</b>";
336
+ if (items.length > 0) return "<b>⏳ Queue:</b>";
337
+ if (emptyRefreshIndex === undefined) return "<b>⌛ Queue is empty.</b>";
338
+ return EMPTY_QUEUE_REFRESH_TITLES[
339
+ emptyRefreshIndex % EMPTY_QUEUE_REFRESH_TITLES.length
340
+ ];
312
341
  }
313
342
  async function updateTelegramQueueMenuList<Context>(
314
343
  callbackQueryId: string,
@@ -316,13 +345,14 @@ async function updateTelegramQueueMenuList<Context>(
316
345
  replyMessageId: number,
317
346
  deps: TelegramQueueMenuCallbackDeps<Context>,
318
347
  notice?: string,
348
+ emptyRefreshIndex?: number,
319
349
  ): Promise<void> {
320
350
  const items = deps.getQueuedItems();
321
351
  await deps.updateQueueMessage(
322
352
  replyChatId,
323
353
  replyMessageId,
324
- getTelegramQueueMenuListText(items),
325
- buildTelegramQueueMenuReplyMarkup(items),
354
+ getTelegramQueueMenuListText(items, emptyRefreshIndex),
355
+ buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex),
326
356
  );
327
357
  await deps.answerCallbackQuery(callbackQueryId, notice);
328
358
  }
@@ -111,7 +111,6 @@ export function buildProactivePushSettingsText(): string {
111
111
  PROACTIVE_PUSH_SETTINGS_TITLE,
112
112
  "",
113
113
  "Send successful local π task results to Telegram when the bridge is connected.",
114
- "Default: off. Persists until disabled or removed from config.",
115
114
  ].join("\n");
116
115
  }
117
116
 
package/lib/preview.ts CHANGED
@@ -90,6 +90,11 @@ export interface TelegramPreviewRuntimeDeps<
90
90
  options?: { replyMarkup?: TReplyMarkup },
91
91
  ) => Promise<number | undefined>;
92
92
  canSend?: () => boolean;
93
+ recordRuntimeEvent?: (
94
+ category: string,
95
+ error: unknown,
96
+ details?: Record<string, unknown>,
97
+ ) => void;
93
98
  }
94
99
 
95
100
  export interface TelegramPreviewActiveTurn {
@@ -191,6 +196,11 @@ export interface TelegramPreviewControllerDeps<
191
196
  ms: number,
192
197
  ) => ReturnType<typeof setTimeout>;
193
198
  clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
199
+ recordRuntimeEvent?: (
200
+ category: string,
201
+ error: unknown,
202
+ details?: Record<string, unknown>,
203
+ ) => void;
194
204
  }
195
205
 
196
206
  export interface TelegramPreviewController<
@@ -421,6 +431,7 @@ export function createTelegramPreviewController<
421
431
  ),
422
432
  editRenderedMessage: deps.editRenderedMessage,
423
433
  canSend: deps.canSend,
434
+ recordRuntimeEvent: deps.recordRuntimeEvent,
424
435
  });
425
436
  return {
426
437
  getState: () => state,
@@ -645,7 +656,16 @@ export async function flushTelegramPreview<
645
656
  state.flushPromise = (async () => {
646
657
  do {
647
658
  state.flushRequested = false;
648
- await performTelegramPreviewFlush(chatId, state, deps);
659
+ try {
660
+ await performTelegramPreviewFlush(chatId, state, deps);
661
+ } catch (error) {
662
+ deps.recordRuntimeEvent?.("preview", error, {
663
+ phase: "flush",
664
+ chatId,
665
+ messageId: state.messageId,
666
+ });
667
+ break;
668
+ }
649
669
  } while (deps.getState() === state && state.flushRequested);
650
670
  })();
651
671
  try {
@@ -704,13 +724,22 @@ export async function finalizeTelegramMarkdownPreview<
704
724
  await clearTelegramPreview(chatId, deps);
705
725
  return false;
706
726
  }
707
- if (state.mode === "draft") {
708
- await deps.sendRenderedChunks(chatId, chunks, options);
709
- await clearTelegramPreview(chatId, deps);
727
+ try {
728
+ if (state.mode === "draft") {
729
+ await deps.sendRenderedChunks(chatId, chunks, options);
730
+ await clearTelegramPreview(chatId, deps);
731
+ return true;
732
+ }
733
+ if (state.messageId === undefined) return false;
734
+ await deps.editRenderedMessage(chatId, state.messageId, chunks, options);
735
+ deps.setState(undefined);
710
736
  return true;
737
+ } catch (error) {
738
+ deps.recordRuntimeEvent?.("preview", error, {
739
+ phase: "finalize-markdown",
740
+ chatId,
741
+ messageId: state.messageId,
742
+ });
743
+ return false;
711
744
  }
712
- if (state.messageId === undefined) return false;
713
- await deps.editRenderedMessage(chatId, state.messageId, chunks, options);
714
- deps.setState(undefined);
715
- return true;
716
745
  }
package/lib/queue.ts CHANGED
@@ -1023,20 +1023,28 @@ export async function handleTelegramAgentEndRuntime<
1023
1023
  if (finalText) deps.setPreviewPendingText(finalText);
1024
1024
  if (!finalText && hasOutboundArtifacts) await deps.clearPreview(turn.chatId);
1025
1025
  if (endPlan.kind === "text" && finalText) {
1026
- const finalized = await deps.finalizeMarkdownPreview(
1027
- turn.chatId,
1028
- finalText,
1029
- turn.replyToMessageId,
1030
- { replyMarkup },
1031
- );
1032
- if (!finalized) {
1033
- await deps.clearPreview(turn.chatId);
1034
- await deps.sendMarkdownReply(
1026
+ try {
1027
+ const finalized = await deps.finalizeMarkdownPreview(
1035
1028
  turn.chatId,
1036
- turn.replyToMessageId,
1037
1029
  finalText,
1030
+ turn.replyToMessageId,
1038
1031
  { replyMarkup },
1039
1032
  );
1033
+ if (!finalized) {
1034
+ await deps.clearPreview(turn.chatId);
1035
+ await deps.sendMarkdownReply(
1036
+ turn.chatId,
1037
+ turn.replyToMessageId,
1038
+ finalText,
1039
+ { replyMarkup },
1040
+ );
1041
+ }
1042
+ } catch (error) {
1043
+ deps.recordRuntimeEvent?.("delivery", error, {
1044
+ phase: "final-text",
1045
+ chatId: turn.chatId,
1046
+ replyToMessageId: turn.replyToMessageId,
1047
+ });
1040
1048
  }
1041
1049
  }
1042
1050
  if (outboundReply && deps.sendOutboundReplyArtifacts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for π",
6
6
  "type": "module",