#!/usr/bin/env python3 """aiogram 3 echo/button demo for the telesrv Bot API endpoint.""" import argparse import asyncio import logging import os from aiogram import Bot, Dispatcher, F, Router from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TelegramAPIServer from aiogram.enums import ButtonStyle from aiogram.filters import Command, CommandStart from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, Message, ReplyParameters, ReplyKeyboardMarkup, ) from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiohttp import web LOG = logging.getLogger("aiogramecho") def env_int(name: str) -> int | None: raw = os.getenv(name) if not raw: return None try: return int(raw) except ValueError as exc: raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="aiogram 3 echo bot 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; aiogram adds /bot and /file/bot", ) parser.add_argument("--prefix", default="aiogram echo: ") parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ") parser.add_argument("--drop-pending", action="store_true") parser.add_argument("--mode", choices=("polling", "webhook"), default=os.getenv("TELESRV_BOT_MODE", "polling")) parser.add_argument( "--webhook-url", default=os.getenv("TELESRV_BOT_WEBHOOK_URL", ""), help="Public HTTP(S) URL including the webhook path", ) parser.add_argument( "--webhook-path", default=os.getenv("TELESRV_BOT_WEBHOOK_PATH", "/webhook"), help="Local aiohttp route, normally the path part of --webhook-url", ) parser.add_argument("--webhook-secret", default=os.getenv("TELESRV_BOT_WEBHOOK_SECRET", "telesrv-aiogram-demo")) parser.add_argument("--listen-host", default=os.getenv("TELESRV_BOT_LISTEN_HOST", "127.0.0.1")) parser.add_argument("--listen-port", type=int, default=env_int("TELESRV_BOT_LISTEN_PORT") or 8082) parser.add_argument("--delete-webhook-on-exit", action="store_true") parser.add_argument("--timeout", type=int, default=30) parser.add_argument("--send-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_CHAT_ID")) parser.add_argument("--send-text", default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", "")) parser.add_argument("--buttons-chat-id", type=int, default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID")) parser.add_argument( "--button-icon-id", default=os.getenv("TELESRV_BOT_DEMO_BUTTON_ICON_ID"), help="Optional custom emoji document id used as the button icon", ) parser.add_argument("--send-only", action="store_true") 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.timeout < 0 or args.timeout > 50: parser.error("--timeout must be between 0 and 50") if args.send_text and args.send_chat_id is None: parser.error("--send-chat-id is required with --send-text") if args.send_only and not args.send_text and args.buttons_chat_id is None: parser.error("--send-only requires --send-text or --buttons-chat-id") if args.button_icon_id: try: if int(args.button_icon_id) <= 0: raise ValueError except ValueError as exc: raise SystemExit("--button-icon-id must be a positive integer") from exc if args.mode == "webhook" and not args.webhook_url: parser.error("--webhook-url or TELESRV_BOT_WEBHOOK_URL is required in webhook mode") if not args.webhook_path.startswith("/") or "?" in args.webhook_path or "#" in args.webhook_path: parser.error("--webhook-path must be an absolute path without query or fragment") if args.listen_port < 1 or args.listen_port > 65535: parser.error("--listen-port must be between 1 and 65535") return args def reply_keyboard(icon_id: str | None) -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [ KeyboardButton(text="Primary", style=ButtonStyle.PRIMARY, icon_custom_emoji_id=icon_id), KeyboardButton(text="Success", style=ButtonStyle.SUCCESS, icon_custom_emoji_id=icon_id), KeyboardButton(text="Danger", style=ButtonStyle.DANGER, icon_custom_emoji_id=icon_id), ] ], resize_keyboard=True, one_time_keyboard=True, input_field_placeholder="Tap a colored reply button", ) def inline_keyboard(icon_id: str | None) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text="Primary", callback_data="aiogram-primary", style=ButtonStyle.PRIMARY, icon_custom_emoji_id=icon_id, ), InlineKeyboardButton( text="Success", callback_data="aiogram-success", style=ButtonStyle.SUCCESS, icon_custom_emoji_id=icon_id, ), InlineKeyboardButton( text="Danger", callback_data="aiogram-danger", style=ButtonStyle.DANGER, icon_custom_emoji_id=icon_id, ), ] ] ) async def send_button_messages(bot: Bot, chat_id: int, icon_id: str | None) -> None: reply = await bot.send_message( chat_id=chat_id, text="TELESRV_AIOGRAM_REPLY_STYLES_20260719", reply_markup=reply_keyboard(icon_id), ) inline = await bot.send_message( chat_id=chat_id, text="TELESRV_AIOGRAM_INLINE_STYLES_20260719", reply_markup=inline_keyboard(icon_id), ) LOG.info( "sent styled buttons chat_id=%s reply_message_id=%s inline_message_id=%s", chat_id, reply.message_id, inline.message_id, ) def ephemeral_message_id(message: object) -> int | None: """Read the native aiogram field, retaining an extra-field fallback.""" raw = getattr(message, "ephemeral_message_id", None) if raw is None: raw = (getattr(message, "model_extra", None) or {}).get("ephemeral_message_id") if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0: return raw return None async def send_echo(message: Message, prefix: str, ephemeral_prefix: str): text = message.text or message.caption or "" if not text: return None transient_id = ephemeral_message_id(message) if transient_id is None: return await message.answer(prefix + text) if message.from_user is None: LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id) return None return await message.bot.send_message( chat_id=message.chat.id, text=ephemeral_prefix + text, receiver_user_id=message.from_user.id, reply_parameters=ReplyParameters(ephemeral_message_id=transient_id), ) def build_dispatcher(args: argparse.Namespace) -> Dispatcher: router = Router(name="telesrv-aiogramecho") @router.message(CommandStart()) async def start(message: Message) -> None: await message.answer("send /ping , /buttons, or any private text") @router.message(Command("buttons")) async def buttons(message: Message) -> None: await send_button_messages(message.bot, message.chat.id, args.button_icon_id) @router.message(Command("ping")) async def ping(message: Message) -> None: await send_echo(message, args.prefix, args.ephemeral_prefix) @router.message(Command("private")) async def private(message: Message) -> None: sent = await send_echo(message, args.prefix, args.ephemeral_prefix) LOG.info( "echoed ephemeral chat_id=%s ephemeral_message_id=%s sent_ephemeral_message_id=%s", message.chat.id, ephemeral_message_id(message), ephemeral_message_id(sent) if sent is not None else None, ) @router.callback_query(F.data.startswith("aiogram-")) async def callback(query: CallbackQuery) -> None: await query.answer(f"telesrv {query.data} callback OK") LOG.info("answered callback query_id=%s data=%r", query.id, query.data) @router.callback_query() async def fallback_callback(query: CallbackQuery) -> None: """Keep the echo demo responsive for buttons created by another demo.""" await query.answer("telesrv callback OK") LOG.info("answered fallback callback query_id=%s data=%r", query.id, query.data) @router.message(F.text) async def echo(message: Message) -> None: await send_echo(message, args.prefix, args.ephemeral_prefix) LOG.info("echoed chat_id=%s message_id=%s", message.chat.id, message.message_id) dispatcher = Dispatcher() dispatcher.include_router(router) return dispatcher def build_bot(args: argparse.Namespace) -> Bot: session = AiohttpSession(api=TelegramAPIServer.from_base(args.base_url.rstrip("/"))) return Bot(token=args.token, session=session) async def run(args: argparse.Namespace) -> None: bot = build_bot(args) runner: web.AppRunner | None = None try: me = await bot.get_me() LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url) if args.send_chat_id is not None and args.send_text: sent = await bot.send_message(chat_id=args.send_chat_id, text=args.send_text) LOG.info("sent proactive chat_id=%s message_id=%s", args.send_chat_id, sent.message_id) if args.buttons_chat_id is not None: await send_button_messages(bot, args.buttons_chat_id, args.button_icon_id) if args.send_only: return dispatcher = build_dispatcher(args) allowed_updates = ["message", "edited_message", "callback_query"] if args.mode == "polling": await bot.delete_webhook(drop_pending_updates=args.drop_pending) await dispatcher.start_polling( bot, allowed_updates=allowed_updates, polling_timeout=args.timeout, close_bot_session=False, ) return application = web.Application() SimpleRequestHandler( dispatcher=dispatcher, bot=bot, secret_token=args.webhook_secret, ).register(application, path=args.webhook_path) setup_application(application, dispatcher, bot=bot) await bot.set_webhook( url=args.webhook_url, secret_token=args.webhook_secret, allowed_updates=allowed_updates, drop_pending_updates=args.drop_pending, ) runner = web.AppRunner(application) await runner.setup() site = web.TCPSite(runner, host=args.listen_host, port=args.listen_port) await site.start() LOG.info( "webhook listening on http://%s:%s%s, public_url=%s", args.listen_host, args.listen_port, args.webhook_path, args.webhook_url, ) await asyncio.Event().wait() finally: if args.mode == "webhook" and args.delete_webhook_on_exit: try: await bot.delete_webhook() except Exception: # pragma: no cover - best-effort shutdown logging LOG.exception("failed to delete webhook during shutdown") if runner is not None: await runner.cleanup() 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())