feat: sync bot rich messages and inline menus

This commit is contained in:
A 2026-07-21 15:44:43 +08:00
parent 2965f5d47d
commit 1a2d03f529
24 changed files with 2073 additions and 38 deletions

View file

@ -14,6 +14,13 @@ telesrv 发送 `parse_mode=HTML`。`/formatdemo` 依次发送默认 HTML、legac
MarkdownV2用于验证完整的 `aiogram → telesrv Bot API → MTProto message/update →
TDesktop` 链路。
`/richdemo` 进一步复刻 Bedolaga 的 rich menu调用 `sendRichMessage` 发送 HTML 与
Markdown `InputRichMessage`,携带 inline callback keyboard再通过
`editMessageText.rich_message` 编辑 HTML 菜单。HTML 样例覆盖 heading、divider、
bordered/striped table、`tg-time`、details、blockquote、code 与 footer。第一次请求
故意带远程 logo当前本地 blob backend 返回 `WEBPAGE_MEDIA_EMPTY`demo 按
Bedolaga 的既有策略自动去掉 logo 重试,正文与按钮不会降级成 classic menu。
## 安装
建议使用虚拟环境token 只通过环境变量传入:
@ -34,6 +41,7 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
```text
/start
/formatdemo
/richdemo
```
也可以不启动 polling直接向指定私聊发送三条格式测试消息
@ -46,5 +54,16 @@ $env:TELESRV_BOT_API_SERVER = "http://127.0.0.1:8081"
--marker BEDOLAGA-LOCAL-VERIFY
```
只主动验证 rich menuHTML + Markdown + 按钮 + 编辑 + logo fallback
```powershell
& "$env:TEMP\telesrv-bedolaga-demo-venv\Scripts\python.exe" `
.\cmd\bots\bedolagaformat\demo.py `
--send-only `
--rich-only `
--send-chat-id 1780243200 `
--marker BEDOLAGA-RICH-VERIFY
```
`--base-url` 只接受 API server 根地址,不要追加 `/bot`。脚本不会打印 token也不会
把 token 写入文件。

View file

