diff --git a/cmd/bots/bedolagaformat/README.md b/cmd/bots/bedolagaformat/README.md
index 32dfccb4..155cff04 100644
--- a/cmd/bots/bedolagaformat/README.md
+++ b/cmd/bots/bedolagaformat/README.md
@@ -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 menu(HTML + 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 写入文件。
diff --git a/cmd/bots/bedolagaformat/demo.py b/cmd/bots/bedolagaformat/demo.py
index a90746e5..d339a8db 100644
--- a/cmd/bots/bedolagaformat/demo.py
+++ b/cmd/bots/bedolagaformat/demo.py
@@ -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 = '
' if include_logo else ""
+ return (
+ f"{logo}
{marker} Admin
"
+ "Subscription overview
"
+ ""
+ "| Status | Active |
"
+ "| Updated | "
+ 'now'
+ " |
|---|
"
+ "Diagnostics
"
+ "rich menu online
"
+ ""
+ )
+
+
+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"],
diff --git a/cmd/bots/bedolagaformat/test_demo.py b/cmd/bots/bedolagaformat/test_demo.py
index 26530af2..d4e005e2 100644
--- a/cmd/bots/bedolagaformat/test_demo.py
+++ b/cmd/bots/bedolagaformat/test_demo.py
@@ -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("BEDOLAGA123 Admin
", html)
+ self.assertIn("", html)
+ self.assertIn("", html)
+ self.assertIn("