intra_max_bot/app/bot/runtime.py
2026-03-19 16:47:49 +03:00

209 lines
8.1 KiB
Python

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