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.
This commit is contained in:
A 2026-07-20 20:23:47 +08:00
parent afc73ca761
commit 2965f5d47d
11 changed files with 1684 additions and 85 deletions

View file

@ -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 = "<bot_id>:<secret>"
$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 写入文件。

View file

@ -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"<b>{marker} Default HTML</b> "
"<i>italic 😀</i> <u>underline</u> "
"<tg-spoiler>spoiler</tg-spoiler> "
'<a href="https://example.com/bedolaga">link</a>'
),
# 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<TOKEN>",
)
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"<b>{marker} Start OK</b> <i>default HTML inherited</i> 😀"
)
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())

View file

@ -0,0 +1 @@
aiogram==3.30.0

View file

@ -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("<b>BEDOLAGA123 Default HTML</b>", 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()

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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(`<b>A <i>😀</i></b> <a href="tg://user?id=42">Alice</a> <tg-time unix="1700000000" format="wdT">now</tg-time>`)
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(`<pre><code class="language-go">if a &lt; b &amp;&amp; b &gt; c</code></pre>`)
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(`<b>ok</b>`, " 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": `<b>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":"<b>inline</b>",
"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":"<b>hello</b>","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":"<u>ephemeral</u>","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":"<s>caption</s>","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", "<b>x</b>", "<", "&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":"<b>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)
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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",

View file

@ -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
}