@llblab/pi-telegram 0.12.0 → 0.13.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/inbound.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  buildCommandTemplateInvocation,
12
12
  expandCommandTemplateConfigs,
13
13
  normalizeCommandTemplateConfig,
14
+ substituteCommandTemplateToken,
14
15
  type CommandTemplateConfig,
15
16
  type CommandTemplateObjectConfig,
16
17
  } from "./command-templates.ts";
@@ -28,10 +29,9 @@ export interface TelegramInboundHandlerConfig {
28
29
  mime?: string | string[];
29
30
  type?: string | string[];
30
31
  template?: string | TelegramInboundCommandTemplateConfig[];
31
- pipe?: TelegramInboundCommandTemplateConfig[];
32
32
  args?: string[];
33
33
  defaults?: Record<string, unknown>;
34
- timeout?: number;
34
+ timeout?: number | string;
35
35
  }
36
36
 
37
37
  export interface TelegramInboundHandlerFile {
@@ -62,6 +62,7 @@ export interface TelegramInboundHandlerExecOptions {
62
62
  timeout?: number;
63
63
  signal?: AbortSignal;
64
64
  stdin?: string;
65
+ retry?: number;
65
66
  }
66
67
 
67
68
  export interface TelegramInboundHandlerExecResult {
@@ -322,13 +323,28 @@ export function buildTelegramInboundHandlerInvocation(
322
323
  );
323
324
  }
324
325
 
326
+ function resolveTelegramInboundNumericControlField(
327
+ value: number | string | undefined,
328
+ values: Record<string, unknown>,
329
+ label: string,
330
+ ): number | undefined {
331
+ if (value === undefined) return undefined;
332
+ const resolved =
333
+ typeof value === "string"
334
+ ? substituteCommandTemplateToken(value, values, label)
335
+ : value;
336
+ if (resolved === "") return undefined;
337
+ const numeric = Number(resolved);
338
+ if (!Number.isFinite(numeric) || numeric < 0)
339
+ throw new Error(`Command template ${label} must be a non-negative number.`);
340
+ return numeric;
341
+ }
342
+
325
343
  function getTelegramInboundHandlerConfiguredTimeout(
326
344
  handler: TelegramInboundCommandTemplateConfig,
327
345
  ): number | undefined {
328
346
  const timeout = typeof handler === "string" ? undefined : handler.timeout;
329
- return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
330
- ? timeout
331
- : undefined;
347
+ return resolveTelegramInboundNumericControlField(timeout, {}, "timeout");
332
348
  }
333
349
 
334
350
  function getTelegramInboundHandlerTimeout(
@@ -374,8 +390,7 @@ function getTelegramInboundCompositionStepTimeout(
374
390
  function getTelegramInboundHandlerKind(
375
391
  handler: TelegramInboundHandlerConfig,
376
392
  ): string {
377
- if (Array.isArray(handler.template) || handler.pipe?.length)
378
- return "composition";
393
+ if (Array.isArray(handler.template)) return "composition";
379
394
  if (handler.template) return "template";
380
395
  return "unknown";
381
396
  }
@@ -410,7 +425,7 @@ async function executeTelegramInboundHandlerInvocation(
410
425
  cwd,
411
426
  timeout,
412
427
  ...(typeof handler === "object" && handler.retry !== undefined
413
- ? { retry: handler.retry }
428
+ ? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
414
429
  : {}),
415
430
  ...(stdin !== undefined ? { stdin } : {}),
416
431
  });
@@ -427,12 +442,6 @@ function getTelegramInboundHandlerCompositionSteps(
427
442
  handler,
428
443
  ) as TelegramInboundCommandTemplateConfig[];
429
444
  }
430
- if (handler.pipe?.length) {
431
- return expandCommandTemplateConfigs({
432
- ...handler,
433
- template: handler.pipe,
434
- }) as TelegramInboundCommandTemplateConfig[];
435
- }
436
445
  return [];
437
446
  }
