From 2965f5d47d25245b4155f4f712bac101aadce4b9 Mon Sep 17 00:00:00 2001 From: A Date: Mon, 20 Jul 2026 20:23:47 +0800 Subject: [PATCH] feat: sync bot api formatted text parse modes Sync telesrv 00630bc (feat(botapi): support formatted text parse modes). Skipped telesrv docs changes per public sync rules. --- cmd/bots/bedolagaformat/README.md | 50 ++ cmd/bots/bedolagaformat/demo.py | 221 +++++ cmd/bots/bedolagaformat/requirements.txt | 1 + cmd/bots/bedolagaformat/test_demo.py | 52 ++ internal/botapi/ephemeral.go | 38 +- internal/botapi/formatted_text.go | 1029 ++++++++++++++++++++++ internal/botapi/formatted_text_test.go | 255 ++++++ internal/botapi/inline.go | 63 +- internal/botapi/projection.go | 11 + internal/botapi/server.go | 37 +- internal/botapi/server_test.go | 12 +- 11 files changed, 1684 insertions(+), 85 deletions(-) create mode 100644 cmd/bots/bedolagaformat/README.md create mode 100644 cmd/bots/bedolagaformat/demo.py create mode 100644 cmd/bots/bedolagaformat/requirements.txt create mode 100644 cmd/bots/bedolagaformat/test_demo.py create mode 100644 internal/botapi/formatted_text.go create mode 100644 internal/botapi/formatted_text_test.go diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md new file mode 100644 index 00000000..32dfccb4 --- /dev/null +++ b/cmd/bots/bedolagaformat/README.md @@ -0,0 +1,50 @@ +# Bedolaga formatted-text demo + +这个 demo 复刻 Bedolaga 的 Bot 工厂关键配置: + +```python +Bot( + ..., + default=DefaultBotProperties(parse_mode=ParseMode.HTML), +) +``` + +因此 `/start` 的 `message.answer()` 不显式传 `parse_mode`,仍会由 aiogram 自动向 +telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legacy Markdown、 +MarkdownV2,用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update → +TDesktop` 链路。 + +## 安装 + +建议使用虚拟环境,token 只通过环境变量传入: + +```powershell +python -m venv "$env:TEMP\telesrv-bedolaga-demo-venv" +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" -m pip install ` + -r .\cmd\bots\bedolagaformat\requirements.txt + +$env:TELESRV_BOT_TOKEN = ":" +$env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081" +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py --drop-pending +``` + +随后在 TDesktop 中向 bot 发送: + +```text +/start +/formatdemo +``` + +也可以不启动 polling,直接向指定私聊发送三条格式测试消息: + +```powershell +& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" ` + .\cmd\bots\bedolagaformat\demo.py ` + --send-only ` + --send-chat-id 1780243200 ` + --marker BEDOLAGA-LOCAL-VERIFY +``` + +`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token,也不会 +把 token 写入文件。 diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py new file mode 100644 index 00000000..a90746e5 --- /dev/null +++ b/cmd/bots/bedolagaformat/demo.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Bedolaga-style aiogram formatted-text demo for the telesrv Bot API. + +The bot factory intentionally mirrors remnawave-bedolaga-telegram-bot: +DefaultBotProperties(parse_mode=ParseMode.HTML) is installed globally, while +individual sends may override it with legacy Markdown or MarkdownV2. +""" + +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +import logging +import os +import re + +from aiogram import Bot, Dispatcher, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.client.telegram import TelegramAPIServer +from aiogram.enums import ParseMode +from aiogram.filters import Command, CommandStart +from aiogram.types import Message + + +LOG = logging.getLogger("bedolagaformat") +MARKER_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$") +MARKDOWN_V2_RESERVED_RE = re.compile(r"([_\*\[\]\(\)~`>#+\-=|{}\.!\\])") + + +@dataclass(frozen=True) +class FormatSample: + name: str + text: str + parse_mode: ParseMode | None + + +def default_marker() -> str: + now = datetime.now(timezone.utc) + return now.strftime("BEDOLAGA%Y%m%dT%H%M%SZ") + + +def escape_markdown_v2_text(value: str) -> str: + return MARKDOWN_V2_RESERVED_RE.sub(r"\\\1", value) + + +def format_samples(marker: str) -> tuple[FormatSample, ...]: + """Return deterministic messages whose labels are safe in every grammar.""" + markdown_v2_marker = escape_markdown_v2_text(marker) + return ( + FormatSample( + name="default_html", + text=( + f"{marker} Default HTML " + "italic 😀 underline " + "spoiler " + 'link' + ), + # Deliberately omitted from send_message: the Bedolaga factory default + # must inject HTML just as it does for message.answer() in start.py. + parse_mode=None, + ), + FormatSample( + name="markdown", + text=( + f"*{marker} Markdown* _italic 😀_ " + "[link](https://example.com/bedolaga) `code`" + ), + parse_mode=ParseMode.MARKDOWN, + ), + FormatSample( + name="markdown_v2", + text=( + f"*{markdown_v2_marker} MarkdownV2* _italic 😀_ __underline__ " + "~strike~ ||spoiler|| " + "[link](https://example.com/bedolaga) `code`" + ), + parse_mode=ParseMode.MARKDOWN_V2, + ), + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Bedolaga-style aiogram HTML/Markdown demo against telesrv" + ) + parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN")) + parser.add_argument( + "--base-url", + default=os.getenv("TELESRV_BOT_API_SERVER", "http://127.0.0.1:8081"), + help="API server origin; do not append /bot", + ) + parser.add_argument( + "--send-chat-id", + type=int, + default=int(os.environ["TELESRV_BOT_DEMO_CHAT_ID"]) + if os.getenv("TELESRV_BOT_DEMO_CHAT_ID") + else None, + help="Send the complete suite proactively before polling", + ) + parser.add_argument("--send-only", action="store_true") + parser.add_argument("--drop-pending", action="store_true") + parser.add_argument("--polling-timeout", type=int, default=10) + parser.add_argument("--marker", default=default_marker()) + parser.add_argument("--log-level", default="INFO") + args = parser.parse_args() + if not args.token: + parser.error("missing --token or TELESRV_BOT_TOKEN") + if args.send_only and args.send_chat_id is None: + parser.error("--send-only requires --send-chat-id") + if not MARKER_RE.fullmatch(args.marker): + parser.error("--marker must contain 1-64 ASCII letters, digits, or hyphens") + if not 0 <= args.polling_timeout <= 50: + parser.error("--polling-timeout must be between 0 and 50") + return args + + +def create_bot(token: str, base_url: str) -> Bot: + """Mirror Bedolaga's create_bot() with a custom Telegram API server.""" + session = AiohttpSession( + api=TelegramAPIServer.from_base(base_url.rstrip("/")) + ) + return Bot( + token=token, + session=session, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + +async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]: + message_ids: list[int] = [] + for sample in format_samples(marker): + if sample.parse_mode is None: + sent = await bot.send_message(chat_id=chat_id, text=sample.text) + else: + sent = await bot.send_message( + chat_id=chat_id, + text=sample.text, + parse_mode=sample.parse_mode, + ) + message_ids.append(sent.message_id) + LOG.info( + "sent sample=%s chat_id=%s message_id=%s parse_mode=%s", + sample.name, + chat_id, + sent.message_id, + sample.parse_mode.value if sample.parse_mode is not None else "default-html", + ) + return message_ids + + +def build_dispatcher(marker: str) -> Dispatcher: + router = Router(name="telesrv-bedolaga-format") + + @router.message(CommandStart()) + async def start(message: Message) -> None: + # No parse_mode argument: this is the exact failure shape from Bedolaga's + # start handler when the Bot factory installs default HTML globally. + await message.answer( + f"{marker} Start OK default HTML inherited 😀" + ) + LOG.info("handled /start chat_id=%s incoming_message_id=%s", message.chat.id, message.message_id) + + @router.message(Command("formatdemo")) + async def format_demo(message: Message) -> None: + ids = await send_format_suite(message.bot, message.chat.id, marker) + LOG.info( + "handled /formatdemo chat_id=%s incoming_message_id=%s sent_message_ids=%s", + message.chat.id, + message.message_id, + ids, + ) + + dispatcher = Dispatcher() + dispatcher.include_router(router) + return dispatcher + + +async def run(args: argparse.Namespace) -> None: + bot = create_bot(args.token, args.base_url) + try: + me = await bot.get_me() + LOG.info( + "authenticated bot_id=%s username=@%s bot_api=%s marker=%s", + me.id, + me.username or "", + args.base_url, + args.marker, + ) + if args.send_chat_id is not None: + await send_format_suite(bot, args.send_chat_id, args.marker) + if args.send_only: + return + + await bot.delete_webhook(drop_pending_updates=args.drop_pending) + dispatcher = build_dispatcher(args.marker) + LOG.info("polling started; send /start or /formatdemo to @%s", me.username or me.id) + await dispatcher.start_polling( + bot, + allowed_updates=["message"], + polling_timeout=args.polling_timeout, + close_bot_session=False, + ) + finally: + await bot.session.close() + + +def main() -> int: + args = parse_args() + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + asyncio.run(run(args)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cmd/bots/bedolagaformat/requirements.txt b/cmd/bots/bedolagaformat/requirements.txt new file mode 100644 index 00000000..1b94ec4e --- /dev/null +++ b/cmd/bots/bedolagaformat/requirements.txt @@ -0,0 +1 @@ +aiogram==3.30.0 diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py new file mode 100644 index 00000000..26530af2 --- /dev/null +++ b/cmd/bots/bedolagaformat/test_demo.py @@ -0,0 +1,52 @@ +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest.mock import AsyncMock + +from aiogram.enums import ParseMode + + +MODULE_PATH = Path(__file__).with_name("demo.py") +SPEC = importlib.util.spec_from_file_location("bedolagaformat_demo", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +demo = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = demo +SPEC.loader.exec_module(demo) + + +class SentMessage: + def __init__(self, message_id: int) -> None: + self.message_id = message_id + + +class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase): + def test_format_samples_cover_default_and_explicit_modes(self) -> None: + samples = demo.format_samples("BEDOLAGA123") + self.assertEqual( + [sample.parse_mode for sample in samples], + [None, ParseMode.MARKDOWN, ParseMode.MARKDOWN_V2], + ) + self.assertIn("BEDOLAGA123 Default HTML", samples[0].text) + self.assertIn("*BEDOLAGA123 Markdown*", samples[1].text) + self.assertIn("*BEDOLAGA123 MarkdownV2*", samples[2].text) + + def test_markdown_v2_escapes_reserved_marker_characters(self) -> None: + samples = demo.format_samples("BEDOLAGA-FULL-20260720") + self.assertIn(r"BEDOLAGA\-FULL\-20260720", samples[2].text) + + async def test_send_suite_omits_parse_mode_only_for_default_html(self) -> None: + bot = AsyncMock() + bot.send_message.side_effect = [SentMessage(11), SentMessage(12), SentMessage(13)] + + message_ids = await demo.send_format_suite(bot, 1780243200, "BEDOLAGA123") + + self.assertEqual(message_ids, [11, 12, 13]) + calls = bot.send_message.await_args_list + self.assertNotIn("parse_mode", calls[0].kwargs) + self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN) + self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2) + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/botapi/ephemeral.go b/internal/botapi/ephemeral.go index 1d8471b7..0ce480b6 100644 --- a/internal/botapi/ephemeral.go +++ b/internal/botapi/ephemeral.go @@ -6,7 +6,6 @@ import ( "net/http" "strconv" "strings" - "unicode/utf8" "telesrv/internal/domain" ) @@ -219,39 +218,19 @@ func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, b input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup switch mode { case "text": - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - if values["text"] == "" { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY") - return - } - if !utf8.ValidString(values["text"]) || utf8.RuneCountInString(values["text"]) > domain.MaxMessageTextLength { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") - return - } - entities, err := botAPIMessageEntities(values["entities"]) + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return } - input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["text"], entities + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, text, entities case "caption": - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["caption_entities"]) + caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return } - if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") - return - } - input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["caption"], entities + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities case "reply_markup": input.Fields.SetReplyMarkup = true case "media": @@ -290,7 +269,7 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) Title string `json:"title"` Performer string `json:"performer"` } - if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" || strings.TrimSpace(media.ParseMode) != "" { + if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" { return errors.New("MEDIA_INVALID") } allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true} @@ -316,14 +295,11 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) } input.SecondaryFile = secondary } - entities, err := botAPIMessageEntities(string(media.CaptionEntities)) + caption, entities, err := botAPIFormattedTextRaw(media.Caption, media.ParseMode, string(media.CaptionEntities), domain.MaxEphemeralCaptionLength, false) if err != nil { return err } - if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength { - return errors.New("MESSAGE_TOO_LONG") - } - input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.Caption, entities + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities return nil } diff --git a/internal/botapi/formatted_text.go b/internal/botapi/formatted_text.go new file mode 100644 index 00000000..8f5fc1c1 --- /dev/null +++ b/internal/botapi/formatted_text.go @@ -0,0 +1,1029 @@ +package botapi + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" +) + +// The official Bot API rejects the raw formatted-text input above 32 KiB before +// parsing; the post-parse message/caption character limit is checked separately. +const maxBotAPIFormattedTextBytes = 1 << 15 + +// botAPIFormattedTextRaw applies the Bot API parse_mode/entities precedence and +// returns the plain text plus UTF-16 based entities that can cross the domain +// boundary. A non-empty parse_mode (except "none") deliberately wins over an +// entities payload, matching the official Bot API server. +func botAPIFormattedTextRaw(text, parseMode, rawEntities string, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + mode, enabled, err := botAPIParseMode(parseMode) + if err != nil { + return "", nil, err + } + if enabled && text != "" { + return botAPIFormattedText(text, mode, nil, maxLength, requireText) + } + entities, err := botAPIMessageEntities(rawEntities) + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(text, entities, maxLength, requireText) +} + +func botAPIFormattedText(text, parseMode string, inputEntities []apiMessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + mode, enabled, err := botAPIParseMode(parseMode) + if err != nil { + return "", nil, err + } + if !utf8.ValidString(text) { + return "", nil, errors.New("ENTITY_INVALID") + } + if len(text) > maxBotAPIFormattedTextBytes { + return "", nil, errors.New("MESSAGE_TOO_LONG") + } + if enabled { + var parsed string + var entities []domain.MessageEntity + switch mode { + case "html": + parsed, entities, err = parseBotAPIHTML(text) + case "markdown": + parsed, entities, err = parseBotAPIMarkdown(text) + case "markdownv2": + parsed, entities, err = parseBotAPIMarkdownV2(text) + default: + panic("normalized Bot API parse mode is not handled") + } + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(parsed, entities, maxLength, requireText) + } + entities, err := messageEntitiesFromAPI(inputEntities) + if err != nil { + return "", nil, err + } + return validateBotAPIFormattedText(text, entities, maxLength, requireText) +} + +func botAPIParseMode(raw string) (mode string, enabled bool, err error) { + mode = strings.ToLower(strings.TrimSpace(raw)) + switch mode { + case "", "none", "null": + return "", false, nil + case "html", "markdown", "markdownv2": + return mode, true, nil + default: + return "", false, errors.New("Unsupported parse_mode") + } +} + +func validateBotAPIFormattedText(text string, entities []domain.MessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) { + if !utf8.ValidString(text) { + return "", nil, errors.New("ENTITY_INVALID") + } + if requireText && text == "" { + return "", nil, errors.New("MESSAGE_EMPTY") + } + if maxLength > 0 && utf8.RuneCountInString(text) > maxLength { + return "", nil, errors.New("MESSAGE_TOO_LONG") + } + if len(entities) > domain.MaxMessageEntityCount { + return "", nil, errors.New("ENTITIES_TOO_LONG") + } + textLength := utf16StringLength(text) + boundaries := make(map[int]struct{}, utf8.RuneCountInString(text)+1) + boundaries[0] = struct{}{} + position := 0 + for _, r := range text { + position++ + if r > 0xffff { + position++ + } + boundaries[position] = struct{}{} + } + for _, entity := range entities { + if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > textLength || entity.Length > textLength-entity.Offset { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + if _, ok := boundaries[entity.Offset]; !ok { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + if _, ok := boundaries[entity.Offset+entity.Length]; !ok { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + } + sortBotAPIEntities(entities) + ends := make([]int, 0, len(entities)) + for _, entity := range entities { + for len(ends) > 0 && entity.Offset >= ends[len(ends)-1] { + ends = ends[:len(ends)-1] + } + end := entity.Offset + entity.Length + if len(ends) > 0 && end > ends[len(ends)-1] { + return "", nil, errors.New("ENTITY_BOUNDS_INVALID") + } + ends = append(ends, end) + } + return text, entities, nil +} + +func utf16StringLength(text string) int { + length := 0 + for _, r := range text { + length++ + if r > 0xffff { + length++ + } + } + return length +} + +type formattedTextBuilder struct { + text bytes.Buffer + utf16 int +} + +func (b *formattedTextBuilder) appendString(value string) { + b.text.WriteString(value) + b.utf16 += utf16StringLength(value) +} + +func (b *formattedTextBuilder) appendRune(r rune) { + b.text.WriteRune(r) + b.utf16++ + if r > 0xffff { + b.utf16++ + } +} + +func (b *formattedTextBuilder) string() string { return b.text.String() } +func (b *formattedTextBuilder) byteLen() int { return b.text.Len() } + +func parseEntityError(format string, args ...any) error { + return fmt.Errorf("Can't parse entities: "+format, args...) +} + +type htmlEntityFrame struct { + tag string + typ domain.MessageEntityType + offset int + outputByte int + argument string + language string + documentID int64 + date int + collapsed bool + relative bool + shortTime bool + longTime bool + shortDate bool + longDate bool + dayOfWeek bool +} + +func parseBotAPIHTML(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + stack := make([]htmlEntityFrame, 0) + for i := 0; i < len(input); { + switch input[i] { + case '&': + decoded, next, err := decodeBotAPIHTMLEntity(input, i) + if err != nil { + return "", nil, err + } + out.appendString(decoded) + i = next + case '<': + closing, tag, attrs, booleans, next, err := scanBotAPIHTMLTag(input, i) + if err != nil { + return "", nil, err + } + if !closing { + frame, frameErr := botAPIHTMLFrame(tag, attrs, booleans, out.utf16, out.byteLen()) + if frameErr != nil { + return "", nil, frameErr + } + stack = append(stack, frame) + } else { + if len(stack) == 0 { + return "", nil, parseEntityError("unexpected end tag at byte offset %d", i) + } + frame := stack[len(stack)-1] + if tag != "" && tag != frame.tag { + return "", nil, parseEntityError("unmatched end tag at byte offset %d, expected , found ", i, frame.tag, tag) + } + stack = stack[:len(stack)-1] + length := out.utf16 - frame.offset + if length > 0 { + if frame.tag == "tg-time" && frame.date <= 0 { + i = next + continue + } + entity := domain.MessageEntity{ + Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language, + DocumentID: frame.documentID, Date: frame.date, Collapsed: frame.collapsed, + Relative: frame.relative, ShortTime: frame.shortTime, LongTime: frame.longTime, + ShortDate: frame.shortDate, LongDate: frame.longDate, DayOfWeek: frame.dayOfWeek, + } + switch frame.tag { + case "a": + link := frame.argument + if link == "" { + link = out.string()[frame.outputByte:] + } + resolved, ok := botAPITextLinkEntity(link, frame.offset, length) + if ok { + entities = append(entities, resolved) + } + case "pre": + if len(entities) > 0 { + last := &entities[len(entities)-1] + if last.Type == domain.MessageEntityCode && last.Offset == frame.offset && last.Length == length && last.Language != "" { + last.Type = domain.MessageEntityPre + break + } + } + entities = append(entities, entity) + default: + entities = append(entities, entity) + } + } + } + i = next + default: + j := i + for j < len(input) && input[j] != '<' && input[j] != '&' { + j++ + } + out.appendString(input[i:j]) + i = j + } + } + if len(stack) > 0 { + return "", nil, parseEntityError("can't find end tag corresponding to start tag <%s>", stack[len(stack)-1].tag) + } + for i := range entities { + if entities[i].Type == domain.MessageEntityCode { + entities[i].Language = "" + } + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +// scanBotAPIHTMLTag parses only the deliberately small HTML dialect accepted by +// the Bot API. It does not apply browser error recovery: malformed and unmatched +// tags must fail before a message state transition. +func scanBotAPIHTMLTag(input string, start int) (closing bool, tag string, attrs map[string]string, booleans map[string]bool, next int, err error) { + if start < 0 || start >= len(input) || input[start] != '<' { + return false, "", nil, nil, start, parseEntityError("invalid tag at byte offset %d", start) + } + i := start + 1 + if i < len(input) && input[i] == '/' { + closing = true + i++ + } + nameStart := i + for i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>' { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed tag at byte offset %d", start) + } + if i == nameStart && !closing { + return false, "", nil, nil, start, parseEntityError("empty tag at byte offset %d", start) + } + tag = strings.ToLower(input[nameStart:i]) + if tag != "" && !supportedBotAPIHTMLTag(tag) { + return false, "", nil, nil, start, parseEntityError("unsupported tag %q at byte offset %d", tag, start) + } + attrs = make(map[string]string) + booleans = make(map[string]bool) + if closing { + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) || input[i] != '>' { + return false, "", nil, nil, start, parseEntityError("unclosed end tag at byte offset %d", start) + } + return true, tag, attrs, booleans, i + 1, nil + } + for { + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag) + } + if input[i] == '>' { + return false, tag, attrs, booleans, i + 1, nil + } + if input[i] == '/' { + return false, "", nil, nil, start, parseEntityError("self-closing tag <%s/> is unsupported", tag) + } + attributeStart := i + for i < len(input) && !isHTMLSpace(input[i]) && !strings.ContainsRune("=>/\"'", rune(input[i])) { + i++ + } + if i == attributeStart { + return false, "", nil, nil, start, parseEntityError("empty attribute name in tag <%s>", tag) + } + name := strings.ToLower(input[attributeStart:i]) + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag) + } + if input[i] != '=' { + booleans[name] = true + continue + } + i++ + for i < len(input) && isHTMLSpace(input[i]) { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name) + } + var raw string + if input[i] == '\'' || input[i] == '"' { + quote := input[i] + i++ + valueStart := i + for i < len(input) && input[i] != quote { + i++ + } + if i >= len(input) { + return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name) + } + raw = input[valueStart:i] + i++ + } else { + valueStart := i + for i < len(input) && (isASCIIAlphaNumeric(input[i]) || input[i] == '.' || input[i] == '-') { + i++ + } + if i == valueStart || (i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>') { + return false, "", nil, nil, start, parseEntityError("invalid unquoted attribute %q", name) + } + raw = strings.ToLower(input[valueStart:i]) + } + value, decodeErr := decodeBotAPIHTMLString(raw) + if decodeErr != nil { + return false, "", nil, nil, start, decodeErr + } + attrs[name] = value + } +} + +func botAPIHTMLFrame(tag string, attrs map[string]string, booleans map[string]bool, offset, outputByte int) (htmlEntityFrame, error) { + frame := htmlEntityFrame{tag: tag, offset: offset, outputByte: outputByte} + switch tag { + case "b", "strong": + frame.typ = domain.MessageEntityBold + case "i", "em": + frame.typ = domain.MessageEntityItalic + case "u", "ins": + frame.typ = domain.MessageEntityUnderline + case "s", "strike", "del": + frame.typ = domain.MessageEntityStrike + case "tg-spoiler": + frame.typ = domain.MessageEntitySpoiler + case "span": + if attrs["class"] != "tg-spoiler" { + return htmlEntityFrame{}, parseEntityError("tag must have class \"tg-spoiler\"") + } + frame.typ = domain.MessageEntitySpoiler + case "a": + frame.typ, frame.argument = domain.MessageEntityTextURL, attrs["href"] + case "code": + frame.typ = domain.MessageEntityCode + if class := attrs["class"]; strings.HasPrefix(class, "language-") { + frame.language = strings.TrimPrefix(class, "language-") + } + case "pre": + frame.typ = domain.MessageEntityPre + case "blockquote": + frame.typ = domain.MessageEntityBlockquote + _, hasExpandable := attrs["expandable"] + frame.collapsed = booleans["expandable"] || hasExpandable + case "tg-emoji": + frame.typ = domain.MessageEntityCustomEmoji + id, err := strconv.ParseInt(attrs["emoji-id"], 10, 64) + if err != nil || id <= 0 { + return htmlEntityFrame{}, parseEntityError("invalid custom emoji identifier") + } + frame.documentID = id + case "tg-time": + frame.typ = domain.MessageEntityFormattedDate + date, err := strconv.ParseInt(attrs["unix"], 10, 32) + if err != nil { + date = 0 + } + formatted, err := botAPIFormattedDate(1, attrs["format"]) + if err != nil { + return htmlEntityFrame{}, err + } + frame.date = int(date) + frame.relative = formatted.Relative + frame.shortTime = formatted.ShortTime + frame.longTime = formatted.LongTime + frame.shortDate = formatted.ShortDate + frame.longDate = formatted.LongDate + frame.dayOfWeek = formatted.DayOfWeek + default: + return htmlEntityFrame{}, parseEntityError("unsupported tag <%s>", tag) + } + return frame, nil +} + +func supportedBotAPIHTMLTag(tag string) bool { + switch tag { + case "a", "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tg-spoiler", "tg-emoji", "tg-time", "span", "pre", "code", "blockquote": + return true + default: + return false + } +} + +func isHTMLSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' } + +func isASCIIAlphaNumeric(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} + +func decodeBotAPIHTMLString(input string) (string, error) { + if !strings.Contains(input, "&") { + return input, nil + } + var out strings.Builder + for i := 0; i < len(input); { + if input[i] != '&' { + j := strings.IndexByte(input[i:], '&') + if j < 0 { + out.WriteString(input[i:]) + break + } + out.WriteString(input[i : i+j]) + i += j + continue + } + decoded, next, err := decodeBotAPIHTMLEntity(input, i) + if err != nil { + return "", err + } + out.WriteString(decoded) + i = next + } + return out.String(), nil +} + +func decodeBotAPIHTMLEntity(input string, start int) (string, int, error) { + endRelative := strings.IndexByte(input[start:], ';') + if endRelative < 0 { + return "&", start + 1, nil + } + end := start + endRelative + name := input[start+1 : end] + switch name { + case "lt": + return "<", end + 1, nil + case "gt": + return ">", end + 1, nil + case "amp": + return "&", end + 1, nil + case "quot": + return "\"", end + 1, nil + } + if !strings.HasPrefix(name, "#") { + return "&", start + 1, nil + } + base, digits := 10, name[1:] + if strings.HasPrefix(digits, "x") || strings.HasPrefix(digits, "X") { + base, digits = 16, digits[1:] + } + value, err := strconv.ParseInt(digits, base, 32) + if err != nil || value <= 0 || value > utf8.MaxRune || value >= 0xd800 && value <= 0xdfff { + return "&", start + 1, nil + } + return string(rune(value)), end + 1, nil +} + +func parseBotAPIMarkdown(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + for i := 0; i < len(input); { + if input[i] == '\\' && i+1 < len(input) && strings.ContainsRune("_*`[", rune(input[i+1])) { + out.appendRune(rune(input[i+1])) + i += 2 + continue + } + marker := input[i] + if marker != '_' && marker != '*' && marker != '`' && marker != '[' { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + i += size + continue + } + begin := i + typ := domain.MessageEntityItalic + delimiter := string(marker) + language := "" + switch marker { + case '*': + typ = domain.MessageEntityBold + i++ + case '[': + typ, delimiter = domain.MessageEntityTextURL, "]" + i++ + case '`': + typ = domain.MessageEntityCode + if strings.HasPrefix(input[i:], "```") { + typ, delimiter = domain.MessageEntityPre, "```" + i += 3 + languageEnd := i + for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' { + languageEnd++ + } + if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' { + language = input[i:languageEnd] + i = languageEnd + } + i = skipSingleLeadingNewline(input, i) + } else { + i++ + } + default: + i++ + } + offset := out.utf16 + for i < len(input) && !strings.HasPrefix(input[i:], delimiter) { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + i += size + } + if i >= len(input) { + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", begin) + } + length := out.utf16 - offset + i += len(delimiter) + if length <= 0 { + continue + } + if typ == domain.MessageEntityTextURL { + // Derive the visible slice by walking back over exactly the entity's + // UTF-16 range; legacy Markdown doesn't allow nested entities. + visibleStart := outputByteOffsetForUTF16Suffix(out.string(), length) + link := out.string()[visibleStart:] + if i < len(input) && input[i] == '(' { + urlStart := i + 1 + urlEnd := strings.IndexByte(input[urlStart:], ')') + if urlEnd < 0 { + link = input[urlStart:] + i = len(input) + } else { + link = input[urlStart : urlStart+urlEnd] + i = urlStart + urlEnd + 1 + } + } + if entity, ok := botAPITextLinkEntity(link, offset, length); ok { + entities = append(entities, entity) + } + continue + } + entities = append(entities, domain.MessageEntity{Type: typ, Offset: offset, Length: length, Language: language}) + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +func skipSingleLeadingNewline(input string, i int) int { + if i >= len(input) || input[i] != '\n' && input[i] != '\r' { + return i + } + first := input[i] + i++ + if i < len(input) && (input[i] == '\n' || input[i] == '\r') && input[i] != first { + i++ + } + return i +} + +func outputByteOffsetForUTF16Suffix(text string, suffixLength int) int { + need := suffixLength + for i := len(text); i > 0; { + r, size := utf8.DecodeLastRuneInString(text[:i]) + need-- + if r > 0xffff { + need-- + } + i -= size + if need == 0 { + return i + } + } + return 0 +} + +type markdownV2Frame struct { + typ domain.MessageEntityType + offset int + inputByte int + outputByte int + language string +} + +func parseBotAPIMarkdownV2(input string) (string, []domain.MessageEntity, error) { + var out formattedTextBuilder + entities := make([]domain.MessageEntity, 0) + stack := make([]markdownV2Frame, 0) + haveBlockquote, canStartBlockquote := false, true + for i := 0; i < len(input); { + if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 { + literal := input[i+1] + out.appendRune(rune(literal)) + if literal != '\r' { + canStartBlockquote = literal == '\n' + } + i += 2 + continue + } + + reserved := "_*[]()~`>#+-=|{}.!\n" + if len(stack) > 0 && (stack[len(stack)-1].typ == domain.MessageEntityCode || stack[len(stack)-1].typ == domain.MessageEntityPre) { + reserved = "`" + } + if !strings.ContainsRune(reserved, rune(input[i])) { + r, size := utf8.DecodeRuneInString(input[i:]) + out.appendRune(r) + if r != '\r' { + canStartBlockquote = false + } + i += size + continue + } + + c := input[i] + endQuote := haveBlockquote && c == '\n' && (i+1 == len(input) || input[i+1] != '>') + isEnd := endQuote || markdownV2ClosesTop(input, i, stack) + if !isEnd { + frame := markdownV2Frame{offset: out.utf16, inputByte: i, outputByte: out.byteLen()} + switch c { + case '_': + frame.typ = domain.MessageEntityItalic + i++ + if i < len(input) && input[i] == '_' { + frame.typ = domain.MessageEntityUnderline + i++ + } + case '*': + frame.typ = domain.MessageEntityBold + i++ + case '~': + frame.typ = domain.MessageEntityStrike + i++ + case '|': + if i+1 >= len(input) || input[i+1] != '|' { + return "", nil, markdownV2ReservedError(c) + } + frame.typ = domain.MessageEntitySpoiler + i += 2 + case '[': + frame.typ = domain.MessageEntityTextURL + i++ + case '!': + if i+1 >= len(input) || input[i+1] != '[' { + return "", nil, markdownV2ReservedError(c) + } + frame.typ = domain.MessageEntityCustomEmoji + i += 2 + case '`': + frame.typ = domain.MessageEntityCode + if strings.HasPrefix(input[i:], "```") { + frame.typ = domain.MessageEntityPre + i += 3 + languageEnd := i + for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' { + languageEnd++ + } + if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' { + frame.language = input[i:languageEnd] + i = languageEnd + } + i = skipSingleLeadingNewline(input, i) + } else { + i++ + } + case '\n': + out.appendRune('\n') + canStartBlockquote = true + i++ + continue + case '>': + if !canStartBlockquote { + return "", nil, markdownV2ReservedError(c) + } + if haveBlockquote { + i++ + continue + } + frame.typ = domain.MessageEntityBlockquote + haveBlockquote = true + i++ + default: + return "", nil, markdownV2ReservedError(c) + } + stack = append(stack, frame) + continue + } + + if len(stack) == 0 { + return "", nil, markdownV2ReservedError(c) + } + collapsed := false + if endQuote { + quoteStart := i + if len(stack) > 0 { + quoteStart = stack[len(stack)-1].inputByte + } + if stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset { + stack = stack[:len(stack)-1] + collapsed = true + } + if len(stack) == 0 || stack[len(stack)-1].typ != domain.MessageEntityBlockquote { + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", quoteStart) + } + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + out.appendRune('\n') + length := out.utf16 - frame.offset + if length > 0 { + entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed}) + } + haveBlockquote, canStartBlockquote = false, true + i++ + continue + } + + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + length := out.utf16 - frame.offset + switch frame.typ { + case domain.MessageEntityBold, domain.MessageEntityItalic, domain.MessageEntityStrike, domain.MessageEntityCode: + i++ + case domain.MessageEntityUnderline, domain.MessageEntitySpoiler: + i += 2 + case domain.MessageEntityPre: + i += 3 + case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji: + i++ // closing ] + link := out.string()[frame.outputByte:] + if i < len(input) && input[i] == '(' { + parsedURL, next, parseErr := parseMarkdownV2URL(input, i+1) + if parseErr != nil { + return "", nil, parseErr + } + link, i = parsedURL, next + } else if frame.typ == domain.MessageEntityCustomEmoji { + return "", nil, parseEntityError("custom emoji entity must contain a tg://emoji or tg://time URL") + } + if length > 0 { + if frame.typ == domain.MessageEntityTextURL { + if entity, ok := botAPITextLinkEntity(link, frame.offset, length); ok { + entities = append(entities, entity) + } + } else { + entity, resolveErr := botAPICustomLinkEntity(link, frame.offset, length) + if resolveErr != nil { + return "", nil, resolveErr + } + entities = append(entities, entity) + } + } + continue + default: + return "", nil, parseEntityError("invalid MarkdownV2 entity") + } + if length > 0 { + entities = append(entities, domain.MessageEntity{Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language}) + } + } + + if haveBlockquote { + collapsed := false + if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset { + stack = stack[:len(stack)-1] + collapsed = true + } + if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntityBlockquote { + frame := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if length := out.utf16 - frame.offset; length > 0 { + entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed}) + } + haveBlockquote = false + } + } + if len(stack) > 0 { + frame := stack[len(stack)-1] + return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", frame.inputByte) + } + sortBotAPIEntities(entities) + return out.string(), entities, nil +} + +func markdownV2ClosesTop(input string, i int, stack []markdownV2Frame) bool { + if len(stack) == 0 { + return false + } + c := input[i] + switch stack[len(stack)-1].typ { + case domain.MessageEntityBold: + return c == '*' + case domain.MessageEntityItalic: + return c == '_' && (i+1 >= len(input) || input[i+1] != '_') + case domain.MessageEntityUnderline: + return c == '_' && i+1 < len(input) && input[i+1] == '_' + case domain.MessageEntityStrike: + return c == '~' + case domain.MessageEntitySpoiler: + return c == '|' && i+1 < len(input) && input[i+1] == '|' + case domain.MessageEntityCode: + return c == '`' + case domain.MessageEntityPre: + return strings.HasPrefix(input[i:], "```") + case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji: + return c == ']' + case domain.MessageEntityBlockquote: + return false + default: + return false + } +} + +func markdownV2ReservedError(c byte) error { + return parseEntityError("character %q is reserved and must be escaped with a preceding backslash", c) +} + +func parseMarkdownV2URL(input string, start int) (string, int, error) { + var out strings.Builder + for i := start; i < len(input); { + if input[i] == ')' { + return out.String(), i + 1, nil + } + if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 { + out.WriteByte(input[i+1]) + i += 2 + continue + } + r, size := utf8.DecodeRuneInString(input[i:]) + out.WriteRune(r) + i += size + } + return "", start, parseEntityError("can't find end of URL at byte offset %d", start) +} + +func botAPITextLinkEntity(raw string, offset, length int) (domain.MessageEntity, bool) { + if userID, ok := botAPITGUserID(raw); ok { + return domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: offset, Length: length, UserID: userID}, true + } + if parsed, err := url.Parse(raw); err == nil && strings.EqualFold(parsed.Scheme, "tg") && strings.EqualFold(parsed.Host, "user") { + return domain.MessageEntity{}, false + } + if !validBotAPITextURL(raw) { + return domain.MessageEntity{}, false + } + return domain.MessageEntity{Type: domain.MessageEntityTextURL, Offset: offset, Length: length, URL: raw}, true +} + +func botAPITGUserID(raw string) (int64, bool) { + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "tg") || !strings.EqualFold(parsed.Host, "user") { + return 0, false + } + id, err := strconv.ParseInt(parsed.Query().Get("id"), 10, 64) + return id, err == nil && id > 0 +} + +func validBotAPITextURL(raw string) bool { + if raw == "" || strings.ContainsAny(raw, " \t\r\n") { + return false + } + parsed, err := url.Parse(raw) + if err != nil { + return false + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + return parsed.Host != "" + case "tg": + return parsed.Host != "" + case "mailto", "tel": + return parsed.Opaque != "" || parsed.Path != "" + case "": + return strings.Contains(parsed.Path, ".") + default: + return false + } +} + +func botAPICustomLinkEntity(raw string, offset, length int) (domain.MessageEntity, error) { + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Scheme, "tg") { + return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL") + } + switch strings.ToLower(parsed.Host) { + case "emoji": + id, parseErr := strconv.ParseInt(parsed.Query().Get("id"), 10, 64) + if parseErr != nil || id <= 0 { + return domain.MessageEntity{}, parseEntityError("invalid custom emoji identifier") + } + return domain.MessageEntity{Type: domain.MessageEntityCustomEmoji, Offset: offset, Length: length, DocumentID: id}, nil + case "time": + date, parseErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32) + if parseErr != nil || date <= 0 { + return domain.MessageEntity{}, parseEntityError("invalid date-time unix value") + } + entity, formatErr := botAPIFormattedDate(int(date), parsed.Query().Get("format")) + if formatErr != nil { + return domain.MessageEntity{}, formatErr + } + entity.Offset, entity.Length = offset, length + return entity, nil + default: + return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL") + } +} + +func botAPIFormattedDate(date int, format string) (domain.MessageEntity, error) { + if date <= 0 || int64(date) > 1<<31-1 { + return domain.MessageEntity{}, parseEntityError("invalid date-time unix value") + } + entity := domain.MessageEntity{Type: domain.MessageEntityFormattedDate, Date: date} + if format == "" { + return entity, nil + } + if format == "r" || format == "R" { + entity.Relative = true + return entity, nil + } + for _, part := range format { + switch part { + case 't': + entity.ShortTime = true + case 'T': + entity.LongTime = true + case 'd': + entity.ShortDate = true + case 'D': + entity.LongDate = true + case 'w', 'W': + entity.DayOfWeek = true + default: + return domain.MessageEntity{}, parseEntityError("invalid date-time format %q", format) + } + } + return entity, nil +} + +func botAPIFormattedDateFormat(entity domain.MessageEntity) string { + if entity.Relative { + return "r" + } + var out strings.Builder + if entity.DayOfWeek { + out.WriteByte('w') + } + if entity.ShortDate { + out.WriteByte('d') + } else if entity.LongDate { + out.WriteByte('D') + } + if entity.ShortTime { + out.WriteByte('t') + } else if entity.LongTime { + out.WriteByte('T') + } + return out.String() +} + +func sortBotAPIEntities(entities []domain.MessageEntity) { + sort.SliceStable(entities, func(i, j int) bool { + if entities[i].Offset != entities[j].Offset { + return entities[i].Offset < entities[j].Offset + } + if entities[i].Length != entities[j].Length { + return entities[i].Length > entities[j].Length + } + return entities[i].Type < entities[j].Type + }) +} diff --git a/internal/botapi/formatted_text_test.go b/internal/botapi/formatted_text_test.go new file mode 100644 index 00000000..ee8783e6 --- /dev/null +++ b/internal/botapi/formatted_text_test.go @@ -0,0 +1,255 @@ +package botapi + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "telesrv/internal/domain" +) + +func TestParseBotAPIHTMLNestedUTF16LinksAndDate(t *testing.T) { + plain, entities, err := parseBotAPIHTML(`A 😀 Alice now`) + if err != nil { + t.Fatal(err) + } + if plain != "A 😀 Alice now" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 4}, + {Type: domain.MessageEntityItalic, Offset: 2, Length: 2}, + {Type: domain.MessageEntityMentionName, Offset: 5, Length: 5, UserID: 42}, + {Type: domain.MessageEntityFormattedDate, Offset: 11, Length: 3, Date: 1700000000, DayOfWeek: true, ShortDate: true, LongTime: true}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPIHTMLPreAndEscapes(t *testing.T) { + plain, entities, err := parseBotAPIHTML(`
if a < b && b > c
`) + if err != nil { + t.Fatal(err) + } + if plain != "if a < b && b > c" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{{Type: domain.MessageEntityPre, Offset: 0, Length: 17, Language: "go"}} + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPILegacyMarkdown(t *testing.T) { + plain, entities, err := parseBotAPIMarkdown(`*bold* _😀_ [site](https://example.com) \*raw\*`) + if err != nil { + t.Fatal(err) + } + if plain != "bold 😀 site *raw*" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 4}, + {Type: domain.MessageEntityItalic, Offset: 5, Length: 2}, + {Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com"}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } +} + +func TestParseBotAPIMarkdownV2NestedLinksAndExpandableQuote(t *testing.T) { + plain, entities, err := parseBotAPIMarkdownV2(`*bold _😀_* [site](https://example.com/a\)b) ||secret||`) + if err != nil { + t.Fatal(err) + } + if plain != "bold 😀 site secret" { + t.Fatalf("plain = %q", plain) + } + want := []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 7}, + {Type: domain.MessageEntityItalic, Offset: 5, Length: 2}, + {Type: domain.MessageEntityTextURL, Offset: 8, Length: 4, URL: "https://example.com/a)b"}, + {Type: domain.MessageEntitySpoiler, Offset: 13, Length: 6}, + } + if !reflect.DeepEqual(entities, want) { + t.Fatalf("entities = %#v, want %#v", entities, want) + } + + plain, entities, err = parseBotAPIMarkdownV2(">visible\n>hidden||") + if err != nil { + t.Fatal(err) + } + if plain != "visible\nhidden" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBlockquote, Offset: 0, Length: 14, Collapsed: true}}) { + t.Fatalf("expandable quote plain=%q entities=%#v", plain, entities) + } +} + +func TestParseBotAPIMarkdownV2FormattedDate(t *testing.T) { + plain, entities, err := parseBotAPIMarkdownV2(`![when](tg://time?unix=1700000000&format=wdT)`) + if err != nil { + t.Fatal(err) + } + want := []domain.MessageEntity{{ + Type: domain.MessageEntityFormattedDate, Offset: 0, Length: 4, Date: 1700000000, + DayOfWeek: true, ShortDate: true, LongTime: true, + }} + if plain != "when" || !reflect.DeepEqual(entities, want) { + t.Fatalf("plain=%q entities=%#v", plain, entities) + } +} + +func TestBotAPIFormattedTextPrecedenceAndEntityBounds(t *testing.T) { + plain, entities, err := botAPIFormattedTextRaw(`ok`, " HTML ", `{not json`, domain.MaxMessageTextLength, true) + if err != nil { + t.Fatal(err) + } + if plain != "ok" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 2}}) { + t.Fatalf("plain=%q entities=%#v", plain, entities) + } + + for name, raw := range map[string]string{ + "unterminated HTML": `broken`, + "reserved MarkdownV2": `plain-text`, + } { + t.Run(name, func(t *testing.T) { + mode := "HTML" + if strings.Contains(name, "MarkdownV2") { + mode = "MarkdownV2" + } + if _, _, err := botAPIFormattedTextRaw(raw, mode, "", domain.MaxMessageTextLength, true); err == nil || !strings.Contains(err.Error(), "Can't parse entities") { + t.Fatalf("error = %v", err) + } + }) + } + + _, _, err = botAPIFormattedText("😀x", "", []apiMessageEntity{{Type: "bold", Offset: 1, Length: 1}}, domain.MaxMessageTextLength, true) + if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" { + t.Fatalf("surrogate-split error = %v", err) + } + _, _, err = botAPIFormattedText("abcdef", "", []apiMessageEntity{ + {Type: "bold", Offset: 0, Length: 4}, + {Type: "italic", Offset: 2, Length: 4}, + }, domain.MaxMessageTextLength, true) + if err == nil || err.Error() != "ENTITY_BOUNDS_INVALID" { + t.Fatalf("crossing error = %v", err) + } +} + +func TestBotAPIExplicitExtendedEntitiesRoundTrip(t *testing.T) { + input := []apiMessageEntity{ + {Type: "expandable_blockquote", Offset: 0, Length: 4}, + {Type: "date_time", Offset: 5, Length: 4, UnixTime: 1700000000, DateTimeFormat: "wdT"}, + {Type: "bank_card_number", Offset: 10, Length: 4}, + } + _, entities, err := botAPIFormattedText("text when 1234", "", input, domain.MaxMessageTextLength, true) + if err != nil { + t.Fatal(err) + } + projected := apiMessageEntities(entities, nil) + if projected[0]["type"] != "expandable_blockquote" || projected[1]["type"] != "date_time" || projected[1]["unix_time"] != 1700000000 || projected[1]["date_time_format"] != "wdT" || projected[2]["type"] != "bank_card_number" { + t.Fatalf("projected = %#v", projected) + } +} + +func TestBotAPIInlineAndNestedMediaUseFormattedTextParser(t *testing.T) { + payload := apiInlineResult{InputMessageContent: json.RawMessage(`{ + "message_text":"inline", + "parse_mode":"HTML", + "entities":[{"type":"bold","offset":999,"length":1}] + }`)} + message, entities, _, err := inputTextMessageContentFromAPI(payload) + if err != nil { + t.Fatal(err) + } + if message != "inline" || !reflect.DeepEqual(entities, []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 6}}) { + t.Fatalf("inline message=%q entities=%#v", message, entities) + } + + fileID := encodeBotAPIFileID("photo:7002:m") + raw, _ := json.Marshal(map[string]any{ + "type": "photo", "media": fileID, "caption": "_media_", "parse_mode": "MarkdownV2", + }) + var input domain.BotAPIEphemeralEditInput + if err := parseEphemeralEditMedia(string(raw), &input); err != nil { + t.Fatal(err) + } + if input.Fields.Message != "media" || !reflect.DeepEqual(input.Fields.Entities, []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}) { + t.Fatalf("media fields=%#v", input.Fields) + } +} + +func TestBotAPIFormattedTextIsUsedByAllMessageEntryPoints(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + sendMessage: domain.Message{ID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "hello"}, + sendMediaMessage: domain.Message{ID: 2, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "caption"}, + editMessage: domain.Message{ID: 3, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Body: "edited"}, + ephemeralMessage: domain.EphemeralMessage{ID: 4, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, Content: domain.EphemeralContent{Message: "ephemeral"}}, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"hello","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || gateway.sendText != "hello" || len(gateway.sendEntities) != 1 || gateway.sendEntities[0].Type != domain.MessageEntityBold { + t.Fatalf("sendMessage status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendText, gateway.sendEntities) + } + + fileID := encodeBotAPIFileID("doc:7001") + body, _ := json.Marshal(map[string]any{"chat_id": 2001, "document": fileID, "caption": "*caption*", "parse_mode": "MarkdownV2"}) + rec = performBotAPIRequest(t, h, bots.profile, "sendDocument", string(body)) + if rec.Code != http.StatusOK || gateway.sendMediaCaption != "caption" || len(gateway.sendMediaEntities) != 1 || gateway.sendMediaEntities[0].Type != domain.MessageEntityBold { + t.Fatalf("sendDocument status=%d body=%s caption=%q entities=%#v", rec.Code, rec.Body.String(), gateway.sendMediaCaption, gateway.sendMediaEntities) + } + + rec = performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"chat_id":2001,"message_id":3,"text":"_edited_","parse_mode":"Markdown"}`) + if rec.Code != http.StatusOK || gateway.editText != "edited" || len(gateway.editEntities) != 1 || gateway.editEntities[0].Type != domain.MessageEntityItalic { + t.Fatalf("edit status=%d body=%s text=%q entities=%#v", rec.Code, rec.Body.String(), gateway.editText, gateway.editEntities) + } + + rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"text":"ephemeral","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralSends) == 0 { + t.Fatalf("ephemeral status=%d body=%s", rec.Code, rec.Body.String()) + } + lastSend := gateway.ephemeralSends[len(gateway.ephemeralSends)-1] + if lastSend.Text != "ephemeral" || len(lastSend.Entities) != 1 || lastSend.Entities[0].Type != domain.MessageEntityUnderline { + t.Fatalf("ephemeral status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastSend) + } + + rec = performBotAPIRequest(t, h, bots.profile, "editEphemeralMessageCaption", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":4,"caption":"caption","parse_mode":"HTML"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralEdits) == 0 { + t.Fatalf("ephemeral edit status=%d body=%s", rec.Code, rec.Body.String()) + } + lastEdit := gateway.ephemeralEdits[len(gateway.ephemeralEdits)-1] + if lastEdit.Fields.Message != "caption" || len(lastEdit.Fields.Entities) != 1 || lastEdit.Fields.Entities[0].Type != domain.MessageEntityStrike { + t.Fatalf("ephemeral edit status=%d body=%s input=%#v", rec.Code, rec.Body.String(), lastEdit) + } +} + +func FuzzBotAPIFormattedTextParsersNeverPanic(f *testing.F) { + for _, seed := range []string{"", "plain", "x", "<", "&broken", "*x*", "_", ">quote\n>hidden||", "![x](tg://emoji?id=1)", "😀"} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input string) { + if len(input) > 4096 || !utf8.ValidString(input) { + return + } + _, _, _ = parseBotAPIHTML(input) + _, _, _ = parseBotAPIMarkdown(input) + _, _, _ = parseBotAPIMarkdownV2(input) + }) +} + +func TestBotAPIHTMLParseFailureIsAtomic(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":2001,"text":"broken","parse_mode":"HTML"}`) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "Can't parse entities") || gateway.sendCalled { + t.Fatalf("status=%d body=%s gatewayCalled=%v", rec.Code, rec.Body.String(), gateway.sendCalled) + } +} diff --git a/internal/botapi/inline.go b/internal/botapi/inline.go index 42f61344..74d03bf5 100644 --- a/internal/botapi/inline.go +++ b/internal/botapi/inline.go @@ -6,7 +6,6 @@ import ( "net/url" "strconv" "strings" - "unicode/utf8" "telesrv/internal/domain" "telesrv/internal/store" @@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M } else if payload.MessageText != "" { content.MessageText = payload.MessageText } - if content.ParseMode != "" { - return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED") - } - message := content.MessageText - if message == "" { - return "", nil, false, errors.New("MESSAGE_EMPTY") - } - if utf8.RuneCountInString(message) > domain.MaxMessageTextLength { - return "", nil, false, errors.New("MESSAGE_TOO_LONG") - } - entities, err := messageEntitiesFromAPI(content.Entities) + message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true) if err != nil { return "", nil, false, err } @@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") } item := domain.MessageEntity{ - Type: mapped, - Offset: entity.Offset, - Length: entity.Length, - URL: entity.URL, - Language: entity.Language, + Type: mapped, + Offset: entity.Offset, + Length: entity.Length, } - if entity.User != nil { - item.UserID = entity.User.ID - } - if entity.CustomEmojiID != "" { + switch mapped { + case domain.MessageEntityTextURL: + resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length) + if !ok { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + item = resolved + case domain.MessageEntityMentionName: + if entity.User != nil { + item.UserID = entity.User.ID + } + if item.UserID <= 0 { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + case domain.MessageEntityPre: + item.Language = entity.Language + case domain.MessageEntityBlockquote: + item.Collapsed = entity.Type == "expandable_blockquote" + case domain.MessageEntityCustomEmoji: id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64) if err != nil || id <= 0 { return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") } item.DocumentID = id + case domain.MessageEntityFormattedDate: + formatted, err := botAPIFormattedDate(entity.UnixTime, entity.DateTimeFormat) + if err != nil { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + formatted.Offset, formatted.Length = entity.Offset, entity.Length + item = formatted } out = append(out, item) } @@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) { return domain.MessageEntitySpoiler, true case "blockquote": return domain.MessageEntityBlockquote, true + case "expandable_blockquote": + return domain.MessageEntityBlockquote, true case "custom_emoji": return domain.MessageEntityCustomEmoji, true case "mention": @@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) { return domain.MessageEntityEmail, true case "phone_number": return domain.MessageEntityPhone, true + case "bank_card_number": + return domain.MessageEntityBankCard, true + case "date_time": + return domain.MessageEntityFormattedDate, true default: return "", false } @@ -450,8 +465,10 @@ type apiMessageEntity struct { User *struct { ID int64 `json:"id"` } `json:"user"` - Language string `json:"language"` - CustomEmojiID string `json:"custom_emoji_id"` + Language string `json:"language"` + CustomEmojiID string `json:"custom_emoji_id"` + UnixTime int `json:"unix_time"` + DateTimeFormat string `json:"date_time_format"` } type apiInlineKeyboardMarkup struct { diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 4bc08efe..668586d7 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -402,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) "offset": entity.Offset, "length": entity.Length, } + if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed { + item["type"] = "expandable_blockquote" + } if entity.URL != "" { item["url"] = entity.URL } @@ -418,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User) if entity.DocumentID != 0 { item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10) } + if entity.Type == domain.MessageEntityFormattedDate { + item["unix_time"] = entity.Date + item["date_time_format"] = botAPIFormattedDateFormat(entity) + } out = append(out, item) } return out @@ -461,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) { return "email", true case domain.MessageEntityPhone: return "phone_number", true + case domain.MessageEntityBankCard: + return "bank_card_number", true + case domain.MessageEntityFormattedDate: + return "date_time", true default: return "", false } diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 49539b66..5495724c 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -15,7 +15,6 @@ import ( "strings" "sync" "time" - "unicode/utf8" "go.uber.org/zap" @@ -525,16 +524,7 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6 writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") return } - text := values["text"] - if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY") - return - } - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["entities"]) + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -602,19 +592,11 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") return } - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["caption_entities"]) + caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return } - if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength { - writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") - return - } var markup *domain.MessageReplyMarkup if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { markup, err = replyMarkupFromAPI(json.RawMessage(raw)) @@ -662,7 +644,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, - TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities, + TopMessageID: ephemeral.topMessageID, Kind: kind, Text: caption, Entities: entities, ReplyMarkup: markup, File: file, SecondaryFile: secondary, }) if err != nil { @@ -673,7 +655,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, return } locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes - msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0)) + msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, caption, entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0)) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) return @@ -713,11 +695,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID") return } - if strings.TrimSpace(values["parse_mode"]) != "" { - writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") - return - } - entities, err := botAPIMessageEntities(values["entities"]) + text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -737,7 +715,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID writeAPIError(w, http.StatusBadRequest, err.Error()) return } - ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) + ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) return @@ -745,7 +723,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID writeAPIOK(w, ok) return } - msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) + msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, text, entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"])) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) return @@ -1366,7 +1344,6 @@ func apiErrorDescription(err error) string { "BOT_INVALID", "CHAT_ID_INVALID", "ENTITY_INVALID", - "ENTITY_PARSE_UNSUPPORTED", "ENTITIES_TOO_LONG", "ENTITY_BOUNDS_INVALID", "ENTITY_TYPE_UNSUPPORTED", diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index 83f0206d..b6f33227 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -1335,12 +1335,17 @@ type fakeBotAPIGateway struct { sendMediaFileName string sendMediaBytes []byte sendMediaCaption string + sendMediaEntities []domain.MessageEntity sendMediaMessage domain.Message editCalled bool + editText string + editEntities []domain.MessageEntity editSetMarkup bool editMessage domain.Message editInlineCalled bool editInlineID domain.BotInlineMessageID + editInlineText string + editInlineEntities []domain.MessageEntity deleteCalled bool callbackCalled bool callbackID string @@ -1450,17 +1455,22 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int f.sendMediaFileName = fileName f.sendMediaBytes = append([]byte(nil), fileBytes...) f.sendMediaCaption = caption + f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...) return f.sendMediaMessage, nil } func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error) { f.editCalled = true + f.editText = text + f.editEntities = append([]domain.MessageEntity(nil), entities...) f.editSetMarkup = setReplyMarkup return f.editMessage, nil } -func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) { +func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) { f.editInlineCalled, f.editInlineID = true, inlineMessageID + f.editInlineText = text + f.editInlineEntities = append([]domain.MessageEntity(nil), entities...) return true, nil }