209 lines
8.1 KiB
Python
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
|