Skip to content
Merged
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
54 changes: 54 additions & 0 deletions src/telegram/__tests__/bot-bridge-html-split.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const sendMessage = vi.fn();
let guestHandler: ((ctx: unknown) => Promise<void>) | undefined;

vi.mock("grammy", () => ({
Bot: class {
api = { sendMessage };
catch(): void {}
on(event: string, handler: (ctx: unknown) => Promise<void>): void {
if (event === "guest_message") guestHandler = handler;
}
},
InlineKeyboard: class {},
InputFile: class {},
}));

import { GrammyBotBridge } from "../bridges/bot";

describe("GrammyBotBridge long HTML messages", () => {
beforeEach(() => {
sendMessage.mockReset();
sendMessage.mockImplementation(async () => ({ message_id: 1, date: 1 }));
guestHandler = undefined;
});

it("sends balanced chunks when formatting spans the Telegram limit", async () => {
const bridge = new GrammyBotBridge({ bot_token: "test" });

await bridge.sendMessage({ chatId: "123", text: `**${"word ".repeat(1200)}**` });

expect(sendMessage.mock.calls.length).toBeGreaterThan(1);
for (const [, html, options] of sendMessage.mock.calls) {
expect(html.length).toBeLessThanOrEqual(4096);
expect(html).toMatch(/^<b>.*<\/b>$/s);
expect(options.parse_mode).toBe("HTML");
}
});

it("truncates guest answers at a valid HTML chunk boundary", async () => {
const bridge = new GrammyBotBridge({ bot_token: "test" });
bridge.onGuestMessage(async () => `**${"word ".repeat(1200)}**`);
const answerGuestQuery = vi.fn();

await guestHandler?.({
guestMessage: { message_id: 42, chat: { id: 123 }, from: { id: 456 } },
answerGuestQuery,
});

const html = answerGuestQuery.mock.calls[0][0].input_message_content.message_text;
expect(html.length).toBeLessThanOrEqual(4096);
expect(html).toMatch(/^<b>.*<\/b>$/s);
});
});
56 changes: 56 additions & 0 deletions src/telegram/__tests__/html-splitter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { splitTelegramHtml } from "../html-splitter";

function expectBalanced(chunks: string[], maxLength: number): void {
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(maxLength);
expect(chunk.match(/<b>/g)?.length ?? 0).toBe(chunk.match(/<\/b>/g)?.length ?? 0);
expect(chunk).not.toMatch(/&(?:#\d*|#x[\da-f]*|[a-z]*)$/i);
}
}

describe("splitTelegramHtml", () => {
it("rejects a non-positive limit even for short input", () => {
expect(() => splitTelegramHtml("short", 0)).toThrow(RangeError);
});

it("closes and reopens formatting that spans a chunk boundary", () => {
const chunks = splitTelegramHtml(`<b>${"word ".repeat(30)}</b>`, 50);

expect(chunks.length).toBeGreaterThan(1);
expectBalanced(chunks, 50);
expect(chunks.every((chunk) => chunk.startsWith("<b>") && chunk.endsWith("</b>"))).toBe(true);
});

it("never splits an HTML entity", () => {
const chunks = splitTelegramHtml(`prefix ${"x".repeat(10)} &amp; suffix`, 20);

expect(chunks.join("")).toContain("&amp;");
expectBalanced(chunks, 20);
});

it("accounts for entities when the rendered text expands beyond the limit", () => {
const chunks = splitTelegramHtml("&amp;".repeat(20), 21);

expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join("")).toBe("&amp;".repeat(20));
expectBalanced(chunks, 21);
});

it("keeps nested tags and astral characters intact", () => {
const chunks = splitTelegramHtml(`<b><i>${"🙂".repeat(30)}</i></b>`, 35);

expectBalanced(chunks, 35);
expect(chunks.some((chunk) => chunk.includes("�"))).toBe(false);
expect(chunks.every((chunk) => chunk.includes("<b>") && chunk.includes("<i>"))).toBe(true);
});

it("preserves link attributes when reopening a tag", () => {
const opening = '<a href="https://example.com?a=1&amp;b=2">';
const chunks = splitTelegramHtml(`${opening}${"link text ".repeat(20)}</a>`, 80);

expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.startsWith(opening) && chunk.endsWith("</a>"))).toBe(true);
expect(chunks.every((chunk) => chunk.length <= 80)).toBe(true);
});
});
25 changes: 4 additions & 21 deletions src/telegram/bridges/bot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Bot, InlineKeyboard, InputFile, type Context } from "grammy";
import { markdownToTelegramHtml } from "../formatting.js";
import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js";
import { splitTelegramHtml } from "../html-splitter.js";
import type {
ITelegramBridge,
SentMessage,
Expand Down Expand Up @@ -140,32 +141,14 @@ export class GrammyBotBridge implements ITelegramBridge {
};
}