438
447
 
@@ -490,7 +499,7 @@ async function executeTelegramTextHandlerInvocation(
490
499
  timeout,
491
500
  stdin: text,
492
501
  ...(typeof handler === "object" && handler.retry !== undefined
493
- ? { retry: handler.retry }
502
+ ? { retry: resolveTelegramInboundNumericControlField(handler.retry, {}, "retry") }
494
503
  : {}),
495
504
  });
496
505
  if (result.code !== 0)
@@ -524,7 +533,7 @@ async function executeTelegramTextHandler(
524
533
  : getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
525
534
  );
526
535
  } catch (error) {
527
- if (typeof step === "object" && step.critical) throw error;
536
+ if (typeof step === "object" && step.failure === "root") throw error;
528
537
  output = "";
529
538
  }
530
539
  if (index > 0 && !output) output = text;
@@ -682,7 +691,7 @@ async function executeTelegramInboundHandler(
682
691
  index === 0 ? undefined : output,
683
692
  );
684
693
  } catch (error) {
685
- if (typeof step === "object" && step.critical) throw error;
694
+ if (typeof step === "object" && step.failure === "root") throw error;
686
695
  output = "";
687
696
  }
688
697
  }
package/lib/lifecycle.ts CHANGED
@@ -182,6 +182,11 @@ export interface TelegramMessageActivityTypingDeps<TContext> {
182
182
  startTypingLoop: (ctx: TContext) => void;
183
183
  onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
184
184
  onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
185
+ recordRuntimeEvent?: (
186
+ category: string,
187
+ error: unknown,
188
+ details?: Record<string, unknown>,
189
+ ) => void;
185
190
  }
186
191
 
187
192
  export function createTelegramMessageActivityTypingHooks<
@@ -195,15 +200,27 @@ export function createTelegramMessageActivityTypingHooks<
195
200
  const ensureTyping = (ctx: TContext): void => {
196
201
  if (deps.hasActiveTurn()) deps.startTypingLoop(ctx);
197
202
  };
203
+ const handleMessageActivity = async (
204
+ phase: "start" | "update",
205
+ event: Parameters<TelegramLifecycleRegistrationDeps["onMessageStart"]>[0],
206
+ ctx: ExtensionContext,
207
+ inner: TelegramLifecycleRegistrationDeps["onMessageStart"],
208
+ ): Promise<void> => {
209
+ const typedCtx = ctx as TContext;
210
+ ensureTyping(typedCtx);
211
+ try {
212
+ await inner(event, ctx);
213
+ } catch (error) {
214
+ deps.recordRuntimeEvent?.("message-activity", error, { phase });
215
+ } finally {
216
+ ensureTyping(typedCtx);
217
+ }
218
+ };
198
219
  return {
199
- onMessageStart: async (event, ctx) => {
200
- ensureTyping(ctx as TContext);
201
- await deps.onMessageStart(event, ctx);
202
- },
203
- onMessageUpdate: async (event, ctx) => {
204
- ensureTyping(ctx as TContext);
205
- await deps.onMessageUpdate(event, ctx);
206
- },
220
+ onMessageStart: (event, ctx) =>
221
+ handleMessageActivity("start", event, ctx, deps.onMessageStart),
222
+ onMessageUpdate: (event, ctx) =>
223
+ handleMessageActivity("update", event, ctx, deps.onMessageUpdate),
207
224
  };
208
225
  }
209
226
 
