438 lines
17 KiB
Python
438 lines
17 KiB
Python
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")
|