342 lines
12 KiB
Python
342 lines
12 KiB
Python
#!/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.exceptions import TelegramBadRequest
|
|
from aiogram.filters import Command, CommandStart
|
|
from aiogram.types import (
|
|
InlineKeyboardButton,
|
|
InlineKeyboardMarkup,
|
|
InputRichMessage,
|
|
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(
|
|
"--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())
|
|
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 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:
|
|
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 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")
|
|
|
|
@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,
|
|
)
|
|
|
|
@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
|
|
|
|
|
|
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:
|
|
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, /formatdemo or /richdemo 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())
|