This commit is contained in:
dv 2026-03-26 00:54:10 +03:00
parent 5986f420b1
commit b9e9b5d51b
11 changed files with 204 additions and 35 deletions

View file

@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin from admin.auth import require_admin
from admin.templates_env import templates 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)]) 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) @router.get("/{max_user_id}", response_class=HTMLResponse)
async def chat_history(request: Request, max_user_id: int): async def chat_history(request: Request, max_user_id: int):
messages = await get_message_log(max_user_id=max_user_id, limit=200) 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( return templates.TemplateResponse(
request=request, request=request,
name="chat_log/history.html", 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)

View file

@ -1,38 +1,92 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<h1>История чата</h1>
<a href="/admin/chat-log" class="btn btn-secondary" style="margin-bottom:16px;display:inline-block">К списку чатов</a>
<div class="card"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<div style="display:flex;flex-direction:column;gap:10px"> <a href="/admin/chat-log" class="btn btn-secondary">К списку</a>
<div>
<strong>
{{ bot_user.first_name or bot_user.username or ('MAX ID: ' ~ max_user_id) if bot_user else ('MAX ID: ' ~ max_user_id) }}
</strong>
{% if bot_user %}
<span style="color:#666;font-size:.85rem;margin-left:8px">
MAX ID: {{ max_user_id }}
{% if bot_user.phone %} · {{ bot_user.phone }}{% endif %}
{% if bot_user.org_name %} · {{ bot_user.org_name }}{% endif %}
</span>
<span style="margin-left:8px">
{% if bot_user.is_blocked %}
<span class="badge badge-blocked">Заблокирован</span>
{% elif bot_user.is_authorized %}
<span class="badge badge-auth">Авторизован</span>
{% else %}
<span class="badge badge-pending">Ожидает</span>
{% endif %}
</span>
{% endif %}
</div>
</div>
{# Лента сообщений #}
<div class="card" style="padding:16px;margin-bottom:12px">
<div style="display:flex;flex-direction:column;gap:10px;max-height:60vh;overflow-y:auto;padding-right:4px" id="chat-feed">
{% for msg in messages|reverse %} {% for msg in messages|reverse %}
<div style="display:flex;{% if msg.direction == 'out' %}justify-content:flex-end{% endif %}"> <div style="display:flex;{% if msg.direction == 'out' %}justify-content:flex-end{% endif %}">
<div style=" <div style="
max-width:70%; max-width:72%;
padding:10px 14px; padding:10px 14px;
border-radius:12px; border-radius:12px;
font-size:.9rem; font-size:.9rem;
line-height:1.5; line-height:1.5;
word-break:break-word;
{% if msg.direction == 'out' %} {% if msg.direction == 'out' %}
background:#1a73e8;color:white;border-bottom-right-radius:2px; background:#1a73e8;color:white;border-bottom-right-radius:2px;
{% else %} {% else %}
background:#f1f3f4;color:#333;border-bottom-left-radius:2px; background:#f1f3f4;color:#333;border-bottom-left-radius:2px;
{% endif %} {% endif %}
"> ">
{% if msg.text %}<div>{{ msg.text }}</div>{% endif %} {% if msg.direction == 'out' %}
<div style="font-size:.7rem;opacity:.7;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em">Поддержка</div>
{% endif %}
{% if msg.text %}<div style="white-space:pre-wrap">{{ msg.text }}</div>{% endif %}
{% if msg.attachment_json and msg.attachment_json != 'null' %} {% if msg.attachment_json and msg.attachment_json != 'null' %}
<div style="font-size:.8rem;opacity:.8;margin-top:4px">📎 Вложение</div> <div style="font-size:.8rem;opacity:.8;margin-top:4px">📎 Вложение</div>
{% endif %} {% endif %}
<div style="font-size:.75rem;opacity:.7;margin-top:4px;text-align:right"> <div style="font-size:.72rem;opacity:.65;margin-top:6px;text-align:right">
{{ msg.created_at[:16] }} {{ msg.created_at[:16] }}
{% if msg.direction == 'in' %} · входящее{% else %} · исходящее{% endif %}
</div> </div>
</div> </div>
</div> </div>
{% else %} {% else %}
<p style="color:#999;text-align:center">История пуста</p> <p style="color:#999;text-align:center;margin:32px 0">История пуста</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{# Форма отправки #}
{% if bot_user and bot_user.max_chat_id %}
<div class="card" style="padding:16px">
<form action="/admin/chat-log/{{ max_user_id }}/send" method="post"
style="display:flex;gap:8px;align-items:flex-end">
<textarea name="text" rows="3" required placeholder="Напишите сообщение от лица бота..."
style="flex:1;resize:vertical;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:.9rem;font-family:inherit"
onkeydown="if(event.ctrlKey&&event.key==='Enter'){this.form.submit()}"></textarea>
<button class="btn btn-primary" type="submit" style="height:fit-content;padding:10px 20px">
Отправить
</button>
</form>
<div style="font-size:.75rem;color:#999;margin-top:6px">Ctrl+Enter для отправки</div>
</div>
{% else %}
<div class="card" style="padding:16px;color:#999;font-size:.9rem">
Отправка недоступна: нет chat_id (пользователь ещё не начал диалог с ботом).
</div>
{% endif %}
<script>
// Прокрутить ленту вниз при загрузке
const feed = document.getElementById('chat-feed');
if (feed) feed.scrollTop = feed.scrollHeight;
</script>
{% endblock %} {% endblock %}

20
bot/bot_instance.py Normal file
View file

@ -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

View file

@ -22,6 +22,7 @@ from maxapi.types.updates.message_created import MessageCreated
from bot.keyboards import done_ticket_kb, main_menu_kb from bot.keyboards import done_ticket_kb, main_menu_kb
from bot.logging_setup import get_logger from bot.logging_setup import get_logger
from bot.send_helper import bot_send
from bot.states import TicketStates from bot.states import TicketStates
logger = get_logger("bot.handlers.create_ticket") logger = get_logger("bot.handlers.create_ticket")
@ -70,8 +71,10 @@ async def cb_create_ticket(
if chat_id is None: if chat_id is None:
return return
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=( text=(
"📝 Опишите вашу проблему.\n\n" "📝 Опишите вашу проблему.\n\n"
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n" "Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
@ -85,7 +88,7 @@ async def cb_create_ticket(
async def on_ticket_description( async def on_ticket_description(
event: MessageCreated, context: BaseContext event: MessageCreated, context: BaseContext
) -> None: ) -> None:
chat_id, _ = event.get_ids() chat_id, user_id = event.get_ids()
if chat_id is None: if chat_id is None:
return return
@ -99,8 +102,10 @@ async def on_ticket_description(
) )
await context.set_state(TicketStates.collecting_attachments) await context.set_state(TicketStates.collecting_attachments)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text=( text=(
"✏️ Описание принято. Вы можете добавить ещё файлы " "✏️ Описание принято. Вы можете добавить ещё файлы "
"или нажмите «Готово» для отправки заявки." "или нажмите «Готово» для отправки заявки."
@ -145,8 +150,10 @@ async def cb_done_ticket(
if not description: if not description:
await event.answer(notification="Сначала опишите проблему!") await event.answer(notification="Сначала опишите проблему!")
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text="❗ Пожалуйста, сначала напишите описание проблемы.", text="❗ Пожалуйста, сначала напишите описание проблемы.",
attachments=[done_ticket_kb()], attachments=[done_ticket_kb()],
) )
@ -173,8 +180,10 @@ async def cb_done_ticket(
await context.clear() await context.clear()
await event.answer(notification="Заявка создана!") await event.answer(notification="Заявка создана!")
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=( text=(
f"✅ Заявка №{ticket_id} успешно создана!\n" f"✅ Заявка №{ticket_id} успешно создана!\n"
"Наши специалисты свяжутся с вами в ближайшее время." "Наши специалисты свяжутся с вами в ближайшее время."

View file

@ -5,12 +5,15 @@ from maxapi.types.updates.message_callback import MessageCallback
from maxapi.context import BaseContext from maxapi.context import BaseContext
from bot.keyboards import main_menu_kb from bot.keyboards import main_menu_kb
from bot.send_helper import bot_send
router = Router(router_id="menu") router = Router(router_id="menu")
@router.message_callback(F.callback.payload == "main_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 context.set_state(None)
await event.answer(notification="") await event.answer(notification="")
@ -18,8 +21,10 @@ async def cb_main_menu(event: MessageCallback, context: BaseContext) -> None:
if chat_id is None: if chat_id is None:
return return
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text="Главное меню:", text="Главное меню:",
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )

View file

@ -18,6 +18,7 @@ from maxapi.filters.command import Command
from bot.keyboards import contact_request_kb, main_menu_kb from bot.keyboards import contact_request_kb, main_menu_kb
from bot.logging_setup import get_logger from bot.logging_setup import get_logger
from bot.send_helper import bot_send
from bot.states import AuthStates from bot.states import AuthStates
logger = get_logger("bot.handlers.start") 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"]: if bot_user["is_blocked"]:
logger.info("🚫 bot_started blocked | user_id=%s", user.user_id) 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, chat_id=event.chat_id,
user_id=user.user_id,
text=( text=(
"⛔ Ваш номер не найден в системе. Доступ заблокирован.\n" "⛔ Ваш номер не найден в системе. Доступ заблокирован.\n"
"Обратитесь к администратору для подключения." "Обратитесь к администратору для подключения."
@ -65,8 +68,10 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
if bot_user["is_authorized"]: if bot_user["is_authorized"]:
logger.info("✅ bot_started returning user | user_id=%s", user.user_id) logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=event.chat_id, chat_id=event.chat_id,
user_id=user.user_id,
text="👋 С возвращением! Выберите действие:", text="👋 С возвращением! Выберите действие:",
attachments=[main_menu_kb()], 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) logger.info("👋 bot_started new user | user_id=%s", user.user_id)
await context.set_state(AuthStates.waiting_contact) await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=event.chat_id, chat_id=event.chat_id,
user_id=user.user_id,
text=( text=(
"👋 Добро пожаловать в систему технической поддержки!\n\n" "👋 Добро пожаловать в систему технической поддержки!\n\n"
"Для входа нажмите кнопку ниже и поделитесь своим номером телефона." "Для входа нажмите кнопку ниже и поделитесь своим номером телефона."
@ -101,8 +108,10 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
first_name=None, first_name=None,
) )
await context.set_state(AuthStates.waiting_contact) await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text=( text=(
"👋 Добро пожаловать!\n\n" "👋 Добро пожаловать!\n\n"
"Поделитесь номером телефона для доступа к системе." "Поделитесь номером телефона для доступа к системе."
@ -112,24 +121,30 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
return return
if bot_user["is_blocked"]: if bot_user["is_blocked"]:
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="⛔ Доступ заблокирован. Обратитесь к администратору.", text="⛔ Доступ заблокирован. Обратитесь к администратору.",
) )
return return
if not bot_user["is_authorized"]: if not bot_user["is_authorized"]:
await context.set_state(AuthStates.waiting_contact) await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="Поделитесь номером телефона для доступа:", text="Поделитесь номером телефона для доступа:",
attachments=[contact_request_kb()], attachments=[contact_request_kb()],
) )
return return
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="Главное меню:", text="Главное меню:",
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )
@ -148,8 +163,10 @@ async def on_contact_shared(
# Извлекаем телефон из VCF # Извлекаем телефон из VCF
payload = contact.payload payload = contact.payload
if not isinstance(payload, ContactAttachmentPayload): if not isinstance(payload, ContactAttachmentPayload):
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="Не удалось прочитать контакт. Попробуйте ещё раз.", text="Не удалось прочитать контакт. Попробуйте ещё раз.",
attachments=[contact_request_kb()], attachments=[contact_request_kb()],
) )
@ -158,8 +175,10 @@ async def on_contact_shared(
vcf = payload.vcf vcf = payload.vcf
raw_phone = vcf.phone raw_phone = vcf.phone
if not raw_phone: if not raw_phone:
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="В контакте не найден номер телефона. Попробуйте ещё раз.", text="В контакте не найден номер телефона. Попробуйте ещё раз.",
attachments=[contact_request_kb()], 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) 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 block_bot_user(max_user_id=user_id, phone=phone)
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text=( text=(
"⛔ Ваш номер не найден в системе.\n" "⛔ Ваш номер не найден в системе.\n"
"Доступ заблокирован. Обратитесь к администратору для подключения." "Доступ заблокирован. Обратитесь к администратору для подключения."
@ -202,8 +223,10 @@ async def on_contact_shared(
) )
await context.set_state(None) await context.set_state(None)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=user_id,
text="✅ Вы успешно авторизованы! Выберите действие:", text="✅ Вы успешно авторизованы! Выберите действие:",
attachments=[main_menu_kb()], attachments=[main_menu_kb()],
) )

View file

@ -14,6 +14,7 @@ from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith from bot.filters import PayloadStartsWith
from bot.keyboards import ticket_detail_kb, tickets_list_kb 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.ticket_attachments import get_attachments
from database.queries.tickets import ( from database.queries.tickets import (
PAGE_SIZE, PAGE_SIZE,
@ -43,15 +44,19 @@ async def _send_tickets_page(
return return
if not tickets: if not tickets:
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text="📭 У вашей организации пока нет заявок.", text="📭 У вашей организации пока нет заявок.",
attachments=[ticket_detail_kb(page=0)], attachments=[ticket_detail_kb(page=0)],
) )
return return
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=f"📋 Заявки организации (стр. {page + 1}):", text=f"📋 Заявки организации (стр. {page + 1}):",
attachments=[tickets_list_kb(tickets, page=page, total=total)], attachments=[tickets_list_kb(tickets, page=page, total=total)],
) )
@ -127,8 +132,10 @@ async def cb_ticket_detail(
att_lines.append(line) att_lines.append(line)
text += "\n\n📎 Вложения:\n" + "\n".join(att_lines) text += "\n\n📎 Вложения:\n" + "\n".join(att_lines)
await event.bot.send_message( await bot_send(
event.bot,
chat_id=chat_id, chat_id=chat_id,
user_id=bot_user["max_user_id"],
text=text, text=text,
attachments=[ticket_detail_kb(page=0)], attachments=[ticket_detail_kb(page=0)],
) )

23
bot/send_helper.py Normal file
View file

@ -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,
)

View file

@ -3,12 +3,14 @@ from maxapi.context import MemoryContext
from maxapi.dispatcher import Dispatcher, Router from maxapi.dispatcher import Dispatcher, Router
import config import config
from bot.bot_instance import set_bot
from bot.middleware import AuthMiddleware from bot.middleware import AuthMiddleware
from bot.handlers import start, menu, create_ticket, view_tickets from bot.handlers import start, menu, create_ticket, view_tickets
def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]: def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
bot = Bot(token=config.BOT_TOKEN) bot = Bot(token=config.BOT_TOKEN)
set_bot(bot)
dp = Dispatcher(router_id="main", storage=MemoryContext) dp = Dispatcher(router_id="main", storage=MemoryContext)
dp.outer_middleware(AuthMiddleware()) dp.outer_middleware(AuthMiddleware())

Binary file not shown.

Binary file not shown.