@llblab/pi-telegram 0.16.5 → 0.16.6

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/BACKLOG.md CHANGED
@@ -4,3 +4,5 @@
4
4
  - Context: `pi-telegram` is an extension/mobile companion, not a PTY supervisor. A soft `/new` that mutates session internals or filters context without TUI/runtime parity breaks the product boundary.
5
5
  - Requirement: only add Telegram `/new` when Pi exposes a safe public API that invokes the same session-replacement path as terminal `/new`, including lifecycle, active-run handling, and TUI rerender semantics.
6
6
  - Rejected for this extension: raw TTY injection, ANSI terminal clearing, private TUI container mutation, or running a shadow `pi` subprocess to control the current session.
7
+
8
+ - [ ] Consider splitting `lib/bindings.ts` if lifecycle/tool wiring grows further.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.16.6: Telegram Review Hardening Hotfix
6
+
7
+ - `[Guest Mode]` Deny `guest_message` updates until the bridge already has a paired Telegram user. Impact: guest mode can no longer become the first pairing surface or trigger guest file/handler processing before explicit DM pairing.
8
+ - `[Lifecycle]` Unref the compaction observer fallback timer when the host timer supports it. Impact: headless or shutdown paths are less likely to linger until the 5-minute safety timeout.
9
+ - `[Shutdown]` Stop polling before clearing active-turn/abort state, keep the abort controller visible until the polling promise settles, and record typing-cleanup failures without skipping polling abort. Impact: session shutdown and polling cleanup ordering is more deterministic.
10
+ - `[Replies]` Scope transport-level reply deduplication by chat id. Impact: equal Telegram message ids in different chats no longer suppress valid reply metadata for each other.
11
+ - `[Buttons]` Consume one-shot `telegram_button` callback actions after the first successful resolve. Impact: repeated taps on an old assistant-authored button no longer enqueue duplicate prompts.
12
+ - `[Buttons]` Centralized Telegram `callback_data` byte-limit guards for generated inline keyboards outside the section helper path. Impact: oversized generated button callbacks fail locally before Telegram rejects the message.
13
+ - `[Diagnostics]` Record `answerCallbackQuery` transport failures in runtime API diagnostics while keeping callback handling non-fatal. Impact: `/telegram-status` can explain failed Telegram callback acknowledgements instead of losing the signal silently.
14
+ - `[Tests]` Added regressions for shutdown during pending control, long-text, and media-group dispatch, for settings menu callbacks persisting voice/time changes to `telegram.json`, for malformed/boundary Markdown rendering, and for runtime outbound delivery retrying a transient Telegram API failure. Impact: high-risk queue/timer/settings/rendering/API paths are pinned at the bridge boundary.
15
+ - `[Docs]` Documented which environment-driven transport defaults should be set before launch because module-load constants intentionally capture them.
16
+ - `[Backlog]` Captured and narrowed the non-blocking 2026-06 review-swarm follow-ups for lifecycle shutdown hardening, reply/callback/button state cleanup, validation coverage, and bindings maintainability.
17
+
5
18
  ## 0.16.5: Context-Aware Prompt Guidance Hotfix
6
19
 
7
20
  - `[Prompt Guidance]` Made before-agent-start Telegram guidance context-aware: unconfigured sessions receive no bridge suffix, local/TUI prompts receive only explicit direct-delivery guidance, and Telegram-originated turns keep the full inbound, phone-width, voice, and button contract. Impact: ordinary local replies no longer get raw Telegram action-comment syntax unless the current turn actually comes from Telegram.
package/README.md CHANGED
@@ -68,6 +68,8 @@ Most day-to-day controls live in the Telegram menu or π commands. A few importa
68
68
  - **Inbound file limit**: `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES` changes the default 50 MiB Telegram download limit.
69
69
  - **Outbound attachment limit**: `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` changes the default 50 MiB `telegram_attach` delivery limit.
70
70
 
71
+ Set these variables before launching π. Some transport defaults (notably Telegram temp directory and inbound/outbound byte-limit constants) are intentionally captured when the extension modules load, while setup-token defaults and agent-dir lookups used by config/locks are read through their runtime helpers.
72
+
71
73
  ## Use
72
74
 
73
75
  Once paired, chat with your bot in Telegram. Text, images, files, replies, edits, media groups, and configured handler output are forwarded into π as Telegram-originated turns.
