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" "net/http"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"telesrv/internal/domain" "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 input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup
switch mode { switch mode {
case "text": case "text":
if strings.TrimSpace(values["parse_mode"]) != "" { text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
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"])
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return 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": case "caption":
if strings.TrimSpace(values["parse_mode"]) != "" { caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
} }
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength { input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["caption"], entities
case "reply_markup": case "reply_markup":
input.Fields.SetReplyMarkup = true input.Fields.SetReplyMarkup = true
case "media": case "media":
@ -290,7 +269,7 @@ func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput)
Title string `json:"title"` Title string `json:"title"`
Performer string `json:"performer"` 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") return errors.New("MEDIA_INVALID")
} }
allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true} 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 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 { if err != nil {
return err return err
} }
if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength { input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, caption, entities
return errors.New("MESSAGE_TOO_LONG")
}
input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.Caption, entities
return nil 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" "net/url"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
@ -62,17 +61,7 @@ func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.M
} else if payload.MessageText != "" { } else if payload.MessageText != "" {
content.MessageText = payload.MessageText content.MessageText = payload.MessageText
} }
if content.ParseMode != "" { message, entities, err := botAPIFormattedText(content.MessageText, content.ParseMode, content.Entities, domain.MaxMessageTextLength, true)
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)
if err != nil { if err != nil {
return "", nil, false, err return "", nil, false, err
} }
@ -97,21 +86,41 @@ func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, erro
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
} }
item := domain.MessageEntity{ item := domain.MessageEntity{
Type: mapped, Type: mapped,
Offset: entity.Offset, Offset: entity.Offset,
Length: entity.Length, Length: entity.Length,
URL: entity.URL,
Language: entity.Language,
} }
if entity.User != nil { switch mapped {
item.UserID = entity.User.ID case domain.MessageEntityTextURL:
} resolved, ok := botAPITextLinkEntity(entity.URL, entity.Offset, entity.Length)
if entity.CustomEmojiID != "" { 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) id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") return nil, errors.New("ENTITY_TYPE_UNSUPPORTED")
} }
item.DocumentID = id 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) out = append(out, item)
} }
@ -140,6 +149,8 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntitySpoiler, true return domain.MessageEntitySpoiler, true
case "blockquote": case "blockquote":
return domain.MessageEntityBlockquote, true return domain.MessageEntityBlockquote, true
case "expandable_blockquote":
return domain.MessageEntityBlockquote, true
case "custom_emoji": case "custom_emoji":
return domain.MessageEntityCustomEmoji, true return domain.MessageEntityCustomEmoji, true
case "mention": case "mention":
@ -156,6 +167,10 @@ func apiEntityType(in string) (domain.MessageEntityType, bool) {
return domain.MessageEntityEmail, true return domain.MessageEntityEmail, true
case "phone_number": case "phone_number":
return domain.MessageEntityPhone, true return domain.MessageEntityPhone, true
case "bank_card_number":
return domain.MessageEntityBankCard, true
case "date_time":
return domain.MessageEntityFormattedDate, true
default: default:
return "", false return "", false
} }
@ -450,8 +465,10 @@ type apiMessageEntity struct {
User *struct { User *struct {
ID int64 `json:"id"` ID int64 `json:"id"`
} `json:"user"` } `json:"user"`
Language string `json:"language"` Language string `json:"language"`
CustomEmojiID string `json:"custom_emoji_id"` CustomEmojiID string `json:"custom_emoji_id"`
UnixTime int `json:"unix_time"`
DateTimeFormat string `json:"date_time_format"`
} }
type apiInlineKeyboardMarkup struct { type apiInlineKeyboardMarkup struct {

View file

@ -402,6 +402,9 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
"offset": entity.Offset, "offset": entity.Offset,
"length": entity.Length, "length": entity.Length,
} }
if entity.Type == domain.MessageEntityBlockquote && entity.Collapsed {
item["type"] = "expandable_blockquote"
}
if entity.URL != "" { if entity.URL != "" {
item["url"] = entity.URL item["url"] = entity.URL
} }
@ -418,6 +421,10 @@ func apiMessageEntities(in []domain.MessageEntity, users map[int64]domain.User)
if entity.DocumentID != 0 { if entity.DocumentID != 0 {
item["custom_emoji_id"] = strconv.FormatInt(entity.DocumentID, 10) 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) out = append(out, item)
} }
return out return out
@ -461,6 +468,10 @@ func botAPIEntityType(in domain.MessageEntityType) (string, bool) {
return "email", true return "email", true
case domain.MessageEntityPhone: case domain.MessageEntityPhone:
return "phone_number", true return "phone_number", true
case domain.MessageEntityBankCard:
return "bank_card_number", true
case domain.MessageEntityFormattedDate:
return "date_time", true
default: default:
return "", false return "", false
} }

