@llblab/pi-telegram 0.17.0 → 0.17.2

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/AGENTS.md CHANGED
@@ -68,14 +68,14 @@ The core product loop is mobile continuation: start or supervise work in the ter
68
68
  - Abort-history mode is scoped to Telegram-owned active turns. Internally this is `foldQueuedPromptsIntoHistory`: local/non-Telegram agent starts after abort clear it so the next Telegram prompt appends to the local queue instead of absorbing older queued turns as history
69
69
  - `/start`, `/help`, and `/status` open the unified command-help/status-row/control menu; `/model`, `/thinking`, and `/queue` jump to sections directly; visible bot commands are `/start`, `/compact`, `/next`, `/continue`, `/abort`, `/stop`
70
70
  - Command/menu emoji are fixed UI adornments owned by the `commands` map; do not add a persisted emoji toggle or Settings menu until there is a real setting to own
71
- - Telegram `reply_to_message` context is prompt-only and must not affect slash-command parsing
71
+ - Telegram `reply_to_message` context is prompt-only and must not affect slash-command parsing; when Telegram includes `rich_message` blocks on a quoted rich reply, extract plain text from those blocks before falling back to raw `text`/`caption` so prompt context does not leak raw Rich Markdown source
72
72
  - Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live π contexts after session replacement
73
73
  - Do not add Telegram commands that imitate Pi interactive session replacement, navigation, or TUI rendering through private internals, ANSI terminal clearing, raw TTY injection, or a shadow `pi` subprocess. Features such as a real Telegram `/new` require a public Pi API that runs the same session-replacement path as the terminal command.
74
74
  - In-flight `/model` switching is limited to Telegram-owned active turns; if a tool call is active, abort is delayed until the tool finishes
75
75
 
76
76
  ## 5.3 Telegram Delivery Semantics
77
77
 
78
- - Assistant and guest replies use Telegram-native Rich Markdown via Rich Message APIs, not Markdown→HTML conversion. Bridge-owned UI surfaces such as commands, menus, status, queue controls, and sections should keep explicit Telegram HTML/plain rendering by default because readability and maintainability are higher there. Companion sections may explicitly choose Markdown, HTML, or plain text per view
78
+ - Assistant and guest replies use Telegram-native Rich Markdown via Rich Message APIs, not Markdown→HTML conversion. Bridge-owned UI surfaces such as commands, menus, status, queue controls, and sections should keep explicit Telegram HTML/plain rendering by default because readability and maintainability are higher there. Companion sections may explicitly choose Markdown, HTML, or plain text per view. Keep native Rich Markdown source close to model-authored Markdown, but normalize Bot-API-fragile equivalents when evidence shows a Telegram parser/client edge, such as space-after-marker blockquotes (`> quote` -> `>quote`) and dollar-prefixed ticker atoms (`$BLDR` -> `\$BLDR`) outside code fences/spans
79
79
  - Use `docs/telegram-bot-api-rich-messages.md` as the local Bot API/Rich Messages reference for native Rich Markdown work
80
80
  - Formula guidance belongs in the Telegram-turn prompt contract: use `$...$` for inline math and `$$...$$` for block math; backticks intentionally render formulas as literal code
81
81
  - Real code blocks must stay literal and escaped
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.17.2: Indented List Rich Markdown Hotfix
6
+
7
+ - `[Rich Markdown]` Neutralized indented list markers before native Rich Markdown delivery by replacing leading list indentation with non-breaking spaces while preserving top-level list markers. Impact: assistant replies with bold section headers followed by two-space-indented bullets containing slash/hyphen text, `with`, and inline code no longer render raw Markdown or truncate around the inline-code span.
8
+ - `[Preview]` Pinned the same normalization path for native draft previews and editable/final Rich Markdown messages. Impact: risky replies use consistent Markdown safety behavior across draft, fallback edit, and final delivery paths.
9
+ - `[Tests]` Added regression coverage for the discovered formatting-only and truncation-risk fixtures, including payload-tail preservation through native Markdown splitting and send delivery. Impact: future Rich Markdown parser changes are less likely to reintroduce partial Telegram messages.
10
+
11
+ ## 0.17.1: Rich Markdown Parser Hotfix
12
+
13
+ - `[Rich Markdown]` Normalize Bot-API-fragile source before native Rich Markdown delivery, including space-after-marker blockquotes and dollar-prefixed ticker atoms such as `$BLDR` / `$NTVE`, prefer Telegram `rich_message` blocks over raw `text`/`caption` when extracting quoted reply context for prompts, and keep Telegram copyability guidance generic by recommending inline code for short copyable literals. Impact: assistant replies are less likely to render as raw Markdown on Telegram Rich Message parser/client edges, replying to a native Rich Markdown bot message no longer injects raw Markdown source into `[reply]`, and prompts nudge copyable identifiers without ticker-specific bloat.
14
+
5
15
  ## 0.17.0: Native Rich Markdown Delivery