@@ -117,7 +117,7 @@ Deleting `locks.json` resets runtime ownership without deleting Telegram configu
117
117
 
118
118
  1. Poll updates through `getUpdates`.
119
119
  2. Persist update offsets only after successful handling; repeated handler failures are bounded.
120
- 3. Filter to the paired private user.
120
+ 3. Filter to the paired private user; guest-mode updates require an existing paired user and cannot establish first pairing.
121
121
  4. Dispatch owned callbacks and controls before fallback prompt forwarding.
122
122
  5. Coalesce media groups and likely split long text when needed.
123
123
  6. Download files into `~/.pi/agent/tmp/telegram` with size limits and partial-download cleanup.
package/lib/keyboard.ts CHANGED
@@ -12,3 +12,42 @@ export interface TelegramInlineKeyboardButton {
12
12
  export interface TelegramInlineKeyboardMarkup {
13
13
  inline_keyboard: TelegramInlineKeyboardButton[][];
14
14
  }
15
+
16
+ export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
17
+
18
+ export function getTelegramCallbackDataByteLength(value: string): number {
19
+ return new TextEncoder().encode(value).byteLength;
20
+ }
21
+
22
+ export function assertTelegramCallbackData(
23
+ callbackData: string,
24
+ context = "Telegram callback_data",
25
+ ): string {
26
+ const byteLength = getTelegramCallbackDataByteLength(callbackData);
27
+ if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
28
+ throw new Error(
29
+ `${context} exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
30
+ );
31
+ }
32
+ return callbackData;
33
+ }
34
+
35
+ export function assertTelegramInlineKeyboardCallbackData(
36
+ replyMarkup: unknown,
37
+ context = "Telegram inline keyboard callback_data",
38
+ ): void {
39
+ if (!replyMarkup || typeof replyMarkup !== "object") return;
40
+ const keyboard = (replyMarkup as { inline_keyboard?: unknown })
41
+ .inline_keyboard;
42
+ if (!Array.isArray(keyboard)) return;
43
+ for (const row of keyboard) {
44
+ if (!Array.isArray(row)) continue;
45
+ for (const button of row) {
46
+ if (!button || typeof button !== "object") continue;
47
+ const callbackData = (button as { callback_data?: unknown })
48
+ .callback_data;
49
+ if (typeof callbackData !== "string") continue;
50
+ assertTelegramCallbackData(callbackData, context);
51
+ }
52
+ }
53
+ }
package/lib/lifecycle.ts CHANGED
@@ -138,6 +138,11 @@ export function createTelegramSessionContextTracker(
138
138
 
139
139
  type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
140
140
 
141
+ function unrefTelegramLifecycleTimer(timer: TelegramLifecycleTimer): void {
142
+ if (!timer || typeof timer !== "object") return;
143
+ if (typeof timer.unref === "function") timer.unref();
144
+ }
145
+
141
146
  export interface TelegramCompactionObserverRuntimeDeps<TContext> {
142
147
  setCompactionInProgress: (inProgress: boolean) => void;
143
148
  updateStatus: (ctx: TContext) => void;
@@ -196,6 +201,7 @@ export function createTelegramCompactionObserverRuntime<TContext>(
196
201
  );
197
202
  requestDispatch();
198
203
  }, timeoutMs);
204
+ unrefTelegramLifecycleTimer(fallbackTimer);
199
205
  },
200
206
  onSessionCompact: (_event, ctx) => {
201
207
  clearFallbackTimer();
@@ -474,6 +474,7 @@ export async function sendQueuedTelegramOutboundAttachments(
474
474
  const method = isPhoto ? "sendPhoto" : "sendDocument";
475
475
  const fieldName = isPhoto ? "photo" : "document";
476
476
  const replyParameters = buildTelegramMultipartReplyParameters(
477
+ turn.chatId,
477
478
  turn.replyToMessageId,
478
479
  );
479
480
  await deps.sendMultipart(
@@ -135,6 +135,7 @@ export function createTelegramButtonActionStore(
135
135
  cleanup(currentTime);
136
136
  const action = actions.get(callbackData);
137
137
  if (!action) return undefined;
138
+ actions.delete(callbackData);
138
139
  return { text: action.text, prompt: action.prompt };
139
140
  },
140
141
  };
@@ -7,6 +7,7 @@
7
7
  import { unlink } from "node:fs/promises";
8
8
  import { basename, extname } from "node:path";
9
9
 
10
+ import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
10
11
  import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
11
12
 
12
13
  export interface TelegramVoiceReplyTurnView {
@@ -125,6 +126,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
125
126
  },
126
127
  ): Promise<void> {
127
128
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
129
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
128
130
  await sendVoiceChatAction(deps, turn.chatId);
129
131
  const replyParameters = buildVoiceReplyParameters(
130
132
  options?.replyToPrompt,
package/lib/polling.ts CHANGED
@@ -187,11 +187,21 @@ export function shouldStartTelegramPolling(
187
187
  export async function stopTelegramPollingRuntime<TContext>(
188
188
  deps: TelegramPollingRuntimeDeps<TContext>,
189
189
  ): Promise<void> {
190
- deps.stopTypingLoop();
191
- deps.getPollingController()?.abort();
192
- deps.setPollingController(undefined);
193
- await deps.getPollingPromise()?.catch(() => undefined);
194
- deps.setPollingPromise(undefined);
190
+ const pollingPromise = deps.getPollingPromise();
191
+ const pollingController = deps.getPollingController();
192
+ try {
193
+ deps.stopTypingLoop();
194
+ } catch (error) {
195
+ deps.recordRuntimeEvent?.("polling", error, { phase: "typing-stop" });
196
+ }
197
+ pollingController?.abort();
198
+ await pollingPromise?.catch(() => undefined);
199
+ if (deps.getPollingPromise() === pollingPromise) {
200
+ deps.setPollingPromise(undefined);
201
+ }
202
+ if (deps.getPollingController() === pollingController) {
203
+ deps.setPollingController(undefined);
204
+ }
195
205
  }
196
206
 
197
207
  function updateTelegramPollingStatusSafely<TContext>(
@@ -224,9 +234,12 @@ export function startTelegramPollingRuntime<TContext>(
224
234
  }
225
235
  const controller = deps.createAbortController?.() ?? new AbortController();
226
236
  deps.setPollingController(controller);
227
- const promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
228
- deps.setPollingPromise(undefined);
229
- deps.setPollingController(undefined);
237
+ let promise: Promise<void>;
238
+ promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
239
+ if (deps.getPollingPromise() === promise) deps.setPollingPromise(undefined);
240
+ if (deps.getPollingController() === controller) {
241
+ deps.setPollingController(undefined);
242
+ }
230
243
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
231
244
  recordRuntimeEvent: deps.recordRuntimeEvent,
232
245
  });
package/lib/preview.ts CHANGED
@@ -241,6 +241,7 @@ export interface TelegramPreviewMessageTransportDeps {
241
241
  sendMessage: (body: TelegramSendMessageBody) => Promise<TelegramSentMessage>;
242
242
  editMessageText: (body: TelegramEditMessageTextBody) => Promise<unknown>;
243
243
  buildReplyParameters?: (
244
+ chatId: number,
244
245
  replyToMessageId: number | undefined,
245
246
  ) => TelegramReplyParameters | undefined;
246
247
  }
@@ -252,7 +253,7 @@ export function createTelegramPreviewMessageTransport(
252
253
  deps.buildReplyParameters ?? buildTelegramReplyParameters;
253
254
  return {
254
255
  sendMessage: (chatId, text, options, replyToMessageId) => {
255
- const replyParameters = getReplyParameters(replyToMessageId);
256
+ const replyParameters = getReplyParameters(chatId, replyToMessageId);
256
257
  return deps.sendMessage({
257
258
  chat_id: chatId,
258
259
  text,
package/lib/queue.ts CHANGED
@@ -1439,6 +1439,7 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
1439
1439
  deps: TelegramSessionShutdownRuntimeDeps<TQueueItem>,
1440
1440
  ): Promise<void> {
1441
1441
  deps.unbindDeferredDispatchContext?.();
1442
+ await deps.stopPolling();
1442
1443
  deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
1443
1444
  deps.clearPendingMediaGroups();
1444
1445
  deps.clearModelMenuState();
@@ -1448,7 +1449,6 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
1448
1449
  }
1449
1450
  deps.clearActiveTurn();
1450
1451
  deps.clearAbort();
1451
- await deps.stopPolling();
1452
1452
  }
1453
1453
 
1454
1454
  export type TelegramSessionLifecycleRuntimeDeps<
package/lib/replies.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
5
5
  */
6
6
 
7
+ import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
7
8
  import type {
8
9
  TelegramReplyParameters,
9
10
  TelegramSentMessage,
@@ -50,25 +51,29 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
50
51
 
51
52
  // --- Transport-level dedup ---
52
53
 
53
- let lastRepliedToMessageId: number | undefined;
54
+ const lastRepliedToMessageIdByChat = new Map<number, number>();
54
55
 
55
56
  export function resetTransportReplyDedup(): void {
56
- lastRepliedToMessageId = undefined;
57
+ lastRepliedToMessageIdByChat.clear();
57
58
  }
58
59
 
59
60
  export function buildTelegramReplyParameters(
61
+ chatId: number,
60
62
  messageId: number | undefined,
61
63
  ): TelegramReplyParameters | undefined {
62
64
  if (messageId === undefined) return undefined;
63
- if (messageId === lastRepliedToMessageId) return undefined;
64
- lastRepliedToMessageId = messageId;
65
+ if (lastRepliedToMessageIdByChat.get(chatId) === messageId) {
66
+ return undefined;
67
+ }
68
+ lastRepliedToMessageIdByChat.set(chatId, messageId);
65
69
  return { message_id: messageId, allow_sending_without_reply: true };
66
70
  }
67
71
 
68
72
  export function buildTelegramMultipartReplyParameters(
73
+ chatId: number,
69
74
  messageId: number | undefined,
70
75
  ): string | undefined {
71
- const parameters = buildTelegramReplyParameters(messageId);
76
+ const parameters = buildTelegramReplyParameters(chatId, messageId);
72
77
  return parameters ? JSON.stringify(parameters) : undefined;
73
78
  }
74
79
 
@@ -178,11 +183,12 @@ export async function sendTelegramRenderedChunks<TReplyMarkup>(
178
183
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
179
184
  options?: { replyMarkup?: TReplyMarkup; replyToMessageId?: number },
180
185
  ): Promise<number | undefined> {
186
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
181
187
  let lastMessageId: number | undefined;
182
188
  for (const [index, chunk] of chunks.entries()) {
183
189
  const replyParameters =
184
190
  index === 0
185
- ? buildTelegramReplyParameters(options?.replyToMessageId)
191
+ ? buildTelegramReplyParameters(chatId, options?.replyToMessageId)
186
192
  : undefined;
187
193
  const sent = await deps.sendMessage({
188
194
  chat_id: chatId,
@@ -204,6 +210,7 @@ export async function editTelegramRenderedMessage<TReplyMarkup>(
204
210
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
205
211
  options?: { replyMarkup?: TReplyMarkup },
206
212
  ): Promise<number | undefined> {
213
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
207
214
  if (chunks.length === 0) return messageId;
208
215
  const [firstChunk, ...remainingChunks] = chunks;
209
216
  await deps.editMessage({
package/lib/sections.ts CHANGED
@@ -4,10 +4,12 @@
4
4
  * Owns section registration, global registry binding, token mapping, main-menu/settings row injection, and section callback dispatch
5
5
  */
6
6
 
7
- import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
7
+ import {
8
+ assertTelegramCallbackData,
9
+ type TelegramInlineKeyboardMarkup,
10
+ } from "./keyboard.ts";
8
11
 
9
12
  const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
10
- const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
11
13
 
12
14
  // --- Core Types ---
13
15
 
@@ -301,10 +303,6 @@ const BACK_NAV_ROW = {
301
303
  text: "⬆️ Back",
302
304
  } as const;
303
305
 
304
- function getUtf8ByteLength(value: string): number {
305
- return new TextEncoder().encode(value).byteLength;
306
- }
307
-
308
306
  function sectionErrorMessage(error: unknown): string {
309
307
  return error instanceof Error ? error.message : String(error);
310
308
  }
@@ -317,13 +315,7 @@ function buildTelegramSectionCallbackData(
317
315
  const data = payload
318
316
  ? `section:${token}:${action}:${payload}`
319
317
  : `section:${token}:${action}`;
320
- const byteLength = getUtf8ByteLength(data);
321
- if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
322
- throw new Error(
323
- `Telegram section callback_data exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
324
- );
325
- }
326
- return data;
318
+ return assertTelegramCallbackData(data, "Telegram section callback_data");
327
319
  }