View file

@ -15,7 +15,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"go.uber.org/zap" "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") writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return return
} }
text := values["text"] text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
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"])
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
@ -602,19 +592,11 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
return return
} }
if strings.TrimSpace(values["parse_mode"]) != "" { caption, entities, err := botAPIFormattedTextRaw(values["caption"], values["parse_mode"], values["caption_entities"], domain.MaxEphemeralCaptionLength, false)
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["caption_entities"])
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
} }
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
var markup *domain.MessageReplyMarkup var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(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{ message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{
BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID,
CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, 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, ReplyMarkup: markup, File: file, SecondaryFile: secondary,
}) })
if err != nil { if err != nil {
@ -673,7 +655,7 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
return return
} }
locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return return
@ -713,11 +695,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID") writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
return return
} }
if strings.TrimSpace(values["parse_mode"]) != "" { text, entities, err := botAPIFormattedTextRaw(values["text"], values["parse_mode"], values["entities"], domain.MaxMessageTextLength, true)
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return
}
entities, err := botAPIMessageEntities(values["entities"])
if err != nil { if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
@ -737,7 +715,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
return 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return return
@ -745,7 +723,7 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
writeAPIOK(w, ok) writeAPIOK(w, ok)
return 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
return return
@ -1366,7 +1344,6 @@ func apiErrorDescription(err error) string {
"BOT_INVALID", "BOT_INVALID",
"CHAT_ID_INVALID", "CHAT_ID_INVALID",
"ENTITY_INVALID", "ENTITY_INVALID",
"ENTITY_PARSE_UNSUPPORTED",
"ENTITIES_TOO_LONG", "ENTITIES_TOO_LONG",
"ENTITY_BOUNDS_INVALID", "ENTITY_BOUNDS_INVALID",
"ENTITY_TYPE_UNSUPPORTED", "ENTITY_TYPE_UNSUPPORTED",

View file

@ -1335,12 +1335,17 @@ type fakeBotAPIGateway struct {
sendMediaFileName string sendMediaFileName string
sendMediaBytes []byte sendMediaBytes []byte
sendMediaCaption string sendMediaCaption string
sendMediaEntities []domain.MessageEntity
sendMediaMessage domain.Message sendMediaMessage domain.Message
editCalled bool editCalled bool
editText string
editEntities []domain.MessageEntity
editSetMarkup bool editSetMarkup bool
editMessage domain.Message editMessage domain.Message
editInlineCalled bool editInlineCalled bool
editInlineID domain.BotInlineMessageID editInlineID domain.BotInlineMessageID
editInlineText string
editInlineEntities []domain.MessageEntity
deleteCalled bool deleteCalled bool
callbackCalled bool callbackCalled bool
callbackID string callbackID string
@ -1450,17 +1455,22 @@ func (f *fakeBotAPIGateway) BotAPISendMedia(_ context.Context, botID, chatID int
f.sendMediaFileName = fileName f.sendMediaFileName = fileName
f.sendMediaBytes = append([]byte(nil), fileBytes...) f.sendMediaBytes = append([]byte(nil), fileBytes...)
f.sendMediaCaption = caption f.sendMediaCaption = caption
f.sendMediaEntities = append([]domain.MessageEntity(nil), entities...)
return f.sendMediaMessage, nil 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) { 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.editCalled = true
f.editText = text
f.editEntities = append([]domain.MessageEntity(nil), entities...)
f.editSetMarkup = setReplyMarkup f.editSetMarkup = setReplyMarkup
return f.editMessage, nil 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.editInlineCalled, f.editInlineID = true, inlineMessageID
f.editInlineText = text
f.editInlineEntities = append([]domain.MessageEntity(nil), entities...)
return true, nil return true, nil
} }