feat: sync bot rich messages and inline menus
This commit is contained in:
parent
2965f5d47d
commit
1a2d03f529
24 changed files with 2073 additions and 38 deletions
|
|
@ -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 写入文件。
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue