diff --git a/admin/routes/chat_log.py b/admin/routes/chat_log.py index 9baa100..e544e0a 100644 --- a/admin/routes/chat_log.py +++ b/admin/routes/chat_log.py @@ -1,9 +1,11 @@ -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse from admin.auth import require_admin from admin.templates_env import templates -from database.queries.message_log import get_chat_list, get_message_log +from bot.bot_instance import get_bot +from database.queries.bot_users import get_bot_user_by_max_id +from database.queries.message_log import get_chat_list, get_message_log, log_message router = APIRouter(prefix="/chat-log", dependencies=[Depends(require_admin)]) @@ -19,8 +21,32 @@ async def chat_list(request: Request): @router.get("/{max_user_id}", response_class=HTMLResponse) async def chat_history(request: Request, max_user_id: int): messages = await get_message_log(max_user_id=max_user_id, limit=200) + bot_user = await get_bot_user_by_max_id(max_user_id) return templates.TemplateResponse( request=request, name="chat_log/history.html", - context={"messages": messages, "max_user_id": max_user_id}, + context={ + "messages": messages, + "max_user_id": max_user_id, + "bot_user": bot_user, + }, ) + + +@router.post("/{max_user_id}/send") +async def send_message_to_user(max_user_id: int, text: str = Form(...)): + bot_user = await get_bot_user_by_max_id(max_user_id) + if bot_user is None or not bot_user.get("max_chat_id"): + return RedirectResponse(f"/admin/chat-log/{max_user_id}", status_code=303) + + chat_id = bot_user["max_chat_id"] + bot = get_bot() + await bot.send_message(chat_id=chat_id, text=text) + await log_message( + direction="out", + max_user_id=max_user_id, + max_chat_id=chat_id, + message_id=None, + text=text, + ) + return RedirectResponse(f"/admin/chat-log/{max_user_id}", status_code=303) diff --git a/admin/templates/chat_log/history.html b/admin/templates/chat_log/history.html index 2b5b3cb..209610d 100644 --- a/admin/templates/chat_log/history.html +++ b/admin/templates/chat_log/history.html @@ -1,38 +1,92 @@ {% extends "base.html" %} -{% block title %}История чата {{ max_user_id }}{% endblock %} +{% block title %}Чат — {{ bot_user.first_name or bot_user.username or max_user_id if bot_user else max_user_id }}{% endblock %} {% block content %} -

История чата

