diff --git a/src/bot/messages/telegram-text.ts b/src/bot/messages/telegram-text.ts index 2e3d7060..e916f2c9 100644 --- a/src/bot/messages/telegram-text.ts +++ b/src/bot/messages/telegram-text.ts @@ -6,9 +6,16 @@ import { } from "./send-with-markdown-fallback.js"; import type { TelegramRenderedPart } from "../render/types.js"; -type SendMessageApi = Pick, "sendMessage">; +type SendMessageApi = Pick, "sendMessage"> & { + sendRichMessage?: Api["sendRichMessage"]; +}; type EditMessageApi = Pick, "editMessageText">; -type SendDraftApi = Pick, "sendMessageDraft">; +type SendDraftApi = Pick, "sendMessageDraft"> & { + sendRichMessageDraft?: Api["sendRichMessageDraft"]; +}; + +type RichMessageParam = Parameters>[1]; +type RichMessageDraftParam = Parameters>[2]; type TelegramSendMessageOptions = Parameters[2]; type TelegramEditMessageOptions = Parameters[3]; @@ -86,9 +93,94 @@ function stripRichFormattingOptions, + part: Pick< + TelegramRenderedPart, + "text" | "entities" | "tableRows" | "codeDetails" | "thinkingText" + >, ): string { - return `${part.text}\n${JSON.stringify(part.entities ?? null)}`; + return `${part.text}\n${JSON.stringify(part.entities ?? null)}\n${JSON.stringify( + part.tableRows ?? null, + )}\n${JSON.stringify(part.codeDetails ?? null)}\n${JSON.stringify(part.thinkingText ?? null)}`; +} + +function buildNativeTableRichMessage(part: TelegramRenderedPart): RichMessageParam | null { + if (!part.tableRows?.length) { + return null; + } + + const cells = part.tableRows.map((row, rowIndex) => + row.map((cell) => ({ + text: String(cell ?? ""), + ...(rowIndex === 0 ? { is_header: true as const } : {}), + align: "left" as const, + valign: "top" as const, + })), + ); + + return { + blocks: [ + { + type: "table", + cells, + is_bordered: true, + }, + ], + }; +} + +function buildNativeCodeDetailsMessage(part: TelegramRenderedPart): RichMessageParam | null { + if (!part.codeDetails) { + return null; + } + + const { language, text } = part.codeDetails; + const lineCount = text.split("\n").length; + const summary = language + ? `Code — ${language} (${lineCount} lines)` + : `Code (${lineCount} lines)`; + + return { + blocks: [ + { + type: "details", + summary, + blocks: [ + { + type: "pre", + text, + ...(language ? { language } : {}), + }, + ], + }, + ], + }; +} + +function buildNativeThinkingMessage(part: TelegramRenderedPart): RichMessageParam | null { + if (!part.thinkingText) { + return null; + } + + return { + blocks: [ + { + type: "thinking", + text: part.thinkingText, + }, + ], + }; +} + +function buildNativeRichMessage(part: TelegramRenderedPart): RichMessageParam | null { + return buildNativeTableRichMessage(part) ?? buildNativeCodeDetailsMessage(part); +} + +function buildNativeDraftRichMessage(part: TelegramRenderedPart): RichMessageParam | null { + return ( + buildNativeTableRichMessage(part) ?? + buildNativeCodeDetailsMessage(part) ?? + buildNativeThinkingMessage(part) + ); } export async function sendBotText({ @@ -122,8 +214,22 @@ export async function sendRenderedBotPart({ textLength: part.text.length, fallbackTextLength: part.fallbackText.length, entityCount: part.entities?.length ?? 0, + tableRows: part.tableRows?.length ?? 0, }); + const nativeTableMessage = buildNativeRichMessage(part); + if (nativeTableMessage && api.sendRichMessage) { + try { + const sentMessage = await api.sendRichMessage(chatId, nativeTableMessage, rawOptions); + return { + messageId: sentMessage.message_id, + deliveredSignature: getTelegramRenderedPartSignature(part), + }; + } catch (error) { + logger.warn("[Bot] Native table send failed, falling back to text part", error); + } + } + if (!part.entities?.length) { const sentMessage = await api.sendMessage(chatId, part.text, rawOptions); return { @@ -173,8 +279,21 @@ export async function editRenderedBotPart({ textLength: part.text.length, fallbackTextLength: part.fallbackText.length, entityCount: part.entities?.length ?? 0, + tableRows: part.tableRows?.length ?? 0, }); + const nativeTableMessage = buildNativeRichMessage(part); + if (nativeTableMessage) { + try { + await api.editMessageText(chatId, messageId, nativeTableMessage, rawOptions); + return { + deliveredSignature: getTelegramRenderedPartSignature(part), + }; + } catch (error) { + logger.warn("[Bot] Native table edit failed, falling back to text edit", error); + } + } + if (!part.entities?.length) { await api.editMessageText(chatId, messageId, part.text, rawOptions); return { @@ -228,8 +347,21 @@ export async function sendDraftBotPart({ draftId, textLength: part.text.length, entityCount: part.entities?.length ?? 0, + tableRows: part.tableRows?.length ?? 0, }); + const nativeTableMessage = buildNativeDraftRichMessage(part); + if (nativeTableMessage && api.sendRichMessageDraft) { + try { + await api.sendRichMessageDraft(chatId, draftId, nativeTableMessage as RichMessageDraftParam); + return { + deliveredSignature: getTelegramRenderedPartSignature(part), + }; + } catch (error) { + logger.warn("[Bot] Native table draft failed, falling back to text draft", error); + } + } + if (!part.entities?.length) { await api.sendMessageDraft(chatId, draftId, part.text); return { @@ -264,8 +396,22 @@ export async function completeDraftPart({ logger.debug("[Bot] Completing draft with real message", { textLength: part.text.length, entityCount: part.entities?.length ?? 0, + tableRows: part.tableRows?.length ?? 0, }); + const nativeTableMessage = buildNativeRichMessage(part); + if (nativeTableMessage && api.sendRichMessage) { + try { + const sentMessage = await api.sendRichMessage(chatId, nativeTableMessage, rawOptions); + return { + messageId: sentMessage.message_id, + deliveredSignature: getTelegramRenderedPartSignature(part), + }; + } catch (error) { + logger.warn("[Bot] Native table complete failed, falling back to text", error); + } + } + if (!part.entities?.length) { const sentMessage = await api.sendMessage(chatId, part.text, rawOptions); return { diff --git a/src/bot/messages/thinking-rendering.ts b/src/bot/messages/thinking-rendering.ts index 6973f5b9..ac965e76 100644 --- a/src/bot/messages/thinking-rendering.ts +++ b/src/bot/messages/thinking-rendering.ts @@ -60,6 +60,7 @@ function createThinkingPart(header: string, text: string, expandable: boolean): entities: [entity], fallbackText: `${header}\n${quoteFallbackText(text)}`, source: "entities", + thinkingText: renderedText, }; } @@ -92,6 +93,7 @@ export function makeThinkingPayloadExpandable( ...payload, parts: payload.parts.map((part) => ({ ...part, + thinkingText: part.thinkingText, entities: part.entities?.map((entity) => entity.type === "blockquote" ? { ...entity, type: "expandable_blockquote" } : entity, ), diff --git a/src/bot/render/block-renderer.ts b/src/bot/render/block-renderer.ts index 1edc938c..95c95bf7 100644 --- a/src/bot/render/block-renderer.ts +++ b/src/bot/render/block-renderer.ts @@ -3,6 +3,8 @@ import { renderInlineNodesValidated } from "./inline-renderer.js"; import type { BlockRenderMode, InlineNode, TelegramBlock, TelegramRenderedBlock } from "./types.js"; import { validateTelegramEntities } from "./validator.js"; +const CODE_DETAILS_MIN_LINES = 8; + interface RenderedSegment { text: string; fallbackText: string; @@ -584,14 +586,23 @@ export function renderTelegramBlock( return createRenderedBlock(block.type, mode, block.text, block.text); } - return renderPreformattedBlock(block.type, mode, block.text, block.language); + { + const rendered = renderPreformattedBlock(block.type, mode, block.text, block.language); + const lineCount = block.text.split("\n").length; + if (lineCount >= CODE_DETAILS_MIN_LINES) { + rendered.codeDetails = { language: block.language, text: block.text }; + } + return rendered; + } case "table": { const text = buildAlignedTableText(block.rows); if (mode === "plain" || mode === "line-by-line") { return createRenderedBlock(block.type, mode, text, text); } - return renderPreformattedBlock(block.type, mode, text); + const rendered = renderPreformattedBlock(block.type, mode, text); + rendered.tableRows = block.rows; + return rendered; } case "rule": return createRenderedBlock(block.type, mode, "──────────", "──────────"); diff --git a/src/bot/render/chunker.ts b/src/bot/render/chunker.ts index 96d52529..e28b132b 100644 --- a/src/bot/render/chunker.ts +++ b/src/bot/render/chunker.ts @@ -108,12 +108,35 @@ function createRenderedPart( }; } +function createRenderedPartWithDetails( + text: string, + fallbackText: string, + entities: MessageEntity[] | undefined, + block: TelegramRenderedBlock, +): TelegramRenderedPart { + const part = createRenderedPart(text, fallbackText, entities); + if (block.tableRows?.length) { + part.tableRows = block.tableRows; + } + if (block.codeDetails) { + part.codeDetails = block.codeDetails; + } + if (block.thinkingText) { + part.thinkingText = block.thinkingText; + } + + return part; +} + function clonePart(part: TelegramRenderedPart): TelegramRenderedPart { return { text: part.text, entities: part.entities ? [...part.entities] : undefined, fallbackText: part.fallbackText, source: part.source, + tableRows: part.tableRows ? part.tableRows.map((row) => [...row]) : undefined, + codeDetails: part.codeDetails ? { ...part.codeDetails } : undefined, + thinkingText: part.thinkingText, }; } @@ -290,7 +313,7 @@ function splitBlockToParts( } if (block.text.length <= maxLength) { - return [createRenderedPart(block.text, block.fallbackText, block.entities)]; + return [createRenderedPartWithDetails(block.text, block.fallbackText, block.entities, block)]; } const preEntity = isFullRangePreEntity(block); @@ -366,6 +389,13 @@ export function chunkTelegramRenderedBlocks( .filter((group) => group.length > 0); const parts: TelegramRenderedPart[] = []; let current = createBuilder(); + const flushCurrent = (): void => { + const finalized = finalizeBuilder(current); + if (finalized) { + parts.push(finalized); + } + current = createBuilder(); + }; for (const blockParts of blockGroups) { for (let index = 0; index < blockParts.length; index++) { @@ -373,15 +403,17 @@ export function chunkTelegramRenderedBlocks( const needsSeparator = index === 0 && current.text.length > 0; const prefix = needsSeparator ? blockSeparator : ""; + if (chunk.tableRows || chunk.codeDetails) { + flushCurrent(); + parts.push(chunk); + continue; + } + if ( current.text.length > 0 && current.text.length + prefix.length + chunk.text.length > maxPartLength ) { - const finalized = finalizeBuilder(current); - if (finalized) { - parts.push(finalized); - } - current = createBuilder(); + flushCurrent(); appendToBuilder(current, chunk, ""); continue; } diff --git a/src/bot/render/types.ts b/src/bot/render/types.ts index 6189987a..e182b90c 100644 --- a/src/bot/render/types.ts +++ b/src/bot/render/types.ts @@ -7,6 +7,9 @@ export interface TelegramRenderedPart { entities?: MessageEntity[]; fallbackText: string; source: "entities" | "plain"; + tableRows?: string[][]; + codeDetails?: { language?: string; text: string }; + thinkingText?: string; } export interface TelegramRenderedBlock { @@ -16,6 +19,9 @@ export interface TelegramRenderedBlock { entities?: MessageEntity[]; fallbackText: string; source: "entities" | "plain"; + tableRows?: string[][]; + codeDetails?: { language?: string; text: string }; + thinkingText?: string; } export type TelegramBlock = diff --git a/src/bot/streaming/response-streamer.ts b/src/bot/streaming/response-streamer.ts index 47941aa1..46b20370 100644 --- a/src/bot/streaming/response-streamer.ts +++ b/src/bot/streaming/response-streamer.ts @@ -67,6 +67,9 @@ function clonePart(part: TelegramRenderedPart): TelegramRenderedPart { entities: part.entities ? [...part.entities] : undefined, fallbackText: part.fallbackText, source: part.source, + tableRows: part.tableRows ? part.tableRows.map((row) => [...row]) : undefined, + codeDetails: part.codeDetails ? { ...part.codeDetails } : undefined, + thinkingText: part.thinkingText, }; } @@ -111,8 +114,15 @@ function getRetryAfterMs(error: unknown): number | null { return seconds * 1000; } -function createSignature(part: Pick): string { - return `${part.text}\n${JSON.stringify(part.entities ?? null)}`; +function createSignature( + part: Pick< + TelegramRenderedPart, + "text" | "entities" | "tableRows" | "codeDetails" | "thinkingText" + >, +): string { + return `${part.text}\n${JSON.stringify(part.entities ?? null)}\n${JSON.stringify( + part.tableRows ?? null, + )}\n${JSON.stringify(part.codeDetails ?? null)}\n${JSON.stringify(part.thinkingText ?? null)}`; } function delay(ms: number): Promise { diff --git a/tests/bot/messages/telegram-text.test.ts b/tests/bot/messages/telegram-text.test.ts index a3334df7..031f2264 100644 --- a/tests/bot/messages/telegram-text.test.ts +++ b/tests/bot/messages/telegram-text.test.ts @@ -4,6 +4,7 @@ import { editBotText, getTelegramRenderedPartSignature, sendBotText, + sendDraftBotPart, sendRenderedBotPart, } from "../../../src/bot/messages/telegram-text.js"; @@ -290,4 +291,187 @@ describe("bot/messages/telegram-text", () => { reply_markup: { inline_keyboard: [] }, }); }); + + it("sends native rich table via sendRichMessage when part has tableRows", async () => { + const sendRichMessage = vi.fn().mockResolvedValue({ message_id: 777 }); + const sendMessage = vi.fn(); + + await expect( + sendRenderedBotPart({ + api: { sendMessage, sendRichMessage }, + chatId: 100, + part: { + text: "A | B\n---|---\n1 | 2", + fallbackText: "A | B\n---|---\n1 | 2", + source: "plain", + tableRows: [ + ["A", "B"], + ["1", "2"], + ], + }, + }), + ).resolves.toEqual({ + messageId: 777, + deliveredSignature: getTelegramRenderedPartSignature({ + text: "A | B\n---|---\n1 | 2", + tableRows: [ + ["A", "B"], + ["1", "2"], + ], + }), + }); + + expect(sendRichMessage).toHaveBeenCalledTimes(1); + expect(sendRichMessage).toHaveBeenCalledWith( + 100, + { + blocks: [ + { + type: "table", + cells: [ + [ + { text: "A", is_header: true, align: "left", valign: "top" }, + { text: "B", is_header: true, align: "left", valign: "top" }, + ], + [ + { text: "1", align: "left", valign: "top" }, + { text: "2", align: "left", valign: "top" }, + ], + ], + is_bordered: true, + }, + ], + }, + undefined, + ); + }); + + it("falls back to plain text when sendRichMessage is unavailable", async () => { + const sendMessage = vi.fn().mockResolvedValue({ message_id: 123 }); + + await expect( + sendRenderedBotPart({ + api: { sendMessage }, + chatId: 100, + part: { + text: "A | B\n---|---\n1 | 2", + fallbackText: "A | B\n---|---\n1 | 2", + source: "plain", + tableRows: [ + ["A", "B"], + ["1", "2"], + ], + }, + }), + ).resolves.toEqual({ + messageId: 123, + deliveredSignature: getTelegramRenderedPartSignature({ text: "A | B\n---|---\n1 | 2" }), + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith(100, "A | B\n---|---\n1 | 2", undefined); + }); + + it("falls back to plain text when native table send fails", async () => { + const sendRichMessage = vi + .fn() + .mockRejectedValueOnce(new Error("Bad Request: cannot send rich message")); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 123 }); + + await expect( + sendRenderedBotPart({ + api: { sendRichMessage, sendMessage }, + chatId: 100, + part: { + text: "A | B\n---|---\n1 | 2", + fallbackText: "A | B\n---|---\n1 | 2", + source: "plain", + tableRows: [ + ["A", "B"], + ["1", "2"], + ], + }, + }), + ).resolves.toEqual({ + messageId: 123, + deliveredSignature: getTelegramRenderedPartSignature({ text: "A | B\n---|---\n1 | 2" }), + }); + + expect(sendRichMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it("sends native details block for large code parts via sendRichMessage", async () => { + const sendRichMessage = vi.fn().mockResolvedValue({ message_id: 778 }); + const sendMessage = vi.fn(); + + const code = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n"); + + await expect( + sendRenderedBotPart({ + api: { sendMessage, sendRichMessage }, + chatId: 100, + part: { + text: `\`\`\`ts\n${code}\n\`\`\``, + fallbackText: code, + source: "plain", + codeDetails: { language: "ts", text: code }, + }, + }), + ).resolves.toEqual({ + messageId: 778, + deliveredSignature: getTelegramRenderedPartSignature({ + text: `\`\`\`ts\n${code}\n\`\`\``, + codeDetails: { language: "ts", text: code }, + }), + }); + + expect(sendRichMessage).toHaveBeenCalledTimes(1); + expect(sendRichMessage).toHaveBeenCalledWith( + 100, + { + blocks: [ + { + type: "details", + summary: "Code — ts (10 lines)", + blocks: [{ type: "pre", text: code, language: "ts" }], + }, + ], + }, + undefined, + ); + }); + + it("sends native thinking block via sendRichMessageDraft when part has thinkingText", async () => { + const sendRichMessageDraft = vi.fn().mockResolvedValue(true); + const sendMessageDraft = vi.fn(); + + await expect( + sendDraftBotPart({ + api: { sendRichMessageDraft, sendMessageDraft }, + chatId: 100, + draftId: 500, + part: { + text: "Thinking — Analysis\nline one", + fallbackText: "Thinking — Analysis\n> line one", + source: "entities", + thinkingText: "Thinking — Analysis\nline one", + }, + }), + ).resolves.toEqual({ + deliveredSignature: getTelegramRenderedPartSignature({ + text: "Thinking — Analysis\nline one", + thinkingText: "Thinking — Analysis\nline one", + }), + }); + + expect(sendRichMessageDraft).toHaveBeenCalledTimes(1); + expect(sendRichMessageDraft).toHaveBeenCalledWith( + 100, + 500, + { + blocks: [{ type: "thinking", text: "Thinking — Analysis\nline one" }], + }, + ); + }); }); diff --git a/tests/bot/messages/thinking-rendering.test.ts b/tests/bot/messages/thinking-rendering.test.ts index 2bb00daf..189a516a 100644 --- a/tests/bot/messages/thinking-rendering.test.ts +++ b/tests/bot/messages/thinking-rendering.test.ts @@ -22,6 +22,7 @@ describe("bot/messages/thinking-rendering", () => { entities: [{ type: "expandable_blockquote", offset: header.length + 1, length: text.length }], fallbackText: `${header}\n> Line one\n> Line two`, source: "entities", + thinkingText: `${header}\n${text}`, }, ]); }); diff --git a/tests/bot/render/block-renderer.test.ts b/tests/bot/render/block-renderer.test.ts index 840d2ed2..a193ba83 100644 --- a/tests/bot/render/block-renderer.test.ts +++ b/tests/bot/render/block-renderer.test.ts @@ -118,6 +118,21 @@ describe("bot/render/block-renderer", () => { }); }); + it("carries codeDetails on large code blocks", () => { + const text = Array.from({ length: 10 }, (_, index) => `line ${index}`).join("\n"); + const block: TelegramBlock = { + type: "code", + language: "ts", + text, + }; + + expect(renderTelegramBlock(block)).toMatchObject({ + blockType: "code", + mode: "full", + codeDetails: { language: "ts", text }, + }); + }); + it("renders tables as aligned preformatted text", () => { const block: TelegramBlock = { type: "table", @@ -135,6 +150,11 @@ describe("bot/render/block-renderer", () => { entities: [{ type: "pre", offset: 0, length: 63 }], fallbackText: "Name | Score\n--------|------\napi.js | +1.5 \nalert() | -1.5 ", source: "entities", + tableRows: [ + ["Name", "Score"], + ["api.js", "+1.5"], + ["alert()", "-1.5"], + ], }); }); diff --git a/tests/bot/streaming/response-streamer.test.ts b/tests/bot/streaming/response-streamer.test.ts index 5bbface7..90372666 100644 --- a/tests/bot/streaming/response-streamer.test.ts +++ b/tests/bot/streaming/response-streamer.test.ts @@ -21,8 +21,16 @@ function richPart( }; } -function signature(part: { text: string; entities?: unknown[] }) { - return `${part.text}\n${JSON.stringify(part.entities ?? null)}`; +function signature(part: { + text: string; + entities?: unknown[]; + tableRows?: unknown[][]; + codeDetails?: { language?: string; text: string }; + thinkingText?: string; +}) { + return `${part.text}\n${JSON.stringify(part.entities ?? null)}\n${JSON.stringify( + part.tableRows ?? null, + )}\n${JSON.stringify(part.codeDetails ?? null)}\n${JSON.stringify(part.thinkingText ?? null)}`; } describe("bot/streaming/response-streamer", () => {