From a2a0d0e3031216ff9f3d472eac067fa856fa8c70 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 14 Jul 2026 07:12:49 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/xlabtg/teleton-agent/issues/708 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..e929ee9e --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-07-14T07:12:48.852Z for PR creation at branch issue-708-caafed0dc053 for issue https://github.com/xlabtg/teleton-agent/issues/708 \ No newline at end of file From 065811932d77544c648a6c8a78cd71f27644c0d3 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 14 Jul 2026 07:30:32 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(telegram):=20=D0=B2=D0=BE=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D1=8B=D0=B2=20HTML=20=D0=B2=20=D0=B4=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/bot-bridge-html-split.test.ts | 54 ++++++++++++++++++ src/telegram/__tests__/html-splitter.test.ts | 56 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/telegram/__tests__/bot-bridge-html-split.test.ts create mode 100644 src/telegram/__tests__/html-splitter.test.ts diff --git a/src/telegram/__tests__/bot-bridge-html-split.test.ts b/src/telegram/__tests__/bot-bridge-html-split.test.ts new file mode 100644 index 00000000..037036bc --- /dev/null +++ b/src/telegram/__tests__/bot-bridge-html-split.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMessage = vi.fn(); +let guestHandler: ((ctx: unknown) => Promise) | undefined; + +vi.mock("grammy", () => ({ + Bot: class { + api = { sendMessage }; + catch(): void {} + on(event: string, handler: (ctx: unknown) => Promise): 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>$/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>$/s); + }); +}); diff --git a/src/telegram/__tests__/html-splitter.test.ts b/src/telegram/__tests__/html-splitter.test.ts new file mode 100644 index 00000000..dc5fdf71 --- /dev/null +++ b/src/telegram/__tests__/html-splitter.test.ts @@ -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(//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(`${"word ".repeat(30)}`, 50); + + expect(chunks.length).toBeGreaterThan(1); + expectBalanced(chunks, 50); + expect(chunks.every((chunk) => chunk.startsWith("") && chunk.endsWith(""))).toBe(true); + }); + + it("never splits an HTML entity", () => { + const chunks = splitTelegramHtml(`prefix ${"x".repeat(10)} & suffix`, 20); + + expect(chunks.join("")).toContain("&"); + expectBalanced(chunks, 20); + }); + + it("accounts for entities when the rendered text expands beyond the limit", () => { + const chunks = splitTelegramHtml("&".repeat(20), 21); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe("&".repeat(20)); + expectBalanced(chunks, 21); + }); + + it("keeps nested tags and astral characters intact", () => { + const chunks = splitTelegramHtml(`${"🙂".repeat(30)}`, 35); + + expectBalanced(chunks, 35); + expect(chunks.some((chunk) => chunk.includes("�"))).toBe(false); + expect(chunks.every((chunk) => chunk.includes("") && chunk.includes(""))).toBe(true); + }); + + it("preserves link attributes when reopening a tag", () => { + const opening = ''; + const chunks = splitTelegramHtml(`${opening}${"link text ".repeat(20)}`, 80); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.startsWith(opening) && chunk.endsWith(""))).toBe(true); + expect(chunks.every((chunk) => chunk.length <= 80)).toBe(true); + }); +}); From 4e30ad35ff7f00d6f7c1373be1f5145ec4a2009a Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 14 Jul 2026 07:31:24 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(telegram):=20=D0=B1=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20HTML-=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/telegram/bridges/bot.ts | 25 +------ src/telegram/html-splitter.ts | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 src/telegram/html-splitter.ts diff --git a/src/telegram/bridges/bot.ts b/src/telegram/bridges/bot.ts index 69aa7346..8903a4f9 100644 --- a/src/telegram/bridges/bot.ts +++ b/src/telegram/bridges/bot.ts @@ -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, @@ -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 { - 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 }; @@ -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), diff --git a/src/telegram/html-splitter.ts b/src/telegram/html-splitter.ts new file mode 100644 index 00000000..ab1f89f7 --- /dev/null +++ b/src/telegram/html-splitter.ts @@ -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 }) => ``) + .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); +} From 1019cca0c3b6f907889902c7d5ffe0d23a4aac75 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 14 Jul 2026 07:32:23 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=BF=D0=BE=D0=B4=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D0=BA=D0=B8=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index e929ee9e..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-07-14T07:12:48.852Z for PR creation at branch issue-708-caafed0dc053 for issue https://github.com/xlabtg/teleton-agent/issues/708 \ No newline at end of file