from __future__ import annotations import asyncio import logging from typing import Any from app.bot.handlers import BotHandlerDeps, register_handlers from app.container import Container LOGGER = logging.getLogger(__name__) class BotRuntime: def __init__(self, container: Container) -> None: self._container = container self._enabled = False self._polling_task: asyncio.Task | None = None self.bot: Any | None = None self.dp: Any | None = None self._process_webhook_update = None self._init_maxapi_runtime() @property def enabled(self) -> bool: return self._enabled def _init_maxapi_runtime(self) -> None: token = self._container.settings.max_bot_token if not token: LOGGER.warning("MAX_BOT_TOKEN is empty. Bot runtime is disabled.") return try: from maxapi import Bot, Dispatcher, Router from maxapi.filters.middleware import BaseMiddleware from maxapi.methods.types.getted_updates import process_update_webhook from maxapi.types.command import BotCommand except Exception as exc: LOGGER.exception("Failed to import maxapi: %s", exc) return self.bot = Bot(token=token) if self._container.settings.max_bot_api_url: self.bot.set_api_url(self._container.settings.max_bot_api_url) self.dp = Dispatcher() self._process_webhook_update = process_update_webhook class TraceMiddleware(BaseMiddleware): async def __call__(self, handler, event_object, data): data["trace"] = f"upd:{getattr(event_object, 'update_type', 'unknown')}" return await handler(event_object, data) self.dp.outer_middleware(TraceMiddleware()) main_router = Router("main") deps = BotHandlerDeps( app_service=self._container.app_service, chats=self._container.chats, request_state=self._container.request_state, request_categories=self._container.settings.request_categories or [], operator_chat_ids=self._container.settings.operator_chat_ids or [], safe_send_message=self.safe_send_message, safe_send_operator_card=self.safe_send_operator_card, ) register_handlers(main_router, deps) self.dp.include_routers(main_router) self._commands = [ BotCommand(name="start", description="Старт и справка"), BotCommand(name="help", description="Команды"), BotCommand(name="request", description="Создать заявку"), BotCommand(name="cancel", description="Отменить создание заявки"), BotCommand(name="demo", description="Demo возможностей"), BotCommand(name="state", description="Состояние каналов"), BotCommand(name="myid", description="Показать user_id"), ] self._enabled = True async def startup(self) -> None: if not self._enabled: return assert self.bot is not None try: if hasattr(self.bot, "set_my_commands"): await self.bot.set_my_commands(*self._commands) except Exception: LOGGER.exception("set_my_commands failed") if self._container.settings.max_use_webhook: LOGGER.info("Bot runtime started in webhook mode.") return LOGGER.info("Starting MAX polling task.") self._polling_task = asyncio.create_task(self._run_polling()) async def shutdown(self) -> None: if self._polling_task: self._polling_task.cancel() try: await self._polling_task except asyncio.CancelledError: pass except Exception: LOGGER.exception("Polling task failed while shutdown.") if self.bot and hasattr(self.bot, "close_session"): try: await self.bot.close_session() except Exception: LOGGER.exception("Failed to close bot.") async def _run_polling(self) -> None: assert self.bot is not None assert self.dp is not None try: if hasattr(self.dp, "start_polling"): await self.dp.start_polling(self.bot) else: LOGGER.error("maxapi Dispatcher has no start_polling method.") except asyncio.CancelledError: raise except Exception: LOGGER.exception("Polling loop crashed.") async def handle_webhook_payload(self, payload: dict) -> None: if not self._enabled: return if not self._container.settings.max_use_webhook: LOGGER.warning("Webhook payload received while MAX_USE_WEBHOOK=false.") return assert self.dp is not None if self._process_webhook_update is None: LOGGER.error("No webhook parser configured from maxapi.") return try: update = await self._process_webhook_update(event_json=payload, bot=self.bot) if update is None: return await self.dp.handle(update) except Exception: LOGGER.exception("Failed to process webhook payload.") async def safe_send_message(self, chat_id: int, text: str) -> None: if not self.bot: return try: await self.bot.send_message(chat_id=chat_id, text=text) return except TypeError: pass except Exception: LOGGER.exception("send_message(chat_id=..., text=...) failed") try: await self.bot.send_message(chat_id, text) except Exception: LOGGER.exception("send_message positional call failed") async def safe_send_operator_card(self, chat_id: int, text: str, app_id: int) -> None: if not self.bot: return try: from maxapi.types.attachments.buttons.callback_button import CallbackButton from maxapi.utils.inline_keyboard import InlineKeyboardBuilder keyboard = InlineKeyboardBuilder() keyboard.row( CallbackButton(text="Взять", payload=f"op:take:{app_id}"), CallbackButton(text="Уточнить", payload=f"op:need_info:{app_id}"), ) keyboard.row( CallbackButton(text="Готово", payload=f"op:done:{app_id}"), CallbackButton(text="Отклонить", payload=f"op:reject:{app_id}"), ) await self.bot.send_message(chat_id=chat_id, text=text, attachments=[keyboard.as_markup()]) return except Exception: LOGGER.exception("Failed to send operator card with keyboard, fallback to plain text.") await self.safe_send_message( chat_id, f"{text}\nFallback payloads: op:take:{app_id}, op:need_info:{app_id}, op:done:{app_id}, op:reject:{app_id}", ) async def sync_chats_from_api(self) -> dict[str, int]: """Best-effort sync for /chats/sync. Returns counters.""" counters = {"fetched": 0, "stored": 0} if not self.bot: return counters if not hasattr(self.bot, "get_chats"): return counters try: chats = await self.bot.get_chats() except Exception: LOGGER.exception("get_chats failed") return counters items = getattr(chats, "chats", []) or [] for chat in items: counters["fetched"] += 1 chat_id = getattr(chat, "chat_id", None) or getattr(chat, "id", None) if not isinstance(chat_id, int): continue self._container.chats.upsert( chat_id=chat_id, chat_type=str(getattr(chat, "type", "unknown")), title=getattr(chat, "title", None) or getattr(chat, "name", None), username=getattr(chat, "username", None), is_active=1, ) counters["stored"] += 1 return counters