шиза
This commit is contained in:
parent
5986f420b1
commit
b9e9b5d51b
11 changed files with 204 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<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;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<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 %}
|
||||
<div style="display:flex;{% if msg.direction == 'out' %}justify-content:flex-end{% endif %}">
|
||||
<div style="
|
||||
max-width:70%;
|
||||
max-width:72%;
|
||||
padding:10px 14px;
|
||||
border-radius:12px;
|
||||
font-size:.9rem;
|
||||
line-height:1.5;
|
||||
word-break:break-word;
|
||||
{% if msg.direction == 'out' %}
|
||||
background:#1a73e8;color:white;border-bottom-right-radius:2px;
|
||||
{% else %}
|
||||
background:#f1f3f4;color:#333;border-bottom-left-radius:2px;
|
||||
{% 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' %}
|
||||
<div style="font-size:.8rem;opacity:.8;margin-top:4px">📎 Вложение</div>
|
||||
{% 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] }}
|
||||
{% if msg.direction == 'in' %} · входящее{% else %} · исходящее{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#999;text-align:center">История пуста</p>
|
||||
<p style="color:#999;text-align:center;margin:32px 0">История пуста</p>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
|
|
|
|||
20
bot/bot_instance.py
Normal file
20
bot/bot_instance.py
Normal 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
|
||||
|
|
@ -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"
|
||||
"Наши специалисты свяжутся с вами в ближайшее время."
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
|
|
|
|||
23
bot/send_helper.py
Normal file
23
bot/send_helper.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Loading…
Add table
Reference in a new issue