package/lib/locks.ts CHANGED
@@ -125,7 +125,10 @@ export function writeLocks(path: string, locks: Record<string, unknown>): void {
125
125
  mkdirSync(dirname(path), { recursive: true });
126
126
  const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
127
127
  try {
128
- writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
128
+ writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, {
129
+ encoding: "utf8",
130
+ mode: 0o600,
131
+ });
129
132
  renameSync(tempPath, path);
130
133
  } catch (error) {
131
134
  try {
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Telegram outbound button helpers
3
+ * Zones: telegram outbound, assistant markup, callback routing
4
+ * Owns assistant-authored telegram_button extraction, button action storage, callback handling, and prompt-turn construction
5
+ */
6
+
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
10
+ import {
11
+ parseTelegramCommentAttributes,
12
+ parseTopLevelTelegramComment,
13
+ replaceTopLevelHtmlComments,
14
+ } from "./outbound-markup.ts";
15
+ import {
16
+ type PendingTelegramTurn,
17
+ truncateTelegramQueueSummary,
18
+ } from "./queue.ts";
19
+
20
+ const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
21
+ const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
22
+
23
+ export interface TelegramOutboundButtonAction {
24
+ text: string;
25
+ prompt: string;
26
+ }
27
+
28
+ export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
29
+ createdAt: number;
30
+ }
31
+
32
+ export type TelegramOutboundButtonMarkup = TelegramInlineKeyboardMarkup;
33
+
34
+ export interface TelegramButtonReplyPlan {
35
+ markdown: string;
36
+ replyMarkup?: TelegramOutboundButtonMarkup;
37
+ }
38
+
39
+ export interface TelegramButtonActionStore {
40
+ register: (action: TelegramOutboundButtonAction) => string;
41
+ resolve: (
42
+ callbackData: string | undefined,
43
+ ) => TelegramOutboundButtonAction | undefined;
44
+ }
45
+
46
+ export interface TelegramButtonCallbackQuery {
47
+ id: string;
48
+ data?: string;
49
+ message?: {
50
+ message_id?: number;
51
+ chat?: { id?: number };
52
+ };
53
+ }
54
+
55
+ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
56
+ resolveAction: (
57
+ callbackData: string | undefined,
58
+ ) => TelegramOutboundButtonAction | undefined;
59
+ answerCallbackQuery: (
60
+ callbackQueryId: string,
61
+ text?: string,
62
+ ) => Promise<void>;
63
+ enqueueButtonPrompt: (
64
+ query: TelegramButtonCallbackQuery,
65
+ action: TelegramOutboundButtonAction,
66
+ ctx: TContext,
67
+ ) => void;
68
+ }
69
+
70
+ function nowMs(): number {
71
+ return Date.now();
72
+ }
73
+
74
+ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
75
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
76
+ }
77
+
78
+ function parseButtonsCommentAttributes(input: string): {
79
+ label?: string;
80
+ prompt?: string;
81
+ } {
82
+ const attributes = parseTelegramCommentAttributes(input);
83
+ return {
84
+ ...(attributes.label ? { label: attributes.label } : {}),
85
+ ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
86
+ };
87
+ }
88
+
89
+ function parseButtonsCommentRows(
90
+ head: string,
91
+ body: string | undefined,
92
+ ): TelegramOutboundButtonAction[][] {
93
+ const trimmedHead = head.trim();
94
+
95
+ if (body === undefined) {
96
+ if (trimmedHead.startsWith(":")) {
97
+ const label = trimmedHead.slice(1).trim();
98
+ return label ? [[{ text: label, prompt: label }]] : [];
99
+ }
100
+ const attributes = parseButtonsCommentAttributes(head);
101
+ return attributes.label && attributes.prompt
102
+ ? [[{ text: attributes.label, prompt: attributes.prompt }]]
103
+ : [];
104
+ }
105
+
106
+ const label = parseButtonsCommentAttributes(head).label;
107
+ const prompt = body.trim();
108
+ if (!label || !prompt) return [];
109
+ return [[{ text: label, prompt }]];
110
+ }
111
+
112
+ export function createTelegramButtonActionStore(
113
+ options: { ttlMs?: number } = {},
114
+ ): TelegramButtonActionStore {
115
+ const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
116
+ const actions = new Map<string, TelegramOutboundButtonStoredAction>();
117
+ function cleanup(currentTime: number): void {
118
+ for (const [key, action] of actions) {
119
+ if (currentTime - action.createdAt > ttlMs) actions.delete(key);
120
+ }
121
+ }
122
+ return {
123
+ register: (action) => {
124
+ const currentTime = nowMs();
125
+ cleanup(currentTime);
126
+ const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
127
+ actions.set(key, { ...action, createdAt: currentTime });
128
+ return key;
129
+ },
130
+ resolve: (callbackData) => {
131
+ if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
132
+ return undefined;
133
+ }
134
+ const currentTime = nowMs();
135
+ cleanup(currentTime);
136
+ const action = actions.get(callbackData);
137
+ if (!action) return undefined;
138
+ return { text: action.text, prompt: action.prompt };
139
+ },
140
+ };
141
+ }
142
+
143
+ export function planTelegramButtonReply(
144
+ markdown: string,
145
+ deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
146
+ ): TelegramButtonReplyPlan {
147
+ const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
148
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
149
+ const command = parseTopLevelTelegramComment(comment, "telegram_button");
150
+ if (!command) return comment.raw;
151
+ const rows = parseButtonsCommentRows(command.head, command.body);
152
+ for (const row of rows) {
153
+ keyboard.push(
154
+ row.map((button) => ({
155
+ text: button.text,
156
+ callback_data: deps.registerAction(button),
157
+ })),
158
+ );
159
+ }
160
+ return "";
161
+ });
162
+ return {
163
+ markdown: normalizeMarkdownAfterButtonExtraction(stripped),
164
+ ...(keyboard.length > 0
165
+ ? { replyMarkup: { inline_keyboard: keyboard } }
166
+ : {}),
167
+ };
168
+ }
169
+
170
+ export function createTelegramButtonReplyPlanner(
171
+ store: Pick<TelegramButtonActionStore, "register">,
172
+ ): (markdown: string) => TelegramButtonReplyPlan {
173
+ return (markdown) =>
174
+ planTelegramButtonReply(markdown, { registerAction: store.register });
175
+ }
176
+
177
+ export function createTelegramButtonPromptTurn(options: {
178
+ chatId: number;
179
+ replyToMessageId: number;
180
+ queueOrder: number;
181
+ action: TelegramOutboundButtonAction;
182
+ }): PendingTelegramTurn {
183
+ const prompt = `[telegram] ${options.action.prompt}`;
184
+ return {
185
+ kind: "prompt",
186
+ chatId: options.chatId,
187
+ replyToMessageId: options.replyToMessageId,
188
+ sourceMessageIds: [options.replyToMessageId],
189
+ queueOrder: options.queueOrder,
190
+ queueLane: "default",
191
+ laneOrder: options.queueOrder,
192
+ queuedAttachments: [],
193
+ content: [{ type: "text", text: prompt }],
194
+ historyText: options.action.prompt,
195
+ statusSummary: truncateTelegramQueueSummary(
196
+ options.action.text || options.action.prompt,
197
+ ),
198
+ };
199
+ }
200
+
201
+ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
202
+ query: TelegramButtonCallbackQuery,
203
+ ctx: TContext,
204
+ deps: TelegramButtonCallbackHandlerDeps<TContext>,
205
+ ): Promise<boolean> {
206
+ const action = deps.resolveAction(query.data);
207
+
208
+ if (!action) {
209
+ if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
210
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
211
+ return true;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ const chatId = query.message?.chat?.id;
217
+ const messageId = query.message?.message_id;
218
+ if (typeof chatId !== "number" || typeof messageId !== "number") {
219
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
220
+ return true;
221
+ }
222
+
223
+ deps.enqueueButtonPrompt(query, action, ctx);
224
+ await deps.answerCallbackQuery(query.id, "Queued.");
225
+ return true;
226
+ }