@ -21,8 +21,14 @@ 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.exceptions import TelegramBadRequest
from aiogram.filters import Command, CommandStart
from aiogram.types import Message
from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
InputRichMessage,
Message,
)
LOG = logging.getLogger("bedolagaformat")
@ -101,6 +107,16 @@ def parse_args() -> argparse.Namespace:
help="Send the complete suite proactively before polling",
)
parser.add_argument("--send-only", action="store_true")
parser.add_argument(
"--rich-menu",
action="store_true",
help="also send and edit Bedolaga-style rich HTML/Markdown menus",
)
parser.add_argument(
"--rich-only",
action="store_true",
help="with --send-only, send only the rich menu suite",
)
parser.add_argument("--drop-pending", action="store_true")
parser.add_argument("--polling-timeout", type=int, default=10)
parser.add_argument("--marker", default=default_marker())
@ -110,6 +126,8 @@ def parse_args() -> argparse.Namespace:
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 args.rich_only and (not args.send_only or args.send_chat_id is None):
parser.error("--rich-only requires --send-only and --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:
@ -151,6 +169,96 @@ async def send_format_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
return message_ids
def rich_menu_html(marker: str, *, include_logo: bool) -> str:
"""Build the rich HTML families used by Bedolaga's main menu."""
logo = '<img src="https://example.com/bedolaga-logo.png">' if include_logo else ""
return (
f"{logo}<h4>{marker} Admin</h4>"
"<h6>Subscription overview</h6><hr>"
"<table bordered striped>"
"<tr><th>Status</th><td align=\"right\">Active</td></tr>"
"<tr><th>Updated</th><td align=\"right\">"
'<tg-time unix="1700000000" format="R">now</tg-time>'
"</td></tr></table>"
"<details open><summary>Diagnostics</summary>"
"<blockquote><code>rich menu online</code></blockquote></details>"
"<footer>Choose an option</footer>"
)
def rich_menu_markdown(marker: str) -> str:
return (
f"#### {marker} Markdown menu\n\n"
"**Subscription:** Active\n\n"
"> Rich Markdown transport is online.\n\n"
"`callback keyboard preserved`"
)
def rich_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="Balance", callback_data="menu:balance"),
InlineKeyboardButton(text="Buy", callback_data="menu:buy"),
],
[InlineKeyboardButton(text="Info", callback_data="menu:info")],
]
)
def is_rich_media_retry_error(exc: TelegramBadRequest) -> bool:
message = str(exc).lower()
return "webpage_" in message or "media_empty" in message or "media_invalid" in message
async def send_rich_suite(bot: Bot, chat_id: int, marker: str) -> list[int]:
"""Exercise Bedolaga's send, no-logo retry, keyboard and rich edit path."""
markup = rich_menu_keyboard()
try:
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=True),
skip_entity_detection=True,
),
reply_markup=markup,
)
except TelegramBadRequest as exc:
if not is_rich_media_retry_error(exc):
raise
LOG.info("rich logo fetch rejected; retrying the menu without logo")
html_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
html=rich_menu_html(marker, include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
markdown_message = await bot.send_rich_message(
chat_id=chat_id,
rich_message=InputRichMessage(
markdown=rich_menu_markdown(marker),
skip_entity_detection=True,
),
reply_markup=markup,
)
await bot.edit_message_text(
chat_id=chat_id,
message_id=html_message.message_id,
rich_message=InputRichMessage(
html=rich_menu_html(f"{marker} EDITED", include_logo=False),
skip_entity_detection=True,
),
reply_markup=markup,
)
ids = [html_message.message_id, markdown_message.message_id]
LOG.info("sent rich menu suite chat_id=%s message_ids=%s", chat_id, ids)
return ids
def build_dispatcher(marker: str) -> Dispatcher:
router = Router(name="telesrv-bedolaga-format")
@ -173,6 +281,16 @@ def build_dispatcher(marker: str) -> Dispatcher:
ids,
)
@router.message(Command("richdemo"))
async def rich_demo(message: Message) -> None:
ids = await send_rich_suite(message.bot, message.chat.id, marker)
LOG.info(
"handled /richdemo 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
@ -190,13 +308,16 @@ async def run(args: argparse.Namespace) -> None:
args.marker,
)
if args.send_chat_id is not None:
await send_format_suite(bot, args.send_chat_id, args.marker)
if not args.rich_only:
await send_format_suite(bot, args.send_chat_id, args.marker)
if args.rich_menu or args.rich_only:
await send_rich_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)
LOG.info("polling started; send /start, /formatdemo or /richdemo to @%s", me.username or me.id)
await dispatcher.start_polling(
bot,
allowed_updates=["message"],

View file

@ -5,6 +5,9 @@ import unittest
from unittest.mock import AsyncMock
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendRichMessage
from aiogram.types import InputRichMessage
MODULE_PATH = Path(__file__).with_name("demo.py")
@ -47,6 +50,45 @@ class BedolagaFormatDemoTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(calls[1].kwargs["parse_mode"], ParseMode.MARKDOWN)
self.assertEqual(calls[2].kwargs["parse_mode"], ParseMode.MARKDOWN_V2)
def test_rich_menu_covers_bedolaga_html_and_keyboard(self) -> None:
html = demo.rich_menu_html("BEDOLAGA123", include_logo=False)
self.assertIn("<h4>BEDOLAGA123 Admin</h4>", html)
self.assertIn("<table bordered striped>", html)
self.assertIn("<tg-time", html)
self.assertIn("<details open>", html)
self.assertIn("<footer>", html)
markup = demo.rich_menu_keyboard()
self.assertEqual(markup.inline_keyboard[0][0].callback_data, "menu:balance")
self.assertEqual(markup.inline_keyboard[1][0].callback_data, "menu:info")
async def test_rich_suite_retries_without_logo_and_edits(self) -> None:
bot = AsyncMock()
media_error = TelegramBadRequest(
method=SendRichMessage(
chat_id=1780243200,
rich_message=InputRichMessage(html="<p>fixture</p>"),
),
message="WEBPAGE_MEDIA_EMPTY",
)
bot.send_rich_message.side_effect = [
media_error,
SentMessage(21),
SentMessage(22),
]
bot.edit_message_text.return_value = SentMessage(21)
ids = await demo.send_rich_suite(bot, 1780243200, "BEDOLAGA123")
self.assertEqual(ids, [21, 22])
sends = bot.send_rich_message.await_args_list
self.assertEqual(len(sends), 3)
self.assertIn("<img", sends[0].kwargs["rich_message"].html)
self.assertNotIn("<img", sends[1].kwargs["rich_message"].html)
self.assertIsNotNone(sends[2].kwargs["rich_message"].markdown)
edit = bot.edit_message_text.await_args
self.assertEqual(edit.kwargs["message_id"], 21)
self.assertIn("EDITED", edit.kwargs["rich_message"].html)
if __name__ == "__main__":
unittest.main()