6
16
 
7
17
  - `[Rich Markdown]` Assistant and guest replies now use Telegram-native Rich Message APIs directly: final assistant Markdown goes through `sendRichMessage`, streaming drafts go through `sendRichMessageDraft`, editable fallback previews finalize through `editMessageText.rich_message`, and guest replies use `InputRichMessageContent`. Impact: model-authored Markdown reaches Telegram as native Rich Markdown instead of passing through the legacy Markdown-to-HTML assistant path.
@@ -52,7 +52,7 @@ The repository uses a **Flat Domain DAG**:
52
52
  - `config` / `setup`: `telegram.json`, bot token setup, first-user pairing, authorization, env fallback, atomic persistence, and live config accessors.
53
53
  - `locks` / `polling`: singleton polling ownership, takeover/restart behavior, long-poll controller state, offset persistence, and poll-loop wiring.
54
54
  - `updates` / `routing`: update classification, authorization planning, callbacks, edited messages, reactions, and inbound route composition.
55
- - `media` / `text-groups` / `time-injection` / `turns` / `inbound`: inbound text/media/file extraction, media-group debounce, long-text coalescing, optional `[time]` context, handler execution, and prompt-turn assembly/editing.
55
+ - `media` / `text-groups` / `time-injection` / `turns` / `inbound`: inbound text/media/file extraction, rich-message reply-context plaintext recovery, media-group debounce, long-text coalescing, optional `[time]` context, handler execution, and prompt-turn assembly/editing.
56
56
  - `queue`: queue item contracts, lane admission/order, readiness gates, mutations, dispatch runtime, prompt/control enqueueing, and session/agent/tool lifecycle sequencing.
57
57
  - `runtime`: session-local coordination primitives: counters, flags, setup guard, abort handler, typing timers, dispatch flags, and reset binding.
58
58
  - `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu-queue` / `menu-settings` / `menu` / `commands`: model identity, thinking levels, scoped model handling, menu render/callback behavior, slash commands, bot commands, and interactive controls.
@@ -196,6 +196,8 @@ Assistant replies use Telegram-native Rich Markdown. Final Markdown is sent dire
196
196
  Assistant delivery guarantees:
197
197
 
198
198
  - Model-authored Markdown is the source of truth; the bridge does not pre-render assistant Markdown to HTML.
199
+ - Before native Rich Markdown delivery, the bridge normalizes known Bot-API-fragile source forms without changing visible meaning, including space-after-marker blockquotes and dollar-prefixed ticker atoms that Telegram may otherwise treat as unterminated math.
200
+ - Quoted rich replies use Telegram `rich_message` blocks as the prompt-context source when available, so `[reply]` context receives rendered plain text instead of raw `InputRichMessage.markdown` fallback text.
199
201
  - Long native Markdown replies are split only at Telegram Rich Message transport limits.
200
202
  - Streaming previews pass assistant Markdown through to `sendRichMessageDraft` with ownership checks, voice suppression, serialized flushes, and an editable plain-message fallback when draft delivery is unavailable.
201
203
  - Preview flushes are serialized so older edits cannot race newer drafts; final delivery waits for active draft flushes and does not perform a post-final draft-clear call.
package/lib/media.ts CHANGED
@@ -29,10 +29,15 @@ export interface TelegramVoice {
29
29
  mime_type?: string;
30
30
  }