/** Split and send HTML that exceeds the Telegram message limit */
/** Split rendered HTML while keeping Telegram entities balanced in every chunk. */
private async sendLongMessage(
chatId: string,
html: string,
replyToId?: number,
replyMarkup?: InlineKeyboard
): Promise<SentMessage> {
const chunks: string[] = [];
let remaining = html;

while (remaining.length > TELEGRAM_MAX_MESSAGE_LENGTH) {
// Find a split point: prefer double newline, then single newline, then space
let splitAt = remaining.lastIndexOf("\n\n", TELEGRAM_MAX_MESSAGE_LENGTH);
if (splitAt < TELEGRAM_MAX_MESSAGE_LENGTH * 0.3) {
splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_MESSAGE_LENGTH);
}
if (splitAt < TELEGRAM_MAX_MESSAGE_LENGTH * 0.3) {
splitAt = remaining.lastIndexOf(" ", TELEGRAM_MAX_MESSAGE_LENGTH);
}
if (splitAt < TELEGRAM_MAX_MESSAGE_LENGTH * 0.3) {
splitAt = TELEGRAM_MAX_MESSAGE_LENGTH; // hard cut as last resort
}
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trimStart();
}
if (remaining.length > 0) chunks.push(remaining);
const chunks = splitTelegramHtml(html);

let lastResult: SentMessage = { id: 0, date: Math.floor(Date.now() / 1000), chatId };

Expand Down Expand Up @@ -546,7 +529,7 @@ export class GrammyBotBridge implements ITelegramBridge {
const content = await handler(this.parseMessage(gm));
const text = content?.trim();
if (!text || text === "__SILENT__") return;
const html = markdownToTelegramHtml(text).slice(0, TELEGRAM_MAX_MESSAGE_LENGTH);
const html = splitTelegramHtml(markdownToTelegramHtml(text))[0];
await ctx.answerGuestQuery({
type: "article",
id: String(gm.message_id),
Expand Down
136 changes: 136 additions & 0 deletions src/telegram/html-splitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js";

interface OpenTag {
name: string;
opening: string;
}

interface Token {
value: string;
kind: "tag" | "entity" | "text";
}

const VOID_TAGS = new Set(["br"]);

/** Split Telegram HTML without cutting entities or leaving formatting tags unbalanced. */
export function splitTelegramHtml(
html: string,
maxLength: number = TELEGRAM_MAX_MESSAGE_LENGTH
): string[] {
if (maxLength <= 0) throw new RangeError("maxLength must be positive");
if (html.length <= maxLength) return [html];

const chunks: string[] = [];
const openTags: OpenTag[] = [];
let chunk = "";

const closingTags = (): string =>
[...openTags]
.reverse()
.map(({ name }) => `</${name}>`)
.join("");

const reopenTags = (): string => openTags.map(({ opening }) => opening).join("");

const flush = (): void => {
if (chunk.length === 0) return;
chunks.push(chunk + closingTags());
chunk = reopenTags();
};

for (const token of tokenize(html)) {
let value = token.value;

while (value.length > 0) {
const reserved = closingTags().length;
const available = maxLength - chunk.length - reserved;

if (value.length <= available) {
chunk += value;
value = "";
continue;
}

if (token.kind !== "text") {
if (chunk.length === reopenTags().length) {
throw new RangeError("HTML formatting overhead leaves no room for content");
}
flush();
if (value.length + closingTags().length > maxLength) {
throw new RangeError("An HTML token is longer than maxLength");
}
continue;
}

if (available <= 0) {
flush();
continue;
}

const splitAt = findTextSplit(value, available);
chunk += value.slice(0, splitAt);
value = value.slice(splitAt);
flush();
}

if (token.kind === "tag") {
updateOpenTags(token.value, openTags);
if (chunk.length + closingTags().length > maxLength) {
throw new RangeError("HTML formatting tags are longer than maxLength");
}
}
}

if (chunk.length > 0) chunks.push(chunk);
return chunks;
}

function tokenize(html: string): Token[] {
const tokens: Token[] = [];
const pattern = /<[^>]*>|&(?:#\d+|#x[\da-f]+|[a-z][\da-z]+);/gi;
let offset = 0;
let match: RegExpExecArray | null;

while ((match = pattern.exec(html)) !== null) {
if (match.index > offset) {
tokens.push({ value: html.slice(offset, match.index), kind: "text" });
}
tokens.push({
value: match[0],
kind: match[0].startsWith("<") ? "tag" : "entity",
});
offset = pattern.lastIndex;
}

if (offset < html.length) tokens.push({ value: html.slice(offset), kind: "text" });
return tokens;
}

function updateOpenTags(tag: string, openTags: OpenTag[]): void {
const closing = tag.match(/^<\/\s*([\w-]+)\s*>$/);
if (closing) {
const name = closing[1].toLowerCase();
const index = openTags.map((item) => item.name).lastIndexOf(name);
if (index >= 0) openTags.splice(index, 1);
return;
}

const opening = tag.match(/^<\s*([\w-]+)(?:\s[^>]*)?>$/);
if (!opening || tag.endsWith("/>") || VOID_TAGS.has(opening[1].toLowerCase())) return;
openTags.push({ name: opening[1].toLowerCase(), opening: tag });
}

function findTextSplit(text: string, available: number): number {
const prefix = text.slice(0, available);
const candidates = [
prefix.lastIndexOf("\n\n"),
prefix.lastIndexOf("\n"),
prefix.lastIndexOf(" "),
];
const natural = candidates.find((index) => index >= available * 0.3);
let splitAt = natural === undefined ? available : natural + 1;

const lastCodeUnit = text.charCodeAt(splitAt - 1);
if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) splitAt--;
return Math.max(splitAt, 1);
}
Loading