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

438 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")