-← К списку чатов -
-
+
+ ← К списку +
+ + {{ bot_user.first_name or bot_user.username or ('MAX ID: ' ~ max_user_id) if bot_user else ('MAX ID: ' ~ max_user_id) }} + + {% if bot_user %} + + MAX ID: {{ max_user_id }} + {% if bot_user.phone %} · {{ bot_user.phone }}{% endif %} + {% if bot_user.org_name %} · {{ bot_user.org_name }}{% endif %} + + + {% if bot_user.is_blocked %} + Заблокирован + {% elif bot_user.is_authorized %} + Авторизован + {% else %} + Ожидает + {% endif %} + + {% endif %} +
+
+ +{# Лента сообщений #} +
+
{% for msg in messages|reverse %}
- {% if msg.text %}
{{ msg.text }}
{% endif %} + {% if msg.direction == 'out' %} +
Поддержка
+ {% endif %} + {% if msg.text %}
{{ msg.text }}
{% endif %} {% if msg.attachment_json and msg.attachment_json != 'null' %}
📎 Вложение
{% endif %} -
+
{{ msg.created_at[:16] }} - {% if msg.direction == 'in' %} · входящее{% else %} · исходящее{% endif %}
{% else %} -

История пуста

+

История пуста

{% endfor %}
+ +{# Форма отправки #} +{% if bot_user and bot_user.max_chat_id %} +
+
+ + +
+
Ctrl+Enter для отправки
+
+{% else %} +
+ ℹ️ Отправка недоступна: нет chat_id (пользователь ещё не начал диалог с ботом). +
+{% endif %} + + + {% endblock %} diff --git a/bot/bot_instance.py b/bot/bot_instance.py new file mode 100644 index 0000000..ec94d58 --- /dev/null +++ b/bot/bot_instance.py @@ -0,0 +1,20 @@ +"""Глобальное хранилище экземпляра бота для доступа из admin-панели.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from maxapi.bot import Bot + +_bot: "Bot | None" = None + + +def set_bot(bot: "Bot") -> None: + global _bot + _bot = bot + + +def get_bot() -> "Bot": + if _bot is None: + raise RuntimeError("Bot not initialized — call set_bot() first") + return _bot diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index f90cc5d..a362cf7 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -22,6 +22,7 @@ from maxapi.types.updates.message_created import MessageCreated from bot.keyboards import done_ticket_kb, main_menu_kb from bot.logging_setup import get_logger +from bot.send_helper import bot_send from bot.states import TicketStates logger = get_logger("bot.handlers.create_ticket") @@ -70,8 +71,10 @@ async def cb_create_ticket( if chat_id is None: return - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text=( "📝 Опишите вашу проблему.\n\n" "Вы также можете прикрепить файлы, фото или голосовые сообщения.\n" @@ -85,7 +88,7 @@ async def cb_create_ticket( async def on_ticket_description( event: MessageCreated, context: BaseContext ) -> None: - chat_id, _ = event.get_ids() + chat_id, user_id = event.get_ids() if chat_id is None: return @@ -99,8 +102,10 @@ async def on_ticket_description( ) await context.set_state(TicketStates.collecting_attachments) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text=( "✏️ Описание принято. Вы можете добавить ещё файлы " "или нажмите «Готово» для отправки заявки." @@ -145,8 +150,10 @@ async def cb_done_ticket( if not description: await event.answer(notification="Сначала опишите проблему!") - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text="❗ Пожалуйста, сначала напишите описание проблемы.", attachments=[done_ticket_kb()], ) @@ -173,8 +180,10 @@ async def cb_done_ticket( await context.clear() await event.answer(notification="Заявка создана!") - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text=( f"✅ Заявка №{ticket_id} успешно создана!\n" "Наши специалисты свяжутся с вами в ближайшее время." diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index fc1e18d..5059aa3 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -5,12 +5,15 @@ from maxapi.types.updates.message_callback import MessageCallback from maxapi.context import BaseContext from bot.keyboards import main_menu_kb +from bot.send_helper import bot_send router = Router(router_id="menu") @router.message_callback(F.callback.payload == "main_menu") -async def cb_main_menu(event: MessageCallback, context: BaseContext) -> None: +async def cb_main_menu( + event: MessageCallback, context: BaseContext, bot_user: dict +) -> None: await context.set_state(None) await event.answer(notification="") @@ -18,8 +21,10 @@ async def cb_main_menu(event: MessageCallback, context: BaseContext) -> None: if chat_id is None: return - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text="Главное меню:", attachments=[main_menu_kb()], ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 45099cb..6437fe7 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -18,6 +18,7 @@ from maxapi.filters.command import Command from bot.keyboards import contact_request_kb, main_menu_kb from bot.logging_setup import get_logger +from bot.send_helper import bot_send from bot.states import AuthStates logger = get_logger("bot.handlers.start") @@ -53,8 +54,10 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: if bot_user["is_blocked"]: logger.info("🚫 bot_started blocked | user_id=%s", user.user_id) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=event.chat_id, + user_id=user.user_id, text=( "⛔ Ваш номер не найден в системе. Доступ заблокирован.\n" "Обратитесь к администратору для подключения." @@ -65,8 +68,10 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: if bot_user["is_authorized"]: logger.info("✅ bot_started returning user | user_id=%s", user.user_id) await context.set_state(None) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=event.chat_id, + user_id=user.user_id, text="👋 С возвращением! Выберите действие:", attachments=[main_menu_kb()], ) @@ -75,8 +80,10 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None: # Новый пользователь — запрашиваем контакт logger.info("👋 bot_started new user | user_id=%s", user.user_id) await context.set_state(AuthStates.waiting_contact) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=event.chat_id, + user_id=user.user_id, text=( "👋 Добро пожаловать в систему технической поддержки!\n\n" "Для входа нажмите кнопку ниже и поделитесь своим номером телефона." @@ -101,8 +108,10 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None: first_name=None, ) await context.set_state(AuthStates.waiting_contact) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text=( "👋 Добро пожаловать!\n\n" "Поделитесь номером телефона для доступа к системе." @@ -112,24 +121,30 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None: return if bot_user["is_blocked"]: - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="⛔ Доступ заблокирован. Обратитесь к администратору.", ) return if not bot_user["is_authorized"]: await context.set_state(AuthStates.waiting_contact) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="Поделитесь номером телефона для доступа:", attachments=[contact_request_kb()], ) return await context.set_state(None) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="Главное меню:", attachments=[main_menu_kb()], ) @@ -148,8 +163,10 @@ async def on_contact_shared( # Извлекаем телефон из VCF payload = contact.payload if not isinstance(payload, ContactAttachmentPayload): - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="Не удалось прочитать контакт. Попробуйте ещё раз.", attachments=[contact_request_kb()], ) @@ -158,8 +175,10 @@ async def on_contact_shared( vcf = payload.vcf raw_phone = vcf.phone if not raw_phone: - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="В контакте не найден номер телефона. Попробуйте ещё раз.", attachments=[contact_request_kb()], ) @@ -183,8 +202,10 @@ async def on_contact_shared( logger.warning("🚫 phone not found, blocking | user_id=%s phone=%s", user_id, phone) await block_bot_user(max_user_id=user_id, phone=phone) await context.set_state(None) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text=( "⛔ Ваш номер не найден в системе.\n" "Доступ заблокирован. Обратитесь к администратору для подключения." @@ -202,8 +223,10 @@ async def on_contact_shared( ) await context.set_state(None) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=user_id, text="✅ Вы успешно авторизованы! Выберите действие:", attachments=[main_menu_kb()], ) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 6b7f6c3..8393128 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -14,6 +14,7 @@ from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith from bot.keyboards import ticket_detail_kb, tickets_list_kb +from bot.send_helper import bot_send from database.queries.ticket_attachments import get_attachments from database.queries.tickets import ( PAGE_SIZE, @@ -43,15 +44,19 @@ async def _send_tickets_page( return if not tickets: - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text="📭 У вашей организации пока нет заявок.", attachments=[ticket_detail_kb(page=0)], ) return - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text=f"📋 Заявки организации (стр. {page + 1}):", attachments=[tickets_list_kb(tickets, page=page, total=total)], ) @@ -127,8 +132,10 @@ async def cb_ticket_detail( att_lines.append(line) text += "\n\n📎 Вложения:\n" + "\n".join(att_lines) - await event.bot.send_message( + await bot_send( + event.bot, chat_id=chat_id, + user_id=bot_user["max_user_id"], text=text, attachments=[ticket_detail_kb(page=0)], ) diff --git a/bot/send_helper.py b/bot/send_helper.py new file mode 100644 index 0000000..64fc51c --- /dev/null +++ b/bot/send_helper.py @@ -0,0 +1,23 @@ +"""Обёртка над bot.send_message с автоматическим логированием исходящих.""" +from database.queries.message_log import log_message + + +async def bot_send( + bot, + *, + chat_id: int, + user_id: int | None, + text: str, + attachments=None, +) -> None: + kw: dict = {"chat_id": chat_id, "text": text} + if attachments is not None: + kw["attachments"] = attachments + await bot.send_message(**kw) + await log_message( + direction="out", + max_user_id=user_id, + max_chat_id=chat_id, + message_id=None, + text=text, + ) diff --git a/bot/setup.py b/bot/setup.py index a7dd21b..eb14788 100644 --- a/bot/setup.py +++ b/bot/setup.py @@ -3,12 +3,14 @@ from maxapi.context import MemoryContext from maxapi.dispatcher import Dispatcher, Router import config +from bot.bot_instance import set_bot from bot.middleware import AuthMiddleware from bot.handlers import start, menu, create_ticket, view_tickets def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]: bot = Bot(token=config.BOT_TOKEN) + set_bot(bot) dp = Dispatcher(router_id="main", storage=MemoryContext) dp.outer_middleware(AuthMiddleware()) diff --git a/data/support.db-shm b/data/support.db-shm index 46ff81f..beaf4b9 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index c7fb78f..f45ce7a 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