31
31
 
32
+ export interface TelegramRichMessage {
33
+ blocks?: unknown[];
34
+ }
35
+
32
36
  export interface TelegramReplyToMessage {
33
37
  message_id?: number;
34
38
  text?: string;
35
39
  caption?: string;
40
+ rich_message?: TelegramRichMessage;
36
41
  }
37
42
 
38
43
  export interface TelegramSticker {
@@ -43,6 +48,7 @@ export interface TelegramMediaMessage {
43
48
  message_id: number;
44
49
  text?: string;
45
50
  caption?: string;
51
+ rich_message?: TelegramRichMessage;
46
52
  reply_to_message?: TelegramReplyToMessage;
47
53
  media_group_id?: string;
48
54
  photo?: TelegramPhotoSize[];
@@ -173,10 +179,94 @@ function isImageMimeType(mimeType: string | undefined): boolean {
173
179
  return mimeType?.toLowerCase().startsWith("image/") ?? false;
174
180
  }
175
181
 
182
+ function getObjectField(value: unknown, field: string): unknown {
183
+ if (typeof value !== "object" || value === null || !(field in value)) {
184
+ return undefined;
185
+ }
186
+ return Reflect.get(value, field);
187
+ }
188
+
189
+ function joinRichTextParts(parts: string[], separator = ""): string {
190
+ return parts.filter(Boolean).join(separator).trim();
191
+ }
192
+
193
+ function extractTelegramRichText(value: unknown): string {
194
+ if (typeof value === "string") return value;
195
+ if (Array.isArray(value)) {
196
+ return joinRichTextParts(value.map(extractTelegramRichText));
197
+ }
198
+ if (typeof value !== "object" || value === null) return "";
199
+ const text = getObjectField(value, "text");
200
+ if (text !== undefined) return extractTelegramRichText(text);
201
+ const expression = getObjectField(value, "expression");
202
+ if (typeof expression === "string") return expression;
203
+ const alternativeText = getObjectField(value, "alternative_text");
204
+ if (typeof alternativeText === "string") return alternativeText;
205
+ return "";
206
+ }
207
+
208
+ function extractTelegramRichBlockText(block: unknown): string {
209
+ if (typeof block !== "object" || block === null) return "";
210
+ const directText = extractTelegramRichText(getObjectField(block, "text"));
211
+ if (directText) return directText;
212
+ const summary = extractTelegramRichText(getObjectField(block, "summary"));
213
+ const nestedBlocks = extractTelegramRichMessageBlocksText(
214
+ getObjectField(block, "blocks"),
215
+ );
216
+ const items = getObjectField(block, "items");
217
+ const itemText = Array.isArray(items)
218
+ ? items
219
+ .map((item) => {
220
+ const label = getObjectField(item, "label");
221
+ const body = extractTelegramRichMessageBlocksText(
222
+ getObjectField(item, "blocks"),
223
+ );
224
+ return typeof label === "string" && body ? `${label} ${body}` : body;
225
+ })
226
+ .filter(Boolean)
227
+ .join("\n")
228
+ : "";
229
+ const cells = getObjectField(block, "cells");
230
+ const cellText = Array.isArray(cells)
231
+ ? cells
232
+ .map((row) =>
233
+ Array.isArray(row)
234
+ ? row
235
+ .map((cell) => extractTelegramRichText(getObjectField(cell, "text")))
236
+ .filter(Boolean)
237
+ .join(" | ")
238
+ : "",
239
+ )
240
+ .filter(Boolean)
241
+ .join("\n")
242
+ : "";
243
+ const caption = extractTelegramRichText(getObjectField(block, "caption"));
244
+ return joinRichTextParts(
245
+ [summary, nestedBlocks, itemText, cellText, caption],
246
+ "\n",
247
+ );
248
+ }
249
+
250
+ function extractTelegramRichMessageBlocksText(blocks: unknown): string {
251
+ if (!Array.isArray(blocks)) return "";
252
+ return joinRichTextParts(blocks.map(extractTelegramRichBlockText), "\n\n");
253
+ }
254
+
255
+ function extractTelegramRichMessageText(
256
+ richMessage: TelegramRichMessage | undefined,
257
+ ): string {
258
+ return extractTelegramRichMessageBlocksText(richMessage?.blocks);
259
+ }
260
+
176
261
  export function extractTelegramMessageText(
177
262
  message: TelegramMediaMessage,
178
263
  ): string {
179
- return (message.text || message.caption || "").trim();
264
+ return (
265
+ extractTelegramRichMessageText(message.rich_message) ||
266
+ message.text ||
267
+ message.caption ||
268
+ ""
269
+ ).trim();
180
270
  }
181
271
 
182
272
  function truncateTelegramReplyContextText(text: string): string {
@@ -188,6 +278,7 @@ export function extractTelegramReplyContextText(
188
278
  message: TelegramMediaMessage,
189
279
  ): string {
190
280
  const quoted = (
281
+ extractTelegramRichMessageText(message.reply_to_message?.rich_message) ||
191
282
  message.reply_to_message?.text ||
192
283
  message.reply_to_message?.caption ||
193
284
  ""
package/lib/preview.ts CHANGED
@@ -10,7 +10,10 @@ import type {
10
10
  TelegramSendMessageBody,
11
11
  TelegramSentMessage,
12
12
  } from "./telegram-api.ts";
13
- import { buildTelegramReplyParameters } from "./replies.ts";
13
+ import {
14
+ buildTelegramReplyParameters,
15
+ normalizeTelegramNativeMarkdown,
16
+ } from "./replies.ts";
14
17
  import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
15
18
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
16
19
 
@@ -298,7 +301,10 @@ export function createTelegramNativeMarkdownMessageEditor<TReplyMarkup>(deps: {
298
301
  deps.editMessageText({
299
302
  chat_id: chatId,
300
303
  message_id: messageId,
301
- rich_message: { markdown, skip_entity_detection: true },
304
+ rich_message: {
305
+ markdown: normalizeTelegramNativeMarkdown(markdown),
306
+ skip_entity_detection: true,
307
+ },
302
308
  ...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
303
309
  });
304
310
  }
@@ -632,7 +638,11 @@ async function performTelegramPreviewFlush<
632
638
  const draftId = state.draftId ?? deps.allocateDraftId();
633
639
  state.draftId = draftId;
634
640
  try {
635
- await deps.sendDraft(chatId, draftId, snapshot.sourceText);
641
+ await deps.sendDraft(
642
+ chatId,
643
+ draftId,
644
+ normalizeTelegramNativeMarkdown(snapshot.sourceText),
645
+ );
636
646
  deps.setDraftSupport("supported");
637
647
  state.mode = "draft";
638
648
  state.lastSentText = snapshot.text;
package/lib/prompts.ts CHANGED
@@ -30,7 +30,7 @@ Telegram-originated turn context:
30
30
  Telegram-visible output:
31
31
  - Telegram is mobile-first: keep answers easy to scan, use headings/lists when useful, and avoid unnecessarily huge blocks of text.
32
32
  - For formulas, use math delimiters like \`$E = mc^2$\` for inline formulas and \`$$\\nE = mc^2\\n$$\` for block formulas; do not wrap formulas in backticks unless they should render as literal code.
33
- - Wide monospace blocks can become unreadable on mobile; use them only when structure or literal code requires them.
33
+ - Use inline code for short copyable literals (commands, paths, IDs, symbols); avoid wide monospace blocks unless structure or literal code requires them.
34
34
  - For requested/generated files, call \`telegram_attach(local_path)\`; during Telegram turns it attaches files to the active reply, and during explicit local/TUI Telegram-delivery requests it sends files directly to the paired/default chat or an explicit \`chat_id\`. If a local/TUI user explicitly asks to send a text message to Telegram, use \`telegram_message\` with Markdown text; embed the same top-level \`telegram_button\` comments when inline prompt buttons are needed, because Telegram buttons must belong to a message. Direct local/TUI Telegram delivery requires this π instance to own \`/telegram-connect\`; if ownership is elsewhere, connect/take over first instead of bypassing the lock.
35
35
 
36
36
  Native outbound actions:
package/lib/replies.ts CHANGED
@@ -257,17 +257,72 @@ export async function sendTelegramPlainReply(
257
257
  return deps.sendRenderedChunks(chunks);
258
258
  }
259
259
 
260
+ function normalizeIndentedTelegramNativeMarkdownList(line: string): string {
261
+ return line.replace(
262
+ /^( +|\t+)([-*+] |\d+\. )/,
263
+ (_match, indent: string, marker: string) => {
264
+ const visibleIndent = indent
265
+ .replace(/ /g, "\u00A0")
266
+ .replace(/\t/g, "\u00A0\u00A0");
267
+ return `${visibleIndent}${marker}`;
268
+ },
269
+ );
270
+ }
271
+
272
+ function normalizeTelegramNativeMarkdownLine(line: string): string {
273
+ let result = normalizeIndentedTelegramNativeMarkdownList(
274
+ line.replace(/^( {0,3}>)[ \t]/, "$1"),
275
+ );
276
+ const codeSpans: string[] = [];
277
+ result = result.replace(/`+[^`]*`+/g, (code) => {
278
+ const token = `\u0000${codeSpans.length}\u0000`;
279
+ codeSpans.push(code);
280
+ return token;
281
+ });
282
+ result = result.replace(
283
+ /(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
284
+ (_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
285
+ );
286
+ return result.replace(/\u0000(\d+)\u0000/g, (_match, index) => codeSpans[Number(index)] ?? "");
287
+ }
288
+
289
+ export function normalizeTelegramNativeMarkdown(markdown: string): string {
290
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
291
+ let fence: { marker: "`" | "~"; length: number } | undefined;
292
+ return lines
293
+ .map((line) => {
294
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
295
+ const inFence = fence !== undefined;
296
+ if (!inFence && fenceMatch) {
297
+ const markerText = fenceMatch[1] ?? "```";
298
+ fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
299
+ return line;
300
+ }
301
+ if (
302
+ inFence &&
303
+ new RegExp(`^ {0,3}${fence?.marker}{${fence?.length},}\\s*$`).test(line)
304
+ ) {
305
+ fence = undefined;
306
+ return line;
307
+ }
308
+ if (!inFence) return normalizeTelegramNativeMarkdownLine(line);
309
+ return line;
310
+ })
311
+ .join("\n");
312
+ }
313
+
260
314
  export function splitTelegramNativeMarkdown(markdown: string): string[] {
315
+ const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
261
316
  if (
262
- markdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
263
- countTelegramNativeMarkdownBlocks(markdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
317
+ normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
318
+ countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
264
319
  ) {
265
- return [markdown];
320
+ return [normalizedMarkdown];
266
321
  }
267
322
  const chunks: string[] = [];
268
323
  let current = "";
269
324
  let currentBlockCount = 0;
270
- for (const rawBlock of splitTelegramNativeMarkdownBlocks(markdown)) {
325
+ for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
271
326
  for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
272
327
  const blockCount = countTelegramNativeMarkdownBlocks(block);
273
328
  const candidate = current ? `${current}\n\n${block}` : block;
@@ -101,12 +101,18 @@ export interface TelegramSticker {
101
101
  emoji?: string;
102
102
  }
103
103
 
104
+ export interface TelegramRichMessage {
105
+ blocks?: unknown[];
106
+ is_rtl?: boolean;
107
+ }
108
+
104
109
  export interface TelegramMessage {
105
110
  message_id: number;
106
111
  chat: TelegramChat;
107
112
  from?: TelegramUser;
108
113
  text?: string;
109
114
  caption?: string;
115
+ rich_message?: TelegramRichMessage;
110
116
  media_group_id?: string;
111
117
  photo?: TelegramPhotoSize[];
112
118
  document?: TelegramDocument;
@@ -160,6 +166,7 @@ export interface TelegramGuestMessage {
160
166
  date: number;
161
167
  text?: string;
162
168
  caption?: string;
169
+ rich_message?: TelegramRichMessage;
163
170
  guest_query_id: string;
164
171
  guest_bot_caller_user?: TelegramUser;
165
172
  guest_bot_caller_chat?: TelegramChat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"