Skip to content

Commit fec37fe

Browse files
authored
Merge pull request #76 from llblab/dev
0.17.1: Rich Markdown parser hotfix
2 parents 244ea66 + 6de66d5 commit fec37fe

13 files changed

Lines changed: 262 additions & 20 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,14 @@ The core product loop is mobile continuation: start or supervise work in the ter
6868
- 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
6969
- `/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`
7070
- 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
7272
- Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live π contexts after session replacement
7373
- 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.
7474
- In-flight `/model` switching is limited to Telegram-owned active turns; if a tool call is active, abort is delayed until the tool finishes
7575

7676
## 5.3 Telegram Delivery Semantics
7777

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
7979
- Use `docs/telegram-bot-api-rich-messages.md` as the local Bot API/Rich Messages reference for native Rich Markdown work
8080
- Formula guidance belongs in the Telegram-turn prompt contract: use `$...$` for inline math and `$$...$$` for block math; backticks intentionally render formulas as literal code
8181
- Real code blocks must stay literal and escaped

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
## 0.17.1: Rich Markdown Parser Hotfix
6+
7+
- `[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.
8+
59
## 0.17.0: Native Rich Markdown Delivery
610

711
- `[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.

docs/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ The repository uses a **Flat Domain DAG**:
5252
- `config` / `setup`: `telegram.json`, bot token setup, first-user pairing, authorization, env fallback, atomic persistence, and live config accessors.
5353
- `locks` / `polling`: singleton polling ownership, takeover/restart behavior, long-poll controller state, offset persistence, and poll-loop wiring.
5454
- `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.
5656
- `queue`: queue item contracts, lane admission/order, readiness gates, mutations, dispatch runtime, prompt/control enqueueing, and session/agent/tool lifecycle sequencing.
5757
- `runtime`: session-local coordination primitives: counters, flags, setup guard, abort handler, typing timers, dispatch flags, and reset binding.
5858
- `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
196196
Assistant delivery guarantees:
197197

198198
- 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.
199201
- Long native Markdown replies are split only at Telegram Rich Message transport limits.
200202
- 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.
201203
- 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.

lib/media.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ export interface TelegramVoice {
2929
mime_type?: string;
3030
}
3131

32+
export interface TelegramRichMessage {
33+
blocks?: unknown[];
34+
}
35+
3236
export interface TelegramReplyToMessage {
3337
message_id?: number;
3438
text?: string;
3539
caption?: string;
40+
rich_message?: TelegramRichMessage;
3641
}
3742

3843
export interface TelegramSticker {
@@ -43,6 +48,7 @@ export interface TelegramMediaMessage {
4348
message_id: number;
4449
text?: string;
4550
caption?: string;
51+
rich_message?: TelegramRichMessage;
4652
reply_to_message?: TelegramReplyToMessage;
4753
media_group_id?: string;
4854
photo?: TelegramPhotoSize[];
@@ -173,10 +179,94 @@ function isImageMimeType(mimeType: string | undefined): boolean {
173179
return mimeType?.toLowerCase().startsWith("image/") ?? false;
174180
}
175181

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+
176261
export function extractTelegramMessageText(
177262
message: TelegramMediaMessage,
178263
): string {
179-
return (message.text || message.caption || "").trim();
264+
return (
265+
extractTelegramRichMessageText(message.rich_message) ||
266+
message.text ||
267+
message.caption ||
268+
""
269+
).trim();
180270
}
181271

182272
function truncateTelegramReplyContextText(text: string): string {
@@ -188,6 +278,7 @@ export function extractTelegramReplyContextText(
188278
message: TelegramMediaMessage,
189279
): string {
190280
const quoted = (
281+
extractTelegramRichMessageText(message.reply_to_message?.rich_message) ||
191282
message.reply_to_message?.text ||
192283
message.reply_to_message?.caption ||
193284
""

lib/preview.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import type {
1010
TelegramSendMessageBody,
1111
TelegramSentMessage,
1212
} from "./telegram-api.ts";
13-
import { buildTelegramReplyParameters } from "./replies.ts";
13+
import {
14+
buildTelegramReplyParameters,
15+
normalizeTelegramNativeMarkdown,
16+
} from "./replies.ts";
1417
import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
1518
import { shouldSuppressPreviewForVoice } from "./voice.ts";
1619

@@ -298,7 +301,10 @@ export function createTelegramNativeMarkdownMessageEditor<TReplyMarkup>(deps: {
298301
deps.editMessageText({
299302
chat_id: chatId,
300303
message_id: messageId,
301-
rich_message: { markdown, skip_entity_detection: true },
304+
rich_message: {
305+
markdown: normalizeTelegramNativeMarkdown(markdown),
306+
skip_entity_detection: true,
307+
},
302308
...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
303309
});
304310
}
@@ -632,7 +638,11 @@ async function performTelegramPreviewFlush<
632638
const draftId = state.draftId ?? deps.allocateDraftId();
633639
state.draftId = draftId;
634640
try {
635-
await deps.sendDraft(chatId, draftId, snapshot.sourceText);
641+
await deps.sendDraft(
642+
chatId,
643+
draftId,
644+
normalizeTelegramNativeMarkdown(snapshot.sourceText),
645+
);
636646
deps.setDraftSupport("supported");
637647
state.mode = "draft";
638648
state.lastSentText = snapshot.text;

lib/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Telegram-originated turn context:
3030
Telegram-visible output:
3131
- Telegram is mobile-first: keep answers easy to scan, use headings/lists when useful, and avoid unnecessarily huge blocks of text.
3232
- 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.
3434
- 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.
3535
3636
Native outbound actions:

lib/replies.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,17 +257,58 @@ export async function sendTelegramPlainReply(
257257
return deps.sendRenderedChunks(chunks);
258258
}
259259

260+
function normalizeTelegramNativeMarkdownLine(line: string): string {
261+
let result = line.replace(/^( {0,3}>)[ \t]/, "$1");
262+
const codeSpans: string[] = [];
263+
result = result.replace(/`+[^`]*`+/g, (code) => {
264+
const token = `\u0000${codeSpans.length}\u0000`;
265+
codeSpans.push(code);
266+
return token;
267+
});
268+
result = result.replace(
269+
/(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
270+
(_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
271+
);
272+
return result.replace(/\u0000(\d+)\u0000/g, (_match, index) => codeSpans[Number(index)] ?? "");
273+
}
274+
275+
export function normalizeTelegramNativeMarkdown(markdown: string): string {
276+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
277+
let fence: { marker: "`" | "~"; length: number } | undefined;
278+
return lines
279+
.map((line) => {
280+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
281+
const inFence = fence !== undefined;
282+
if (!inFence && fenceMatch) {
283+
const markerText = fenceMatch[1] ?? "```";
284+
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
285+
return line;
286+
}
287+
if (
288+
inFence &&
289+
new RegExp(`^ {0,3}${fence?.marker}{${fence?.length},}\\s*$`).test(line)
290+
) {
291+
fence = undefined;
292+
return line;
293+
}
294+
if (!inFence) return normalizeTelegramNativeMarkdownLine(line);
295+
return line;
296+
})
297+
.join("\n");
298+
}
299+
260300
export function splitTelegramNativeMarkdown(markdown: string): string[] {
301+
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
261302
if (
262-
markdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
263-
countTelegramNativeMarkdownBlocks(markdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
303+
normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
304+
countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
264305
) {
265-
return [markdown];
306+
return [normalizedMarkdown];
266307
}
267308
const chunks: string[] = [];
268309
let current = "";
269310
let currentBlockCount = 0;
270-
for (const rawBlock of splitTelegramNativeMarkdownBlocks(markdown)) {
311+
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
271312
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
272313
const blockCount = countTelegramNativeMarkdownBlocks(block);
273314
const candidate = current ? `${current}\n\n${block}` : block;

lib/telegram-api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,18 @@ export interface TelegramSticker {
101101
emoji?: string;
102102
}
103103

104+
export interface TelegramRichMessage {
105+
blocks?: unknown[];
106+
is_rtl?: boolean;
107+
}
108+
104109
export interface TelegramMessage {
105110
message_id: number;
106111
chat: TelegramChat;
107112
from?: TelegramUser;
108113
text?: string;
109114
caption?: string;
115+
rich_message?: TelegramRichMessage;
110116
media_group_id?: string;
111117
photo?: TelegramPhotoSize[];
112118
document?: TelegramDocument;
@@ -160,6 +166,7 @@ export interface TelegramGuestMessage {
160166
date: number;
161167
text?: string;
162168
caption?: string;
169+
rich_message?: TelegramRichMessage;
163170
guest_query_id: string;
164171
guest_bot_caller_user?: TelegramUser;
165172
guest_bot_caller_chat?: TelegramChat;

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@llblab/pi-telegram",
3-
"version": "0.17.0",
3+
"version": "0.17.1",
44
"private": false,
55
"publishConfig": {
66
"access": "public"

0 commit comments

Comments
 (0)