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:
parent
afc73ca761
commit
2965f5d47d
11 changed files with 1684 additions and 85 deletions
50
cmd/bots/bedolagaformat/README.md
Normal file
50
cmd/bots/bedolagaformat/README.md
Normal 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 写入文件。
|
||||
221
cmd/bots/bedolagaformat/demo.py
Normal file
221
cmd/bots/bedolagaformat/demo.py
Normal 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())
|
||||
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
1
cmd/bots/bedolagaformat/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
aiogram==3.30.0
|
||||
52
cmd/bots/bedolagaformat/test_demo.py
Normal file
52
cmd/bots/bedolagaformat/test_demo.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue