Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 150 additions & 4 deletions src/bot/messages/telegram-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import {
} from "./send-with-markdown-fallback.js";
import type { TelegramRenderedPart } from "../render/types.js";

type SendMessageApi = Pick<Api<RawApi>, "sendMessage">;
type SendMessageApi = Pick<Api<RawApi>, "sendMessage"> & {
sendRichMessage?: Api<RawApi>["sendRichMessage"];
};
type EditMessageApi = Pick<Api<RawApi>, "editMessageText">;
type SendDraftApi = Pick<Api<RawApi>, "sendMessageDraft">;
type SendDraftApi = Pick<Api<RawApi>, "sendMessageDraft"> & {
sendRichMessageDraft?: Api<RawApi>["sendRichMessageDraft"];
};

type RichMessageParam = Parameters<NonNullable<SendMessageApi["sendRichMessage"]>>[1];
type RichMessageDraftParam = Parameters<NonNullable<SendDraftApi["sendRichMessageDraft"]>>[2];

type TelegramSendMessageOptions = Parameters<SendMessageApi["sendMessage"]>[2];
type TelegramEditMessageOptions = Parameters<EditMessageApi["editMessageText"]>[3];
Expand Down Expand Up @@ -86,9 +93,94 @@ function stripRichFormattingOptions<T extends TelegramSendMessageOptions | undef
}

export function getTelegramRenderedPartSignature(
part: Pick<TelegramRenderedPart, "text" | "entities">,
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({
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/bot/messages/thinking-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function createThinkingPart(header: string, text: string, expandable: boolean):
entities: [entity],
fallbackText: `${header}\n${quoteFallbackText(text)}`,
source: "entities",
thinkingText: renderedText,
};
}

Expand Down Expand Up @@ -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,
),
Expand Down
15 changes: 13 additions & 2 deletions src/bot/render/block-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, "──────────", "──────────");
Expand Down
44 changes: 38 additions & 6 deletions src/bot/render/chunker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -366,22 +389,31 @@ 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++) {
const chunk = blockParts[index];
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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/bot/render/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 =
Expand Down
14 changes: 12 additions & 2 deletions src/bot/streaming/response-streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -111,8 +114,15 @@ function getRetryAfterMs(error: unknown): number | null {
return seconds * 1000;
}

function createSignature(part: Pick<TelegramRenderedPart, "text" | "entities">): 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<void> {
Expand Down
Loading
Loading