328
320
 
329
321
  function prependBackRow(
@@ -234,6 +234,14 @@ export interface TelegramFileDownloadOptions {
234
234
  maxFileSizeBytes?: number;
235
235
  }
236
236
 
237
+ export interface TelegramAnswerCallbackQueryOptions {
238
+ recordRuntimeEvent?: (
239
+ kind: "api",
240
+ error: unknown,
241
+ details?: Record<string, unknown>,
242
+ ) => void;
243
+ }
244
+
237
245
  export interface TelegramApiClient {
238
246
  call: <TResponse>(
239
247
  method: string,
@@ -664,6 +672,7 @@ export async function answerTelegramCallbackQuery(
664
672
  botToken: string | undefined,
665
673
  callbackQueryId: string,
666
674
  text?: string,
675
+ options: TelegramAnswerCallbackQueryOptions = {},
667
676
  ): Promise<void> {
668
677
  try {
669
678
  await callTelegram<boolean>(
@@ -673,8 +682,10 @@ export async function answerTelegramCallbackQuery(
673
682
  ? { callback_query_id: callbackQueryId, text }
674
683
  : { callback_query_id: callbackQueryId },
675
684
  );
676
- } catch {
677
- // ignore
685
+ } catch (error) {
686
+ options.recordRuntimeEvent?.("api", error, {
687
+ method: "answerCallbackQuery",
688
+ });
678
689
  }
679
690
  }
680
691
 
@@ -705,7 +716,9 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
705
716
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
706
717
  }): TelegramBridgeApiRuntime {
707
718
  return createTelegramBridgeApiRuntime({
708
- client: createTelegramApiClient(deps.getBotToken),
719
+ client: createTelegramApiClient(deps.getBotToken, {
720
+ recordRuntimeEvent: deps.recordRuntimeEvent,
721
+ }),
709
722
  tempDir: getTelegramApiTempDir(),
710
723
  maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
711
724
  tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
@@ -834,8 +847,14 @@ export function createTelegramBridgeApiRuntime(
834
847
  throw error;
835
848
  }
836
849
  },
837
- answerCallbackQuery: (callbackQueryId, text) => {
838
- return deps.client.answerCallbackQuery(callbackQueryId, text);
850
+ answerCallbackQuery: async (callbackQueryId, text) => {
851
+ try {
852
+ await deps.client.answerCallbackQuery(callbackQueryId, text);
853
+ } catch (error) {
854
+ deps.recordRuntimeEvent("api", error, {
855
+ method: "answerCallbackQuery",
856
+ });
857
+ }
839
858
  },
840
859
  answerGuestQuery: (
841
860
  guestQueryId: string,
@@ -876,6 +895,7 @@ export function createTelegramBridgeApiRuntime(
876
895
  */
877
896
  export function createTelegramApiClient(
878
897
  getBotToken: () => string | undefined,
898
+ options: TelegramAnswerCallbackQueryOptions = {},
879
899
  ): TelegramApiClient {
880
900
  return {
881
901
  call: async (method, body, options) => {
@@ -909,7 +929,12 @@ export function createTelegramApiClient(
909
929
  );
910
930
  },
911
931
  answerCallbackQuery: async (callbackQueryId, text) => {
912
- await answerTelegramCallbackQuery(getBotToken(), callbackQueryId, text);
932
+ await answerTelegramCallbackQuery(
933
+ getBotToken(),
934
+ callbackQueryId,
935
+ text,
936
+ options,
937
+ );
913
938
  },
914
939
  };
915
940
  }
package/lib/updates.ts CHANGED
@@ -406,7 +406,8 @@ export function buildTelegramUpdateExecutionPlan<
406
406
  return {
407
407
  kind: "guest",
408
408
  guestMessage: action.guestMessage,
409
- shouldDeny: action.authorization.kind === "deny",
409
+ // Guest mode is an extension of an already paired bridge, not a pairing surface.
410
+ shouldDeny: action.authorization.kind !== "allow",
410
411
  };
411
412
  }
412
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.16.5",
3
+ "version": "0.16.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"