from __future__ import annotations import json import logging from dataclasses import dataclass from typing import Any, Awaitable, Callable from app.models import ApplicationStatus from app.repositories.chats import ChatRepository from app.services.application_service import ApplicationService from app.services.request_state import RequestDraft, RequestStateStore LOGGER = logging.getLogger(__name__) @dataclass(slots=True) class BotHandlerDeps: app_service: ApplicationService chats: ChatRepository request_state: RequestStateStore request_categories: list[str] operator_chat_ids: list[int] safe_send_message: Callable[[int, str], Awaitable[None]] safe_send_operator_card: Callable[[int, str, int], Awaitable[None]] def register_handlers(dp: Any, deps: BotHandlerDeps) -> None: @dp.message_created() async def on_message_created(message: Any): await _track_chat(deps.chats, message) text = _extract_text(message).strip() if not text: # Возможно пользователь отправил вложения после описания. await _maybe_collect_attachment(message, deps) return if text.startswith("/"): await _handle_command(message, text, deps) return await _handle_request_flow_text(message, text, deps) @dp.message_callback() async def on_message_callback(callback: Any): await _track_chat(deps.chats, callback) payload = _extract_callback_payload(callback) if payload.startswith("demo:"): await _safe_answer(callback, "Демо callback обработан") await _safe_reply( callback, "Callback обработан. Это демонстрация inline-кнопок и payload роутинга.", ) return if not payload.startswith("op:"): await _safe_answer(callback, "Неизвестное callback-действие.") return _, action, app_id_raw = (payload.split(":", 2) + ["", ""])[:3] if not app_id_raw.isdigit(): await _safe_answer(callback, "Некорректный ID заявки.") return app_id = int(app_id_raw) status_map = { "take": ApplicationStatus.IN_PROGRESS.value, "need_info": ApplicationStatus.NEED_INFO.value, "done": ApplicationStatus.DONE.value, "reject": ApplicationStatus.REJECTED.value, } if action not in status_map: await _safe_answer(callback, "Неизвестное действие оператора.") return actor = f"max_operator:{_extract_user_id(callback)}" updated = deps.app_service.update_status(app_id, status_map[action], actor=actor) if not updated: await _safe_answer(callback, "Заявка не найдена.") return await _safe_answer(callback, f"Статус заявки #{app_id} -> {status_map[action]}") await _safe_reply( callback, ( f"Оператор обновил заявку #{app_id}\n" f"Новый статус: {status_map[action]}\n" "Изменение сохранено в БД и видно в веб-панели." ), ) @dp.bot_added() async def on_bot_added(event: Any): await _track_chat(deps.chats, event) await _safe_reply(event, "Спасибо за добавление. Я готов принимать заявки: /request") @dp.bot_removed() async def on_bot_removed(event: Any): chat_id = _extract_chat_id(event) if chat_id is not None: deps.chats.deactivate(chat_id) @dp.user_added() async def on_user_added(event: Any): await _track_chat(deps.chats, event) @dp.user_removed() async def on_user_removed(event: Any): await _track_chat(deps.chats, event) @dp.message_edited() async def on_message_edited(event: Any): await _track_chat(deps.chats, event) @dp.message_removed() async def on_message_removed(event: Any): await _track_chat(deps.chats, event) async def _handle_command(message: Any, text: str, deps: BotHandlerDeps) -> None: cmd = text.split()[0].lower() user_id = _extract_user_id(message) or 0 if cmd in {"/start", "/help"}: categories = "\n".join(f"- {c}" for c in deps.request_categories) await _safe_reply( message, ( "Привет! Я MAX Support Bot.\n\n" "Команды:\n" "/request - создать заявку\n" "/cancel - отменить создание\n" "/demo - показать demo-возможности\n" "/state - состояние каналов\n" "/myid - ваш id\n\n" f"Категории заявок:\n{categories}" ), ) return if cmd == "/myid": await _safe_reply(message, f"Ваш user_id: {user_id}") return if cmd == "/state": await _safe_reply( message, ( f"Локальный канал: {'ON' if deps.app_service.channel_local_enabled else 'OFF'}\n" f"Внешний канал: {'ON' if deps.app_service.channel_external_enabled else 'OFF'}" ), ) return if cmd == "/cancel": await deps.request_state.clear(user_id) await _safe_reply(message, "Создание заявки отменено.") return if cmd == "/demo": await _send_demo_bundle(message) await _safe_reply( message, ( "Demo maxapi:\n" "1) Команды и обработчики событий\n" "2) Callback-действия операторов\n" "3) Сервисные updates (bot/user/message)\n" "4) Роутер + middleware + фильтры (в коде)\n" "5) Polling/Webhook режимы\n" "6) Веб-панель и REST эндпоинты\n" "Нажмите inline-кнопку для callback demo." ), ) return if cmd == "/request": if not deps.app_service.channel_local_enabled and not deps.app_service.channel_external_enabled: await _safe_reply(message, "Создание заявок временно отключено: оба канала выключены.") return draft = RequestDraft(state="awaiting_category") await deps.request_state.set(user_id, draft) categories = "\n".join(f"- {c}" for c in deps.request_categories) await _safe_reply( message, f"Начинаем создание заявки.\nВыберите категорию и отправьте текстом:\n{categories}", ) return await _safe_reply(message, "Неизвестная команда. Используйте /help") async def _handle_request_flow_text(message: Any, text: str, deps: BotHandlerDeps) -> None: user_id = _extract_user_id(message) or 0 draft = await deps.request_state.get(user_id) if draft.state == "idle": return if draft.state == "awaiting_category": if text not in deps.request_categories: await _safe_reply(message, "Неизвестная категория. Выберите из списка или /cancel") return draft.category = text draft.state = "awaiting_description" await deps.request_state.set(user_id, draft) await _safe_reply( message, "Опишите проблему одним сообщением. Можно приложить медиа следующим сообщением.", ) return if draft.state == "awaiting_description": draft.description = text draft.state = "awaiting_confirmation" await deps.request_state.set(user_id, draft) await _safe_reply( message, ( "Подтвердите создание заявки: отправьте `confirm`.\n" "Или отправьте новое описание для замены.\n" "Или /cancel для отмены." ), ) return if draft.state == "awaiting_confirmation": if text.strip().lower() != "confirm": draft.description = text await deps.request_state.set(user_id, draft) await _safe_reply(message, "Описание обновлено. Отправьте `confirm` для создания.") return customer_name = _extract_user_name(message) outcome = await deps.app_service.create_application( customer_id=_extract_user_id(message), customer_name=customer_name, chat_id=_extract_chat_id(message), category=draft.category or "Другое", description=draft.description or "", attachments=draft.attachments, source="bot", ) await deps.request_state.clear(user_id) app_id = outcome.application.id if outcome.application else "external-only" sync_msg = "внешняя система: OK" if outcome.sent_external else f"внешняя система: ERROR ({outcome.external_error})" await _safe_reply( message, f"Заявка создана: #{app_id}\nЛокально: {'да' if outcome.created_local else 'нет'}\n{sync_msg}", ) if outcome.application and deps.operator_chat_ids: await _notify_operators_new_application(outcome.application.id, outcome.application.description, deps) return async def _notify_operators_new_application(app_id: int, description: str, deps: BotHandlerDeps) -> None: text = ( f"Новая заявка #{app_id}\n" f"{description[:500]}\n" "Используйте кнопки ниже для смены статуса." ) for chat_id in deps.operator_chat_ids: try: await deps.safe_send_operator_card(chat_id, text, app_id) except Exception: LOGGER.exception("Failed to notify operator chat_id=%s about app_id=%s", chat_id, app_id) async def _maybe_collect_attachment(message: Any, deps: BotHandlerDeps) -> None: user_id = _extract_user_id(message) or 0 draft = await deps.request_state.get(user_id) if draft.state not in {"awaiting_description", "awaiting_confirmation"}: return attachment = _extract_attachment(message) if not attachment: return draft.attachments.append(attachment) await deps.request_state.set(user_id, draft) await _safe_reply(message, "Вложение добавлено к текущей заявке.") async def _track_chat(chats: ChatRepository, event: Any) -> None: chat_id = _extract_chat_id(event) if chat_id is None: return chats.upsert( chat_id=chat_id, chat_type=_extract_chat_type(event) or "unknown", title=_extract_chat_title(event), username=None, is_active=1, ) def _extract_text(event: Any) -> str: for candidate in ( getattr(event, "text", None), getattr(getattr(event, "message", None), "text", None), getattr(getattr(event, "body", None), "text", None), getattr(getattr(getattr(event, "message", None), "body", None), "text", None), ): if isinstance(candidate, str): return candidate body = getattr(event, "body", None) if body and isinstance(getattr(body, "text", None), str): return body.text return "" def _extract_user_id(event: Any) -> int | None: for obj in (event, getattr(event, "message", None), getattr(event, "callback", None)): user = getattr(obj, "from_user", None) or getattr(obj, "sender", None) or getattr(obj, "user", None) if user is None: continue for attr in ("user_id", "id"): value = getattr(user, attr, None) if isinstance(value, int): return value return None def _extract_user_name(event: Any) -> str: for obj in (event, getattr(event, "message", None)): user = getattr(obj, "from_user", None) or getattr(obj, "sender", None) or getattr(obj, "user", None) if not user: continue for attr in ("username", "display_name", "name", "first_name"): value = getattr(user, attr, None) if isinstance(value, str) and value.strip(): return value.strip() user_id = _extract_user_id(event) return f"user_{user_id}" if user_id else "unknown_user" def _extract_chat_id(event: Any) -> int | None: for obj in (event, getattr(event, "message", None)): chat = getattr(obj, "chat", None) if chat is None: message_obj = getattr(obj, "message", None) or obj recipient = getattr(message_obj, "recipient", None) if recipient is not None: value = getattr(recipient, "chat_id", None) if isinstance(value, int): return value continue for attr in ("chat_id", "id"): value = getattr(chat, attr, None) if isinstance(value, int): return value return None def _extract_chat_type(event: Any) -> str | None: for obj in (event, getattr(event, "message", None)): chat = getattr(obj, "chat", None) if chat is None: message_obj = getattr(obj, "message", None) or obj recipient = getattr(message_obj, "recipient", None) if recipient is not None: value = getattr(recipient, "type", None) if isinstance(value, str): return value continue for attr in ("type", "chat_type"): value = getattr(chat, attr, None) if isinstance(value, str): return value return None def _extract_chat_title(event: Any) -> str | None: for obj in (event, getattr(event, "message", None)): chat = getattr(obj, "chat", None) if chat is None: message_obj = getattr(obj, "message", None) or obj recipient = getattr(message_obj, "recipient", None) if recipient is not None: for attr in ("title", "name"): value = getattr(recipient, attr, None) if isinstance(value, str): return value continue for attr in ("title", "name"): value = getattr(chat, attr, None) if isinstance(value, str): return value return None def _extract_callback_payload(callback: Any) -> str: for attr in ("payload", "data", "callback_data"): value = getattr(callback, attr, None) if isinstance(value, str): return value inner = getattr(callback, "callback", None) if inner: for attr in ("payload", "data", "callback_data"): value = getattr(inner, attr, None) if isinstance(value, str): return value return "" def _extract_attachment(event: Any) -> dict | None: body = getattr(event, "body", None) or getattr(getattr(event, "message", None), "body", None) attachments = getattr(body, "attachments", None) if body else None if not attachments: return None first = attachments[0] att_type = getattr(first, "type", None) or getattr(first, "media_type", None) or "unknown" att_id = getattr(first, "id", None) or getattr(first, "token", None) or "" return {"type": str(att_type), "id": str(att_id)} async def _safe_reply(event: Any, text: str) -> None: message = getattr(event, "message", None) if message and hasattr(message, "answer"): await message.answer(text) return if hasattr(event, "answer"): await event.answer(text) return async def _safe_answer(callback: Any, text: str) -> None: if hasattr(callback, "answer"): await callback.answer(text) return inner = getattr(callback, "callback", None) if inner and hasattr(inner, "answer"): await inner.answer(text) async def _send_demo_bundle(message_event: Any) -> None: message = getattr(message_event, "message", None) if message is None: return try: from maxapi.enums.sender_action import SenderAction from maxapi.types.attachments.buttons.callback_button import CallbackButton from maxapi.types.attachments.buttons.link_button import LinkButton from maxapi.utils.inline_keyboard import InlineKeyboardBuilder bot = message_event._ensure_bot() chat_id = _extract_chat_id(message_event) if chat_id is not None: await bot.send_action(chat_id=chat_id, action=SenderAction.TYPING_ON) keyboard = InlineKeyboardBuilder() keyboard.row(CallbackButton(text="Demo callback", payload="demo:ping")) keyboard.row(LinkButton(text="Open MAX API Docs", url="https://love-apples.github.io/maxapi/")) await message.answer( text="Demo keyboard отправлена. Нажмите кнопку ниже.", attachments=[keyboard.as_markup()], ) except Exception: LOGGER.exception("Failed to send demo bundle")