1701 lines
71 KiB
Python
1701 lines
71 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from html import escape
|
||
|
||
from aiogram import Bot, Dispatcher, F, Router
|
||
from aiogram.client.default import DefaultBotProperties
|
||
from aiogram.enums import ParseMode
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from aiogram.filters import Command, CommandStart
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||
|
||
from bot.config import Config, load_config
|
||
from bot.db import Database, is_overdue, sla_remaining_minutes
|
||
from bot.keyboards import (
|
||
admin_cancel,
|
||
admin_dashboard,
|
||
assignee_menu,
|
||
back_to_ticket,
|
||
back_to_user_tickets,
|
||
DEFER_REASONS,
|
||
attachment_actions,
|
||
cancel_creation,
|
||
chat_session_kb,
|
||
defer_reasons_menu,
|
||
duplicate_options,
|
||
filter_title,
|
||
instruction_result,
|
||
main_menu,
|
||
open_tickets,
|
||
priority_label,
|
||
priority_menu,
|
||
sections,
|
||
status_label,
|
||
subtopics,
|
||
admin_reply_kb,
|
||
ticket_actions,
|
||
topics,
|
||
user_chat_controls,
|
||
user_close_confirm,
|
||
user_comment_cancel,
|
||
user_reply_kb,
|
||
user_ticket_actions,
|
||
user_tickets,
|
||
)
|
||
from bot.states import AdminFlow, TicketFlow, UserFlow
|
||
from bot.taxonomy import (
|
||
has_subtopics,
|
||
instruction_for,
|
||
section_label,
|
||
subtopic_label,
|
||
topic_label,
|
||
)
|
||
|
||
router = Router()
|
||
db: Database
|
||
config: Config
|
||
|
||
# admin_id -> ticket_id: пока исполнитель в режиме чата по заявке.
|
||
active_admin_chat: dict[int, int] = {}
|
||
|
||
FAQ_TEXT = """
|
||
<b>FAQ</b>
|
||
|
||
• Если нет интернета: проверьте кабель/Wi-Fi и перезагрузите роутер, если он рядом.
|
||
• Если нет доступа: укажите систему, логин и текст ошибки.
|
||
• Если компьютер тормозит: перезагрузите ПК и закройте лишние программы.
|
||
• Если программа выдает ошибку: приложите скриншот и напишите, что делали перед ошибкой.
|
||
"""
|
||
|
||
# Инструкции и FAQ-подсказки по подтемам теперь живут в bot/taxonomy.py
|
||
# (структура есть, тексты можно дописывать без правки кода).
|
||
|
||
EVENT_LABELS = {
|
||
"created": "🆕",
|
||
"assign": "▶️",
|
||
"status": "🔁",
|
||
"priority": "🎚",
|
||
"note": "📝",
|
||
"user_comment": "💬",
|
||
"message": "✉️",
|
||
"close": "✅",
|
||
"reject": "🚫",
|
||
"reopen": "♻️",
|
||
"escalate": "‼️",
|
||
}
|
||
|
||
|
||
def is_admin(user_id: int) -> bool:
|
||
return user_id in config.admin_telegram_ids
|
||
|
||
|
||
def admin_departments(user_id: int) -> set[str]:
|
||
return config.admin_departments.get(user_id, set())
|
||
|
||
|
||
def _admin_sees(admin_id: int, ticket: dict | object) -> bool:
|
||
"""Видит ли админ эту заявку: либо у него нет ограничений (пустой set), либо раздел совпал."""
|
||
deps = config.admin_departments.get(admin_id, set())
|
||
return not deps or ticket["department"] in deps
|
||
|
||
|
||
def can_manage_ticket(user_id: int, ticket: dict | object) -> bool:
|
||
return is_admin(user_id) and _admin_sees(user_id, ticket)
|
||
|
||
|
||
def department_admins(ticket: dict | object) -> list[tuple[int, str]]:
|
||
result: list[tuple[int, str]] = []
|
||
for admin_id in config.admin_telegram_ids:
|
||
if _admin_sees(admin_id, ticket):
|
||
result.append((admin_id, db.user_name(admin_id) or str(admin_id)))
|
||
return result
|
||
|
||
|
||
def actor_name(callback: CallbackQuery) -> str:
|
||
user = callback.from_user
|
||
return user.full_name or (f"@{user.username}" if user.username else str(user.id))
|
||
|
||
|
||
def touch(user) -> None:
|
||
if user:
|
||
db.touch_user(user.id, user.full_name, user.username)
|
||
|
||
|
||
def sla_line(ticket: dict | object) -> str:
|
||
remaining = sla_remaining_minutes(ticket, config.sla_minutes)
|
||
if remaining is None:
|
||
return ""
|
||
if remaining < 0:
|
||
return f"<b>SLA:</b> 🔴 просрочено на {abs(remaining)} мин"
|
||
if remaining <= 10:
|
||
return f"<b>SLA:</b> 🟠 осталось {remaining} мин"
|
||
return f"<b>SLA:</b> 🟢 осталось {remaining} мин"
|
||
|
||
|
||
def ticket_text(ticket: dict | object, for_admin: bool = True) -> str:
|
||
lines = [
|
||
f"<b>📋 Заявка #{ticket['id']}</b>",
|
||
f"<b>Статус:</b> {escape(status_label(ticket['status']))}",
|
||
f"<b>Приоритет:</b> {escape(priority_label(ticket['priority']))}",
|
||
f"<b>Сотрудник:</b> {escape(ticket['full_name'] or '-')}",
|
||
f"<b>Раздел:</b> {escape(ticket['department'])}",
|
||
f"<b>Тема:</b> {escape(ticket['topic'])}",
|
||
]
|
||
if ticket["subtopic"] and ticket["subtopic"] != ticket["topic"]:
|
||
lines.append(f"<b>Уточнение:</b> {escape(ticket['subtopic'])}")
|
||
lines += [
|
||
f"<b>Описание:</b> {escape(ticket['description'])}",
|
||
f"<b>Создана:</b> {escape(ticket['created_at'])}",
|
||
]
|
||
|
||
photo_count = len(ticket_photos(ticket))
|
||
if photo_count:
|
||
lines.append(f"<b>📎 Фото:</b> {photo_count} (см. выше)")
|
||
|
||
sla = sla_line(ticket)
|
||
if sla:
|
||
lines.append(sla)
|
||
|
||
assignee = ticket["assignee_name"] or (str(ticket["assignee_id"]) if ticket["assignee_id"] else None)
|
||
if ticket["status"] != "new" and assignee:
|
||
lines.append(f"<b>Исполнитель:</b> {escape(assignee)}")
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
if ticket["closed_at"]:
|
||
lines.append(f"<b>Закрыта:</b> {escape(ticket['closed_at'])}")
|
||
if ticket["resolution"]:
|
||
label = "Причина отклонения" if ticket["status"] == "rejected" else "Решение"
|
||
lines.append(f"<b>{label}:</b> {escape(ticket['resolution'])}")
|
||
|
||
events = db.list_events(ticket["id"])
|
||
if for_admin:
|
||
# Внутренние заметки видят только администраторы.
|
||
notes = [event for event in events if event["kind"] == "note"]
|
||
if notes:
|
||
lines.append(f"\n<b>📝 Последняя заметка:</b> {escape(notes[-1]['text'] or '')}")
|
||
|
||
comments = [event for event in events if event["kind"] == "user_comment"]
|
||
if comments:
|
||
lines.append("\n<b>💬 Комментарии заявителя:</b>")
|
||
shown = comments[-10:]
|
||
if len(comments) > len(shown):
|
||
lines.append(f"…ещё {len(comments) - len(shown)} раньше — см. «История».")
|
||
for comment in shown:
|
||
lines.append(f"• {escape(comment['created_at'])}: {escape(comment['text'] or '')}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def history_text(ticket_id: int) -> str:
|
||
events = db.list_events(ticket_id, limit=40)
|
||
if not events:
|
||
return f"<b>История заявки #{ticket_id}</b>\n\nСобытий пока нет."
|
||
lines = [f"<b>История заявки #{ticket_id}</b>", ""]
|
||
for event in events:
|
||
icon = EVENT_LABELS.get(event["kind"], "•")
|
||
who = event["actor_name"] or "—"
|
||
body = f" — {escape(event['text'])}" if event["text"] else ""
|
||
lines.append(f"{icon} {escape(event['created_at'])} · {escape(who)}{body}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def safe_edit(
|
||
message: Message,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> None:
|
||
try:
|
||
await message.edit_text(text, reply_markup=reply_markup)
|
||
except TelegramBadRequest as error:
|
||
if "message is not modified" in str(error):
|
||
return
|
||
logging.debug("Could not edit message", exc_info=True)
|
||
|
||
|
||
# Telegram: максимум фото в одном альбоме (media group).
|
||
MAX_PHOTOS = 10
|
||
|
||
# (chat_id, panel_message_id) -> id сообщений альбома над панелью.
|
||
# Нужен, чтобы удалить альбом, когда уходим от заявки к списку/дашборду.
|
||
album_messages: dict[tuple[int, int], list[int]] = {}
|
||
|
||
|
||
def ticket_photos(ticket: dict | object) -> list[str]:
|
||
"""Список file_id вложений заявки (новый столбец attachments, с откатом на старый)."""
|
||
raw = None
|
||
try:
|
||
raw = ticket["attachments"]
|
||
except (KeyError, IndexError):
|
||
raw = None
|
||
if raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
if isinstance(data, list):
|
||
ids = [str(x) for x in data if x]
|
||
if ids:
|
||
return ids[:MAX_PHOTOS]
|
||
except (ValueError, TypeError):
|
||
logging.debug("Bad attachments JSON for ticket", exc_info=True)
|
||
single = ticket["attachment_file_id"]
|
||
return [single] if single else []
|
||
|
||
|
||
async def _send_album(bot: Bot, chat_id: int, photos: list[str]) -> list[int]:
|
||
"""Шлёт фото блоком над панелью. Возвращает id отправленных сообщений."""
|
||
if not photos:
|
||
return []
|
||
try:
|
||
if len(photos) == 1:
|
||
sent = await bot.send_photo(chat_id, photos[0])
|
||
return [sent.message_id]
|
||
media = [InputMediaPhoto(media=fid) for fid in photos[:MAX_PHOTOS]]
|
||
sent = await bot.send_media_group(chat_id, media)
|
||
return [m.message_id for m in sent]
|
||
except Exception:
|
||
logging.debug("Could not send photo album", exc_info=True)
|
||
return []
|
||
|
||
|
||
async def _clear_album(bot: Bot, chat_id: int, panel_message_id: int) -> None:
|
||
"""Удаляет альбом, привязанный к данной панели (если был)."""
|
||
ids = album_messages.pop((chat_id, panel_message_id), None)
|
||
for mid in ids or []:
|
||
try:
|
||
await bot.delete_message(chat_id, mid)
|
||
except Exception:
|
||
logging.debug("Could not delete album message %s", mid, exc_info=True)
|
||
|
||
|
||
async def send_ticket_view(
|
||
bot: Bot,
|
||
chat_id: int,
|
||
ticket: dict | object,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> Message:
|
||
"""Свежая отправка заявки: альбом фото сверху + текст с кнопками снизу.
|
||
|
||
Панель (текст+кнопки) — всегда отдельное текстовое сообщение, т.к. альбом из
|
||
нескольких фото не может нести инлайн-кнопки. Возвращает сообщение-панель.
|
||
"""
|
||
album = await _send_album(bot, chat_id, ticket_photos(ticket))
|
||
panel = await bot.send_message(chat_id, text, reply_markup=reply_markup)
|
||
if album:
|
||
album_messages[(chat_id, panel.message_id)] = album
|
||
return panel
|
||
|
||
|
||
async def show_ticket(
|
||
bot: Bot,
|
||
message: Message,
|
||
ticket: dict | object,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> None:
|
||
"""Рендерит вид заявки. Под-навигация правит текстовую панель на месте; вход в
|
||
заявку с фото из списка — пересоздаёт (альбом сверху + панель)."""
|
||
chat_id = message.chat.id
|
||
key = (chat_id, message.message_id)
|
||
photos = ticket_photos(ticket)
|
||
# message уже является панелью этой заявки (под-навигация) или у заявки нет
|
||
# фото — редактируем текст на месте.
|
||
if key in album_messages or not photos:
|
||
await safe_edit(message, text, reply_markup)
|
||
return
|
||
# Вход в заявку с фото: убираем исходное сообщение (список) и шлём альбом+панель.
|
||
try:
|
||
await message.delete()
|
||
except Exception:
|
||
logging.debug("Could not delete before ticket view", exc_info=True)
|
||
await send_ticket_view(bot, chat_id, ticket, text, reply_markup)
|
||
|
||
|
||
async def show_plain(
|
||
bot: Bot,
|
||
message: Message,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> None:
|
||
"""Показывает обычный текст (список/дашборд). Чистит альбом заявки, если уходим от неё."""
|
||
await _clear_album(bot, message.chat.id, message.message_id)
|
||
await safe_edit(message, text, reply_markup)
|
||
|
||
|
||
async def edit_panel_by_id(
|
||
bot: Bot,
|
||
chat_id: int,
|
||
message_id: int,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> None:
|
||
"""Обновляет сохранённое текстовое сообщение-панель по id."""
|
||
try:
|
||
await bot.edit_message_text(
|
||
chat_id=chat_id, message_id=message_id, text=text, reply_markup=reply_markup
|
||
)
|
||
except Exception:
|
||
logging.debug("Could not edit panel by id", exc_info=True)
|
||
|
||
|
||
def user_tickets_title(status_filter: str) -> str:
|
||
return "Активные заявки:" if status_filter == "active" else "Архив закрытых заявок:"
|
||
|
||
|
||
async def delete_user_message(message: Message) -> None:
|
||
try:
|
||
await message.delete()
|
||
except Exception:
|
||
logging.debug("Could not delete user message", exc_info=True)
|
||
|
||
|
||
async def update_flow_message(
|
||
bot: Bot,
|
||
state: FSMContext,
|
||
chat_id: int,
|
||
text: str,
|
||
reply_markup: InlineKeyboardMarkup | None = None,
|
||
) -> None:
|
||
data = await state.get_data()
|
||
prompt_message_id = data.get("prompt_message_id")
|
||
|
||
if prompt_message_id:
|
||
try:
|
||
await bot.edit_message_text(
|
||
chat_id=chat_id,
|
||
message_id=prompt_message_id,
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
except Exception:
|
||
logging.debug("Could not edit flow message", exc_info=True)
|
||
|
||
sent = await bot.send_message(chat_id, text, reply_markup=reply_markup)
|
||
await state.update_data(prompt_message_id=sent.message_id)
|
||
|
||
|
||
async def notify_admins(bot: Bot, ticket_id: int) -> None:
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket:
|
||
return
|
||
for admin_id in config.admin_telegram_ids:
|
||
if not _admin_sees(admin_id, ticket):
|
||
continue
|
||
try:
|
||
await send_ticket_view(
|
||
bot,
|
||
admin_id,
|
||
ticket,
|
||
ticket_text(ticket),
|
||
reply_markup=ticket_actions(ticket, admin_id, with_back=True),
|
||
)
|
||
except Exception:
|
||
logging.exception("Failed to notify admin %s", admin_id)
|
||
|
||
|
||
async def notify_user(bot: Bot, ticket: dict | object, text: str) -> None:
|
||
if not ticket or not ticket["user_id"]:
|
||
return
|
||
try:
|
||
await bot.send_message(ticket["user_id"], text)
|
||
except Exception:
|
||
logging.debug("Could not notify ticket author", exc_info=True)
|
||
|
||
|
||
def ticket_recipients(ticket: dict | object) -> list[int]:
|
||
"""Кому уходит уведомление по заявке: исполнителю, иначе всем админам, которым раздел доступен."""
|
||
if ticket["assignee_id"]:
|
||
return [ticket["assignee_id"]]
|
||
return [admin_id for admin_id in config.admin_telegram_ids if _admin_sees(admin_id, ticket)]
|
||
|
||
|
||
async def notify_ticket_admins(
|
||
bot: Bot, ticket: dict | object, header: str, extra_photos: list[str] | None = None
|
||
) -> None:
|
||
"""Сообщает по заявке исполнителю, а если её ещё не взяли — всем админам отдела."""
|
||
fresh = db.get_ticket(ticket["id"])
|
||
own = set(ticket_photos(fresh))
|
||
# extra_photos — новые вложения из комментария/дополнения (не исходные фото заявки).
|
||
new_photos = [p for p in (extra_photos or []) if p not in own]
|
||
for admin_id in ticket_recipients(ticket):
|
||
try:
|
||
await send_ticket_view(
|
||
bot,
|
||
admin_id,
|
||
fresh,
|
||
f"{header}\n\n{ticket_text(fresh)}",
|
||
reply_markup=ticket_actions(fresh, admin_id, with_back=True),
|
||
)
|
||
if new_photos:
|
||
await _send_album(bot, admin_id, new_photos)
|
||
except Exception:
|
||
logging.debug("Could not notify ticket admin %s", admin_id, exc_info=True)
|
||
|
||
|
||
async def relay_user_chat_to_admins(
|
||
bot: Bot, ticket: dict | object, body: str, photo_file_id: str | None = None
|
||
) -> None:
|
||
"""Пересылает сообщение заявителя в чате исполнителю (или админам отдела)."""
|
||
header = f"💬 <b>Заявитель по заявке #{ticket['id']}</b>"
|
||
text = f"{header}\n{body}" if body else header
|
||
for admin_id in ticket_recipients(ticket):
|
||
# Если исполнитель уже в чате по этой заявке — кнопка «Ответить» не нужна.
|
||
reply_kb = None if active_admin_chat.get(admin_id) == ticket["id"] else admin_reply_kb(ticket["id"])
|
||
try:
|
||
if photo_file_id:
|
||
await bot.send_photo(admin_id, photo_file_id, caption=text, reply_markup=reply_kb)
|
||
else:
|
||
await bot.send_message(admin_id, text, reply_markup=reply_kb)
|
||
except Exception:
|
||
logging.debug("Could not relay user chat to admin %s", admin_id, exc_info=True)
|
||
|
||
|
||
@router.message(CommandStart())
|
||
async def start(message: Message, state: FSMContext) -> None:
|
||
if message.from_user:
|
||
active_admin_chat.pop(message.from_user.id, None)
|
||
await state.clear()
|
||
touch(message.from_user)
|
||
await message.answer(
|
||
"Привет. Я помогу оформить заявку и передать её в нужный отдел.",
|
||
reply_markup=main_menu(),
|
||
)
|
||
|
||
|
||
@router.message(Command("admin"))
|
||
async def admin_panel(message: Message) -> None:
|
||
if not message.from_user or not is_admin(message.from_user.id):
|
||
await message.answer("Команда доступна только администраторам.")
|
||
return
|
||
touch(message.from_user)
|
||
|
||
deps = admin_departments(message.from_user.id)
|
||
stats = db.dashboard(deps, config.sla_minutes)
|
||
departments_text = ", ".join(sorted(deps)) if deps else "все разделы"
|
||
await message.answer(
|
||
f"<b>Панель заявок</b>\nРазделы: {escape(departments_text)}\n"
|
||
f"Открыто: {stats['open']} · Просрочено: {stats['overdue']}",
|
||
reply_markup=admin_dashboard(stats),
|
||
)
|
||
|
||
|
||
@router.message(Command("find"))
|
||
async def find_command(message: Message) -> None:
|
||
if not message.from_user or not is_admin(message.from_user.id):
|
||
await message.answer("Команда доступна только администраторам.")
|
||
return
|
||
parts = (message.text or "").split(maxsplit=1)
|
||
if len(parts) < 2 or not parts[1].strip().lstrip("#").isdigit():
|
||
await message.answer("Использование: /find <номер заявки>")
|
||
return
|
||
await show_ticket_for_admin(message, message.from_user.id, int(parts[1].strip().lstrip("#")), "open")
|
||
|
||
|
||
async def show_ticket_for_admin(message: Message, admin_id: int, ticket_id: int, list_filter: str) -> None:
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket:
|
||
await message.answer("Заявка не найдена.")
|
||
return
|
||
if not can_manage_ticket(admin_id, ticket):
|
||
await message.answer("Эта заявка относится к другому разделу.")
|
||
return
|
||
await send_ticket_view(
|
||
message.bot, message.chat.id, ticket, ticket_text(ticket),
|
||
reply_markup=ticket_actions(ticket, admin_id, list_filter, with_back=True),
|
||
)
|
||
|
||
|
||
@router.message(F.text == "FAQ")
|
||
async def faq(message: Message) -> None:
|
||
await message.answer(FAQ_TEXT)
|
||
|
||
|
||
@router.message(F.text == "Мои заявки")
|
||
async def my_tickets(message: Message) -> None:
|
||
if not message.from_user:
|
||
return
|
||
tickets = db.list_user_tickets(message.from_user.id, "active")
|
||
if not tickets:
|
||
await message.answer("Активных заявок нет.", reply_markup=user_tickets([], "active"))
|
||
return
|
||
await message.answer(user_tickets_title("active"), reply_markup=user_tickets(tickets, "active"))
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_tickets:"))
|
||
async def user_tickets_list(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
status_filter = callback.data.split(":", 1)[1]
|
||
if status_filter not in {"active", "closed"}:
|
||
status_filter = "active"
|
||
|
||
tickets = db.list_user_tickets(callback.from_user.id, status_filter)
|
||
await callback.answer()
|
||
if not tickets:
|
||
empty_text = "Активных заявок нет." if status_filter == "active" else "Закрытых заявок в архиве нет."
|
||
await show_plain(bot, callback.message, empty_text, reply_markup=user_tickets([], status_filter))
|
||
return
|
||
await show_plain(bot, callback.message, user_tickets_title(status_filter), reply_markup=user_tickets(tickets, status_filter))
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_ticket:"))
|
||
async def user_ticket_details(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
status_filter = parts[2] if len(parts) > 2 and parts[2] in {"active", "closed"} else "active"
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
ticket_text(ticket, for_admin=False),
|
||
reply_markup=user_ticket_actions(ticket_id, ticket["status"], status_filter),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_comment_cancel:"))
|
||
async def user_comment_cancel_cb(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
await state.clear()
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
status_filter = parts[2] if len(parts) > 2 and parts[2] in {"active", "closed"} else "active"
|
||
ticket = db.get_ticket(ticket_id)
|
||
await callback.answer("Отменено")
|
||
if ticket and ticket["user_id"] == callback.from_user.id:
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
ticket_text(ticket, for_admin=False),
|
||
reply_markup=user_ticket_actions(ticket_id, ticket["status"], status_filter),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_comment:"))
|
||
async def user_comment_start(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
status_filter = parts[2] if len(parts) > 2 and parts[2] in {"active", "closed"} else "active"
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await callback.answer("Заявка завершена. Создайте новую заявку.", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(UserFlow.comment)
|
||
await state.update_data(
|
||
ticket_id=ticket_id,
|
||
status_filter=status_filter,
|
||
panel_chat_id=callback.message.chat.id,
|
||
panel_message_id=callback.message.message_id,
|
||
)
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"Напишите комментарий к заявке #{ticket_id}. Его увидит исполнитель.",
|
||
reply_markup=user_comment_cancel(ticket_id, status_filter),
|
||
)
|
||
|
||
|
||
@router.message(UserFlow.comment)
|
||
async def user_comment_save(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
if not message.from_user:
|
||
await state.clear()
|
||
return
|
||
data = await state.get_data()
|
||
ticket_id = data.get("ticket_id")
|
||
status_filter = data.get("status_filter", "active")
|
||
chat_id = data.get("panel_chat_id")
|
||
message_id = data.get("panel_message_id")
|
||
await state.clear()
|
||
|
||
ticket = db.get_ticket(ticket_id) if ticket_id else None
|
||
text = (message.text or "").strip()
|
||
if not ticket or ticket["user_id"] != message.from_user.id:
|
||
await delete_user_message(message)
|
||
return
|
||
|
||
if text:
|
||
author = message.from_user.full_name or (
|
||
f"@{message.from_user.username}" if message.from_user.username else str(message.from_user.id)
|
||
)
|
||
db.add_event(ticket_id, message.from_user.id, author, "user_comment", text)
|
||
await notify_ticket_admins(
|
||
bot,
|
||
ticket,
|
||
f"💬 Новый комментарий заявителя по заявке #{ticket_id}:\n{text}",
|
||
)
|
||
|
||
await delete_user_message(message)
|
||
fresh = db.get_ticket(ticket_id)
|
||
if fresh and chat_id and message_id:
|
||
await edit_panel_by_id(
|
||
bot,
|
||
chat_id,
|
||
message_id,
|
||
ticket_text(fresh, for_admin=False),
|
||
reply_markup=user_ticket_actions(ticket_id, fresh["status"], status_filter),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_close:"))
|
||
async def user_close_request(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
status_filter = parts[2] if len(parts) > 2 and parts[2] in {"active", "closed"} else "active"
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await callback.answer("Заявка уже закрыта", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"Закрыть заявку #{ticket_id}? Это значит, что вопрос решился и помощь больше не нужна.",
|
||
reply_markup=user_close_confirm(ticket_id, status_filter),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_close_yes:"))
|
||
async def user_close_confirmed(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
status_filter = parts[2] if len(parts) > 2 and parts[2] in {"active", "closed"} else "active"
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await callback.answer("Заявка уже закрыта", show_alert=True)
|
||
return
|
||
|
||
author = callback.from_user.full_name or (
|
||
f"@{callback.from_user.username}" if callback.from_user.username else str(callback.from_user.id)
|
||
)
|
||
db.close_by_user(ticket_id, "Закрыто заявителем (вопрос решился)")
|
||
db.add_event(ticket_id, callback.from_user.id, author, "close", "Закрыто заявителем")
|
||
await callback.answer("Заявка закрыта")
|
||
fresh = db.get_ticket(ticket_id)
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
fresh,
|
||
ticket_text(fresh, for_admin=False),
|
||
reply_markup=user_ticket_actions(ticket_id, fresh["status"], status_filter),
|
||
)
|
||
await notify_ticket_admins(bot, ticket, f"✅ Заявитель сам закрыл заявку #{ticket_id} (вопрос решился).")
|
||
|
||
|
||
@router.callback_query(F.data.startswith("user_chat:"))
|
||
async def user_chat_start(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
ticket_id = int(callback.data.split(":")[1])
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await callback.answer("Заявка закрыта — чат недоступен.", show_alert=True)
|
||
return
|
||
await state.set_state(UserFlow.chat)
|
||
await state.update_data(ticket_id=ticket_id)
|
||
await callback.answer()
|
||
await bot.send_message(
|
||
callback.message.chat.id,
|
||
f"💬 Вы на связи с поддержкой по заявке #{ticket_id}. Просто пишите сообщения здесь.\n"
|
||
"Чтобы выйти — нажмите «❌ Завершить чат».",
|
||
reply_markup=user_chat_controls(),
|
||
)
|
||
|
||
|
||
@router.message(UserFlow.chat)
|
||
async def user_chat_message(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
if not message.from_user:
|
||
await state.clear()
|
||
return
|
||
if (message.text or "").strip() == "❌ Завершить чат":
|
||
await state.clear()
|
||
await message.answer("Чат завершён.", reply_markup=main_menu())
|
||
return
|
||
|
||
data = await state.get_data()
|
||
ticket_id = data.get("ticket_id")
|
||
ticket = db.get_ticket(ticket_id) if ticket_id else None
|
||
if not ticket or ticket["user_id"] != message.from_user.id:
|
||
await state.clear()
|
||
await message.answer("Чат недоступен.", reply_markup=main_menu())
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await state.clear()
|
||
await message.answer(f"Заявка #{ticket_id} закрыта — чат завершён.", reply_markup=main_menu())
|
||
return
|
||
|
||
photo = message.photo[-1].file_id if message.photo else None
|
||
body = ((message.caption if photo else message.text) or "").strip()
|
||
if not photo and not body:
|
||
return
|
||
name = message.from_user.full_name or (
|
||
f"@{message.from_user.username}" if message.from_user.username else str(message.from_user.id)
|
||
)
|
||
db.add_event(ticket_id, message.from_user.id, name, "message", body or "[фото]")
|
||
await relay_user_chat_to_admins(bot, ticket, escape(body), photo)
|
||
|
||
|
||
# --------------------------------------------------------------- admin views
|
||
@router.callback_query(F.data == "adm:dash")
|
||
async def admin_dashboard_view(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
deps = admin_departments(callback.from_user.id)
|
||
stats = db.dashboard(deps, config.sla_minutes)
|
||
departments_text = ", ".join(sorted(deps)) if deps else "все разделы"
|
||
await callback.answer()
|
||
await show_plain(
|
||
bot,
|
||
callback.message,
|
||
f"<b>Панель заявок</b>\nРазделы: {escape(departments_text)}\n"
|
||
f"Открыто: {stats['open']} · Просрочено: {stats['overdue']}",
|
||
reply_markup=admin_dashboard(stats),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("adm:list:"))
|
||
async def admin_list_view(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
list_filter = callback.data.split(":", 2)[2]
|
||
deps = admin_departments(callback.from_user.id)
|
||
tickets = db.list_admin_tickets(deps, list_filter, callback.from_user.id, config.sla_minutes)
|
||
await callback.answer()
|
||
title = filter_title(list_filter)
|
||
if not tickets:
|
||
await show_plain(bot, callback.message, f"{title}: пусто.", reply_markup=open_tickets([], list_filter, config.sla_minutes))
|
||
return
|
||
await show_plain(bot, callback.message, f"{title}:", reply_markup=open_tickets(tickets, list_filter, config.sla_minutes))
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin_ticket:"))
|
||
async def admin_ticket_details(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
active_admin_chat.pop(callback.from_user.id, None)
|
||
await state.clear()
|
||
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[1])
|
||
list_filter = parts[2] if len(parts) > 2 else "open"
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if not can_manage_ticket(callback.from_user.id, ticket):
|
||
await callback.answer("Эта заявка относится к другому разделу", show_alert=True)
|
||
return
|
||
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
ticket_text(ticket),
|
||
reply_markup=ticket_actions(ticket, callback.from_user.id, list_filter, with_back=True),
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("setprio:"))
|
||
async def set_priority_callback(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
_, ticket_id_raw, level = callback.data.split(":")
|
||
ticket = db.get_ticket(int(ticket_id_raw))
|
||
if not ticket or not can_manage_ticket(callback.from_user.id, ticket):
|
||
await callback.answer("Заявка недоступна", show_alert=True)
|
||
return
|
||
db.set_priority(ticket["id"], level)
|
||
db.add_event(ticket["id"], callback.from_user.id, actor_name(callback), "priority", f"Приоритет: {priority_label(level)}")
|
||
await callback.answer(f"Приоритет: {priority_label(level)}")
|
||
await refresh_ticket_view(callback, ticket["id"], "open", bot)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("assignto:"))
|
||
async def assign_to_callback(callback: CallbackQuery, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
_, ticket_id_raw, target_raw = callback.data.split(":")
|
||
ticket = db.get_ticket(int(ticket_id_raw))
|
||
if not ticket or not can_manage_ticket(callback.from_user.id, ticket):
|
||
await callback.answer("Заявка недоступна", show_alert=True)
|
||
return
|
||
target_id = int(target_raw)
|
||
target_name = db.user_name(target_id) or str(target_id)
|
||
db.assign_ticket(ticket["id"], target_id, target_name)
|
||
db.add_event(ticket["id"], callback.from_user.id, actor_name(callback), "assign", f"Назначена: {target_name}")
|
||
await callback.answer(f"Назначена: {target_name}")
|
||
await refresh_ticket_view(callback, ticket["id"], "open", bot)
|
||
|
||
fresh = db.get_ticket(ticket["id"])
|
||
if target_id != callback.from_user.id:
|
||
try:
|
||
await send_ticket_view(
|
||
bot,
|
||
target_id,
|
||
fresh,
|
||
f"Вам назначена заявка #{ticket['id']}.\n\n{ticket_text(fresh)}",
|
||
reply_markup=ticket_actions(fresh, target_id, with_back=True),
|
||
)
|
||
except Exception:
|
||
logging.debug("Could not notify assignee", exc_info=True)
|
||
await notify_user(bot, fresh, f"Ваша заявка #{ticket['id']} взята в работу.")
|
||
|
||
|
||
@router.message(F.text == "Создать заявку")
|
||
async def create_ticket(message: Message, state: FSMContext) -> None:
|
||
await state.clear()
|
||
await state.set_state(TicketFlow.section)
|
||
sent = await message.answer(
|
||
"Выберите раздел, к которому относится вопрос.",
|
||
reply_markup=sections(),
|
||
)
|
||
await state.update_data(prompt_message_id=sent.message_id)
|
||
await delete_user_message(message)
|
||
|
||
|
||
async def _proceed_after_topic(
|
||
callback: CallbackQuery,
|
||
state: FSMContext,
|
||
section_code: str,
|
||
topic_code: str,
|
||
) -> None:
|
||
"""Дальше из «темы/подтемы»: показать инструкцию или сразу попросить описание."""
|
||
instruction = instruction_for(section_code, topic_code)
|
||
if instruction:
|
||
await callback.message.edit_text(
|
||
f"{instruction}\n\nПомогло? Если нет — оформим заявку.",
|
||
reply_markup=instruction_result(),
|
||
)
|
||
return
|
||
await _ask_description(callback, state)
|
||
|
||
|
||
async def _ask_description(callback: CallbackQuery, state: FSMContext) -> None:
|
||
# Кабинет/рабочее место подтянем с портала, тут не спрашиваем.
|
||
await state.update_data(location="-")
|
||
await state.set_state(TicketFlow.description)
|
||
await callback.message.edit_text(
|
||
"Опишите задачу или проблему подробно. Если есть важные детали — укажите сразу.",
|
||
reply_markup=cancel_creation(),
|
||
)
|
||
|
||
|
||
@router.callback_query(TicketFlow.section, F.data.startswith("sec:"))
|
||
async def choose_section(callback: CallbackQuery, state: FSMContext) -> None:
|
||
code = callback.data.split(":", 1)[1]
|
||
if code == "back":
|
||
# На корне «назад» возвращает то же меню — ничего страшного.
|
||
await callback.answer()
|
||
await callback.message.edit_text(
|
||
"Выберите раздел, к которому относится вопрос.",
|
||
reply_markup=sections(),
|
||
)
|
||
return
|
||
label = section_label(code)
|
||
# В БД храним русское название раздела в поле department — для совместимости.
|
||
await state.update_data(section_code=code, department=label)
|
||
await state.set_state(TicketFlow.topic)
|
||
await callback.answer()
|
||
await callback.message.edit_text(
|
||
f"<b>{label}</b>\nВыберите тему обращения.",
|
||
reply_markup=topics(code),
|
||
)
|
||
|
||
|
||
@router.callback_query(TicketFlow.topic, F.data == "sec:back")
|
||
async def topic_back_to_sections(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await state.set_state(TicketFlow.section)
|
||
await callback.answer()
|
||
await callback.message.edit_text(
|
||
"Выберите раздел, к которому относится вопрос.",
|
||
reply_markup=sections(),
|
||
)
|
||
|
||
|
||
@router.callback_query(TicketFlow.topic, F.data.startswith("tp:"))
|
||
async def choose_topic(callback: CallbackQuery, state: FSMContext) -> None:
|
||
_, sec_code, topic_code = callback.data.split(":", 2)
|
||
label = topic_label(sec_code, topic_code)
|
||
await state.update_data(topic_code=topic_code, topic=label, subtopic=label)
|
||
await callback.answer()
|
||
|
||
if has_subtopics(sec_code, topic_code):
|
||
await state.set_state(TicketFlow.subtopic)
|
||
await callback.message.edit_text(
|
||
f"<b>{label}</b>\nУточните, к чему относится вопрос.",
|
||
reply_markup=subtopics(sec_code, topic_code),
|
||
)
|
||
return
|
||
|
||
await _proceed_after_topic(callback, state, sec_code, topic_code)
|
||
|
||
|
||
@router.callback_query(TicketFlow.subtopic, F.data.startswith("sec:"))
|
||
async def subtopic_back_to_topics(callback: CallbackQuery, state: FSMContext) -> None:
|
||
# «⬅️ К темам» — callback вида "sec:<section_code>".
|
||
sec_code = callback.data.split(":", 1)[1]
|
||
await state.set_state(TicketFlow.topic)
|
||
await callback.answer()
|
||
await callback.message.edit_text(
|
||
f"<b>{section_label(sec_code)}</b>\nВыберите тему обращения.",
|
||
reply_markup=topics(sec_code),
|
||
)
|
||
|
||
|
||
@router.callback_query(TicketFlow.subtopic, F.data.startswith("sb:"))
|
||
async def choose_subtopic(callback: CallbackQuery, state: FSMContext) -> None:
|
||
_, sec_code, topic_code, sub_code = callback.data.split(":", 3)
|
||
sub_label = subtopic_label(sec_code, topic_code, sub_code)
|
||
# subtopic в БД — конкретное уточнение; topic остаётся темой 1-го уровня.
|
||
await state.update_data(subtopic=sub_label, subtopic_code=sub_code)
|
||
await callback.answer()
|
||
await _proceed_after_topic(callback, state, sec_code, topic_code)
|
||
|
||
|
||
@router.callback_query(F.data == "ticket:cancel")
|
||
async def cancel_ticket_creation(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await state.clear()
|
||
await callback.answer("Создание отменено")
|
||
await callback.message.edit_text("Создание заявки отменено.")
|
||
|
||
|
||
@router.callback_query(F.data == "instruction:create")
|
||
async def continue_ticket(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await callback.answer()
|
||
await _ask_description(callback, state)
|
||
|
||
|
||
@router.message(TicketFlow.description)
|
||
async def set_description(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
await state.update_data(description=message.text or "-", contact_time="-", attachments=[])
|
||
touch(message.from_user)
|
||
await delete_user_message(message)
|
||
|
||
# Фото опционально для любой заявки: можно прислать или сразу «Готово».
|
||
await state.set_state(TicketFlow.attachment)
|
||
await update_flow_message(
|
||
bot,
|
||
state,
|
||
message.chat.id,
|
||
f"Если есть фото/скриншоты — пришлите (до {MAX_PHOTOS}, можно альбомом) и нажмите «Готово».\n"
|
||
"Если фото не нужно — сразу нажмите «Готово».",
|
||
reply_markup=attachment_actions(0),
|
||
)
|
||
|
||
|
||
def build_ticket_payload(user, data: dict) -> dict:
|
||
photos = list(data.get("attachments") or [])[:MAX_PHOTOS]
|
||
topic = data["topic"]
|
||
return {
|
||
"user_id": user.id if user else 0,
|
||
"username": user.username if user else None,
|
||
"full_name": user.full_name if user else None,
|
||
"department": data["department"],
|
||
"topic": topic,
|
||
# Если был 3-й уровень (например тип CRM) — пишем уточнение, иначе дублируем тему.
|
||
"subtopic": data.get("subtopic") or topic,
|
||
"location": data["location"],
|
||
"description": data["description"],
|
||
"contact_time": data.get("contact_time", "-"),
|
||
"attachment_file_id": photos[0] if photos else None,
|
||
"attachments": json.dumps(photos) if photos else None,
|
||
"priority": "medium",
|
||
}
|
||
|
||
|
||
async def create_ticket_from_state(bot: Bot, user, data: dict) -> tuple[int, str]:
|
||
ticket_id = db.create_ticket(build_ticket_payload(user, data))
|
||
ticket = db.get_ticket(ticket_id)
|
||
created_at = ticket["created_at"] if ticket else "по московскому времени"
|
||
await notify_admins(bot, ticket_id)
|
||
return ticket_id, (
|
||
f"Заявка #{ticket_id} создана.\nВремя создания: {escape(created_at)}.\n\n"
|
||
"IT получит уведомление и возьмет ее в работу."
|
||
)
|
||
|
||
|
||
async def finalize_or_duplicate(bot: Bot, state: FSMContext, chat_id: int, user) -> None:
|
||
data = await state.get_data()
|
||
duplicate = db.find_duplicate_ticket(user.id if user else 0, data["topic"])
|
||
if duplicate:
|
||
await state.set_state(TicketFlow.duplicate_decision)
|
||
await update_flow_message(
|
||
bot,
|
||
state,
|
||
chat_id,
|
||
f"У вас уже есть открытая заявка #{duplicate['id']} по теме "
|
||
f"«{escape(duplicate['topic'])}» (статус: {escape(status_label(duplicate['status']))}).\n\n"
|
||
"Что сделать?",
|
||
reply_markup=duplicate_options(duplicate["id"]),
|
||
)
|
||
return
|
||
|
||
_, text = await create_ticket_from_state(bot, user, data)
|
||
await update_flow_message(bot, state, chat_id, text)
|
||
await state.clear()
|
||
|
||
|
||
# Сериализуем добавление фото по пользователю: фото из альбома приходят почти
|
||
# одновременно, а чтение-запись state не атомарны (иначе часть фото теряется).
|
||
_attachment_locks: dict[int, asyncio.Lock] = {}
|
||
|
||
|
||
@router.callback_query(TicketFlow.attachment, F.data == "att:done")
|
||
async def attachment_done(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
await callback.answer()
|
||
await finalize_or_duplicate(bot, state, callback.message.chat.id, callback.from_user)
|
||
|
||
|
||
@router.message(TicketFlow.attachment)
|
||
async def set_attachment(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
if message.photo:
|
||
await delete_user_message(message)
|
||
touch(message.from_user)
|
||
lock = _attachment_locks.setdefault(message.from_user.id, asyncio.Lock())
|
||
async with lock:
|
||
data = await state.get_data()
|
||
photos = list(data.get("attachments") or [])
|
||
if len(photos) >= MAX_PHOTOS:
|
||
hint = f"Уже добавлено максимум — {MAX_PHOTOS} фото. Нажмите «Готово»."
|
||
else:
|
||
photos.append(message.photo[-1].file_id)
|
||
await state.update_data(attachments=photos)
|
||
left = MAX_PHOTOS - len(photos)
|
||
hint = (
|
||
f"Добавлено фото: {len(photos)}/{MAX_PHOTOS}. "
|
||
+ (f"Можно ещё {left} или нажмите «Готово»." if left else "Это максимум. Нажмите «Готово».")
|
||
)
|
||
await update_flow_message(bot, state, message.chat.id, hint, reply_markup=attachment_actions(len(photos)))
|
||
return
|
||
|
||
text = (message.text or "").lower().strip()
|
||
await delete_user_message(message)
|
||
if text in {"нет", "no", "-", "готово", "done"}:
|
||
await finalize_or_duplicate(bot, state, message.chat.id, message.from_user)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
count = len(data.get("attachments") or [])
|
||
await update_flow_message(
|
||
bot,
|
||
state,
|
||
message.chat.id,
|
||
f"Пришлите скриншоты (до {MAX_PHOTOS}, можно альбомом) и нажмите «Готово».\n"
|
||
"Если фото не нужно — тоже нажмите «Готово».",
|
||
reply_markup=attachment_actions(count),
|
||
)
|
||
|
||
|
||
@router.callback_query(TicketFlow.duplicate_decision, F.data == "dup:new")
|
||
async def duplicate_create_new(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
data = await state.get_data()
|
||
_, text = await create_ticket_from_state(bot, callback.from_user, data)
|
||
await callback.answer("Создаю новую заявку")
|
||
await safe_edit(callback.message, text)
|
||
await state.clear()
|
||
|
||
|
||
@router.callback_query(TicketFlow.duplicate_decision, F.data.startswith("dup:append:"))
|
||
async def duplicate_append(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user:
|
||
return
|
||
existing_id = int(callback.data.split(":")[2])
|
||
data = await state.get_data()
|
||
ticket = db.get_ticket(existing_id)
|
||
if not ticket or ticket["user_id"] != callback.from_user.id or ticket["status"] in ("closed", "rejected"):
|
||
_, text = await create_ticket_from_state(bot, callback.from_user, data)
|
||
await callback.answer("Старая заявка недоступна, создал новую", show_alert=True)
|
||
await safe_edit(callback.message, text)
|
||
await state.clear()
|
||
return
|
||
|
||
author = callback.from_user.full_name or (
|
||
f"@{callback.from_user.username}" if callback.from_user.username else str(callback.from_user.id)
|
||
)
|
||
note = data.get("description") or "-"
|
||
photos = list(data.get("attachments") or [])[:MAX_PHOTOS]
|
||
suffix = f" (приложено фото: {len(photos)})" if photos else ""
|
||
comment = f"Дополнение к заявке: {note}{suffix}"
|
||
db.add_event(existing_id, callback.from_user.id, author, "user_comment", comment)
|
||
await callback.answer("Информация добавлена")
|
||
await safe_edit(callback.message, f"Информация добавлена к заявке #{existing_id}. Новую заявку не создаю.")
|
||
await state.clear()
|
||
await notify_ticket_admins(
|
||
bot, ticket, f"💬 Заявитель дополнил заявку #{existing_id}:\n{note}", extra_photos=photos
|
||
)
|
||
|
||
|
||
async def refresh_ticket_view(callback: CallbackQuery, ticket_id: int, list_filter: str, bot: Bot) -> None:
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or not callback.message:
|
||
return
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
ticket_text(ticket),
|
||
reply_markup=ticket_actions(ticket, callback.from_user.id, list_filter, with_back=True),
|
||
)
|
||
|
||
|
||
async def apply_defer(bot: Bot, ticket_id: int, admin_id: int, name: str, reason: str) -> None:
|
||
db.set_status(ticket_id, "deferred")
|
||
db.add_event(ticket_id, admin_id, name, "status", f"Отложена: {reason}")
|
||
await notify_user(
|
||
bot,
|
||
db.get_ticket(ticket_id),
|
||
f"Ваша заявка #{ticket_id} отложена.\nПричина: {reason}\nМы вернёмся к ней позже.",
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("defrsn:"))
|
||
async def defer_reason_choose(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 3 or not parts[1].isdigit():
|
||
await callback.answer("Некорректная команда", show_alert=True)
|
||
return
|
||
ticket_id = int(parts[1])
|
||
choice = parts[2]
|
||
list_filter = parts[3] if len(parts) > 3 else "open"
|
||
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or not can_manage_ticket(callback.from_user.id, ticket):
|
||
await callback.answer("Заявка недоступна", show_alert=True)
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
await callback.answer("Заявка уже завершена", show_alert=True)
|
||
return
|
||
|
||
if choice == "custom":
|
||
await start_admin_input(
|
||
bot, callback, state, ticket_id, list_filter, AdminFlow.defer_reason,
|
||
"Напишите причину отложения — она уйдёт заявителю.",
|
||
)
|
||
return
|
||
|
||
if not choice.isdigit() or int(choice) >= len(DEFER_REASONS):
|
||
await callback.answer("Причина не найдена", show_alert=True)
|
||
return
|
||
reason = DEFER_REASONS[int(choice)]
|
||
await apply_defer(bot, ticket_id, callback.from_user.id, actor_name(callback), reason)
|
||
await callback.answer("Заявка отложена")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
|
||
|
||
async def start_admin_input(
|
||
bot: Bot,
|
||
callback: CallbackQuery,
|
||
state: FSMContext,
|
||
ticket_id: int,
|
||
list_filter: str,
|
||
flow_state,
|
||
prompt: str,
|
||
) -> None:
|
||
active_admin_chat.pop(callback.from_user.id, None)
|
||
await state.set_state(flow_state)
|
||
await state.update_data(
|
||
ticket_id=ticket_id,
|
||
list_filter=list_filter,
|
||
panel_chat_id=callback.message.chat.id,
|
||
panel_message_id=callback.message.message_id,
|
||
)
|
||
await callback.answer()
|
||
# Заявку с фото оставляем как фото-сообщение (подсказка идёт в подпись).
|
||
ticket = db.get_ticket(ticket_id)
|
||
await show_ticket(bot, callback.message, ticket, prompt, reply_markup=admin_cancel(ticket_id, list_filter))
|
||
|
||
|
||
@router.callback_query(F.data.startswith("adm:back:"))
|
||
async def admin_cancel_input(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
|
||
await state.clear()
|
||
parts = callback.data.split(":")
|
||
ticket_id = int(parts[2])
|
||
list_filter = parts[3] if len(parts) > 3 else "open"
|
||
await callback.answer("Отменено")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
|
||
|
||
@router.callback_query(F.data == "adm:find")
|
||
async def admin_find_prompt(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
await state.set_state(AdminFlow.find_number)
|
||
await state.update_data(panel_chat_id=callback.message.chat.id, panel_message_id=callback.message.message_id)
|
||
await callback.answer()
|
||
await safe_edit(callback.message, "Отправьте номер заявки (например, 12).")
|
||
|
||
|
||
@router.callback_query(F.data.startswith("admin:"))
|
||
async def admin_action(callback: CallbackQuery, bot: Bot, state: FSMContext) -> None:
|
||
if not callback.from_user or not is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 3 or not parts[2].isdigit():
|
||
await callback.answer("Некорректная команда", show_alert=True)
|
||
return
|
||
action = parts[1]
|
||
ticket_id = int(parts[2])
|
||
list_filter = parts[3] if len(parts) > 3 else "open"
|
||
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket:
|
||
await callback.answer("Заявка не найдена", show_alert=True)
|
||
return
|
||
if not can_manage_ticket(callback.from_user.id, ticket):
|
||
await callback.answer("Эта заявка относится к другому разделу", show_alert=True)
|
||
return
|
||
|
||
user_id = callback.from_user.id
|
||
name = actor_name(callback)
|
||
status = ticket["status"]
|
||
|
||
if action == "history":
|
||
await callback.answer()
|
||
await show_ticket(bot, callback.message, ticket, history_text(ticket_id), reply_markup=back_to_ticket(ticket_id, list_filter))
|
||
return
|
||
|
||
if action == "chat":
|
||
if status in ("closed", "rejected"):
|
||
await callback.answer("Заявка завершена — чат недоступен.", show_alert=True)
|
||
return
|
||
requester = ticket["full_name"] or "заявитель"
|
||
await state.set_state(AdminFlow.chat)
|
||
active_admin_chat[user_id] = ticket_id
|
||
await state.update_data(
|
||
ticket_id=ticket_id,
|
||
list_filter=list_filter,
|
||
panel_chat_id=callback.message.chat.id,
|
||
panel_message_id=callback.message.message_id,
|
||
confirm_message_id=None,
|
||
)
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"💬 <b>Чат по заявке #{ticket_id}</b> с заявителем ({escape(requester)}).\n\n"
|
||
"Пишите сообщения здесь — заявитель получит их в боте и сможет ответить.\n"
|
||
"Когда закончите — нажмите «Закрыть чат».",
|
||
reply_markup=chat_session_kb(ticket_id, list_filter),
|
||
)
|
||
return
|
||
|
||
if action == "chatend":
|
||
data = await state.get_data()
|
||
confirm_id = data.get("confirm_message_id")
|
||
active_admin_chat.pop(user_id, None)
|
||
await state.clear()
|
||
if confirm_id:
|
||
try:
|
||
await bot.delete_message(callback.message.chat.id, confirm_id)
|
||
except Exception:
|
||
logging.debug("Could not delete chat confirm message", exc_info=True)
|
||
await callback.answer("Чат закрыт")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
return
|
||
|
||
if action == "prio":
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"{ticket_text(ticket)}\n\nВыберите приоритет:",
|
||
reply_markup=priority_menu(ticket_id, list_filter),
|
||
)
|
||
return
|
||
|
||
if action == "assignto":
|
||
admins = department_admins(ticket)
|
||
if not admins:
|
||
await callback.answer("Нет администраторов с доступом к этому разделу", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"{ticket_text(ticket)}\n\nКому назначить заявку?",
|
||
reply_markup=assignee_menu(ticket_id, admins, list_filter),
|
||
)
|
||
return
|
||
|
||
if action == "assign":
|
||
if status in ("closed", "rejected"):
|
||
await callback.answer("Заявка завершена. Сначала переоткройте её.", show_alert=True)
|
||
return
|
||
if status == "in_progress" and ticket["assignee_id"] == user_id:
|
||
await callback.answer("Заявка уже у вас в работе")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
return
|
||
intercepted = status == "in_progress"
|
||
previous = ticket["assignee_name"] or (str(ticket["assignee_id"]) if ticket["assignee_id"] else None)
|
||
db.assign_ticket(ticket_id, user_id, name)
|
||
db.add_event(ticket_id, user_id, name, "assign", "Взята в работу")
|
||
if intercepted and previous:
|
||
await callback.answer(f"Заявка передана вам (была у {previous})")
|
||
else:
|
||
await callback.answer("Заявка взята в работу")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
await notify_user(bot, db.get_ticket(ticket_id), f"Ваша заявка #{ticket_id} взята в работу.")
|
||
return
|
||
|
||
if action == "wait":
|
||
if status != "in_progress":
|
||
await callback.answer("Доступно только для заявок в работе", show_alert=True)
|
||
return
|
||
db.set_status(ticket_id, "waiting")
|
||
db.add_event(ticket_id, user_id, name, "status", "Ожидание ответа заявителя")
|
||
await callback.answer("Заявка переведена в ожидание")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
await notify_user(
|
||
bot,
|
||
db.get_ticket(ticket_id),
|
||
f"По заявке #{ticket_id} нужен ваш ответ. Напишите подробности здесь же.",
|
||
)
|
||
return
|
||
|
||
if action == "defer":
|
||
if status in ("closed", "rejected"):
|
||
await callback.answer("Заявка уже завершена", show_alert=True)
|
||
return
|
||
await callback.answer()
|
||
await show_ticket(
|
||
bot,
|
||
callback.message,
|
||
ticket,
|
||
f"{ticket_text(ticket)}\n\nПочему откладываем? Выберите причину:",
|
||
reply_markup=defer_reasons_menu(ticket_id, list_filter),
|
||
)
|
||
return
|
||
|
||
if action == "reopen":
|
||
if status not in ("closed", "rejected"):
|
||
await callback.answer("Заявка не завершена", show_alert=True)
|
||
return
|
||
db.reopen_ticket(ticket_id, user_id, name)
|
||
db.add_event(ticket_id, user_id, name, "reopen", "Переоткрыта")
|
||
await callback.answer("Заявка переоткрыта и снова в работе")
|
||
await refresh_ticket_view(callback, ticket_id, list_filter, bot)
|
||
await notify_user(bot, db.get_ticket(ticket_id), f"Ваша заявка #{ticket_id} снова в работе.")
|
||
return
|
||
|
||
if action == "close":
|
||
if status in ("closed", "rejected"):
|
||
await callback.answer("Заявка уже завершена", show_alert=True)
|
||
return
|
||
if status == "new":
|
||
await callback.answer("Сначала возьмите заявку в работу, затем закрывайте.", show_alert=True)
|
||
return
|
||
if ticket["assignee_id"] != user_id:
|
||
holder = ticket["assignee_name"] or str(ticket["assignee_id"])
|
||
await callback.answer(
|
||
f"Закрыть может только исполнитель ({holder}). Нажмите «Перехватить», если выполнили вы.",
|
||
show_alert=True,
|
||
)
|
||
return
|
||
await start_admin_input(
|
||
bot, callback, state, ticket_id, list_filter, AdminFlow.close_resolution,
|
||
"Опишите, что сделано (текст решения). Отправьте «-», чтобы записать «Выполнено».",
|
||
)
|
||
return
|
||
|
||
if action == "reject":
|
||
await start_admin_input(
|
||
bot, callback, state, ticket_id, list_filter, AdminFlow.reject_reason,
|
||
"Укажите причину отклонения заявки.",
|
||
)
|
||
return
|
||
|
||
if action == "note":
|
||
await start_admin_input(
|
||
bot, callback, state, ticket_id, list_filter, AdminFlow.note_text,
|
||
"Введите внутреннюю заметку (не видна заявителю).",
|
||
)
|
||
return
|
||
|
||
if action == "msg":
|
||
await start_admin_input(
|
||
bot, callback, state, ticket_id, list_filter, AdminFlow.user_message,
|
||
"Введите сообщение для заявителя — оно будет отправлено ему в чат.",
|
||
)
|
||
return
|
||
|
||
await callback.answer("Неизвестное действие", show_alert=True)
|
||
|
||
|
||
async def finish_admin_input(bot: Bot, state: FSMContext, admin_id: int, admin_name: str) -> tuple[int, str] | None:
|
||
data = await state.get_data()
|
||
ticket_id = data.get("ticket_id")
|
||
list_filter = data.get("list_filter", "open")
|
||
if ticket_id is None:
|
||
return None
|
||
return ticket_id, list_filter
|
||
|
||
|
||
async def render_panel(bot: Bot, state: FSMContext, admin_id: int, ticket_id: int, list_filter: str) -> None:
|
||
data = await state.get_data()
|
||
chat_id = data.get("panel_chat_id")
|
||
message_id = data.get("panel_message_id")
|
||
ticket = db.get_ticket(ticket_id)
|
||
if not ticket or not chat_id or not message_id:
|
||
return
|
||
await edit_panel_by_id(
|
||
bot,
|
||
chat_id,
|
||
message_id,
|
||
ticket_text(ticket),
|
||
reply_markup=ticket_actions(ticket, admin_id, list_filter, with_back=True),
|
||
)
|
||
|
||
|
||
@router.message(AdminFlow.close_resolution)
|
||
async def admin_close_resolution(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
target = await finish_admin_input(bot, state, message.from_user.id, message.from_user.full_name)
|
||
if not target:
|
||
await state.clear()
|
||
return
|
||
ticket_id, list_filter = target
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
text = (message.text or "").strip()
|
||
resolution = "Выполнено" if text in ("", "-") else text
|
||
db.close_ticket(ticket_id, message.from_user.id, name, resolution, status="closed")
|
||
db.add_event(ticket_id, message.from_user.id, name, "close", f"Решение: {resolution}")
|
||
await delete_user_message(message)
|
||
await render_panel(bot, state, message.from_user.id, ticket_id, list_filter)
|
||
await state.clear()
|
||
await notify_user(
|
||
bot,
|
||
db.get_ticket(ticket_id),
|
||
f"Ваша заявка #{ticket_id} закрыта.\nРешение: {resolution}\nЕсли проблема осталась — создайте новую заявку.",
|
||
)
|
||
|
||
|
||
@router.message(AdminFlow.reject_reason)
|
||
async def admin_reject_reason(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
target = await finish_admin_input(bot, state, message.from_user.id, message.from_user.full_name)
|
||
if not target:
|
||
await state.clear()
|
||
return
|
||
ticket_id, list_filter = target
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
reason = (message.text or "").strip() or "Без указания причины"
|
||
db.close_ticket(ticket_id, message.from_user.id, name, reason, status="rejected")
|
||
db.add_event(ticket_id, message.from_user.id, name, "reject", f"Причина: {reason}")
|
||
await delete_user_message(message)
|
||
await render_panel(bot, state, message.from_user.id, ticket_id, list_filter)
|
||
await state.clear()
|
||
await notify_user(bot, db.get_ticket(ticket_id), f"Ваша заявка #{ticket_id} отклонена.\nПричина: {reason}")
|
||
|
||
|
||
@router.message(AdminFlow.note_text)
|
||
async def admin_note_text(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
target = await finish_admin_input(bot, state, message.from_user.id, message.from_user.full_name)
|
||
if not target:
|
||
await state.clear()
|
||
return
|
||
ticket_id, list_filter = target
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
note = (message.text or "").strip()
|
||
if note:
|
||
db.add_event(ticket_id, message.from_user.id, name, "note", note)
|
||
await delete_user_message(message)
|
||
await render_panel(bot, state, message.from_user.id, ticket_id, list_filter)
|
||
await state.clear()
|
||
|
||
|
||
@router.message(AdminFlow.defer_reason)
|
||
async def admin_defer_reason(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
target = await finish_admin_input(bot, state, message.from_user.id, message.from_user.full_name)
|
||
if not target:
|
||
await state.clear()
|
||
return
|
||
ticket_id, list_filter = target
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
reason = (message.text or "").strip() or "Отложена"
|
||
await apply_defer(bot, ticket_id, message.from_user.id, name, reason)
|
||
await delete_user_message(message)
|
||
await render_panel(bot, state, message.from_user.id, ticket_id, list_filter)
|
||
await state.clear()
|
||
|
||
|
||
@router.message(AdminFlow.user_message)
|
||
async def admin_user_message(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
target = await finish_admin_input(bot, state, message.from_user.id, message.from_user.full_name)
|
||
if not target:
|
||
await state.clear()
|
||
return
|
||
ticket_id, list_filter = target
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
text = (message.text or "").strip()
|
||
ticket = db.get_ticket(ticket_id)
|
||
if text and ticket:
|
||
await notify_user(bot, ticket, f"Сообщение по заявке #{ticket_id}:\n{text}")
|
||
db.add_event(ticket_id, message.from_user.id, name, "message", text)
|
||
await delete_user_message(message)
|
||
await render_panel(bot, state, message.from_user.id, ticket_id, list_filter)
|
||
await state.clear()
|
||
|
||
|
||
@router.message(AdminFlow.chat)
|
||
async def admin_chat_message(message: Message, state: FSMContext, bot: Bot) -> None:
|
||
if not message.from_user:
|
||
return
|
||
data = await state.get_data()
|
||
ticket_id = data.get("ticket_id")
|
||
list_filter = data.get("list_filter", "open")
|
||
ticket = db.get_ticket(ticket_id) if ticket_id else None
|
||
if not ticket:
|
||
active_admin_chat.pop(message.from_user.id, None)
|
||
await state.clear()
|
||
await message.answer("Чат закрыт: заявка недоступна.")
|
||
return
|
||
if ticket["status"] in ("closed", "rejected"):
|
||
active_admin_chat.pop(message.from_user.id, None)
|
||
await state.clear()
|
||
await message.answer(f"Заявка #{ticket_id} закрыта — чат завершён.")
|
||
return
|
||
|
||
# Поддерживаем реестр активного чата в актуальном состоянии.
|
||
active_admin_chat[message.from_user.id] = ticket_id
|
||
name = message.from_user.full_name or str(message.from_user.id)
|
||
photo = message.photo[-1].file_id if message.photo else None
|
||
body = (message.caption if photo else message.text) or ""
|
||
body = body.strip()
|
||
if not photo and not body:
|
||
return
|
||
|
||
# Пересылаем сообщение заявителю с кнопкой «Ответить».
|
||
header = f"💬 <b>Сообщение от поддержки по заявке #{ticket_id}</b>"
|
||
try:
|
||
if photo:
|
||
await bot.send_photo(
|
||
ticket["user_id"], photo,
|
||
caption=f"{header}\n{escape(body)}" if body else header,
|
||
reply_markup=user_reply_kb(ticket_id),
|
||
)
|
||
else:
|
||
await bot.send_message(
|
||
ticket["user_id"], f"{header}\n{escape(body)}", reply_markup=user_reply_kb(ticket_id)
|
||
)
|
||
except Exception:
|
||
logging.debug("Could not deliver chat message to user", exc_info=True)
|
||
await message.answer("Не удалось доставить сообщение заявителю.")
|
||
return
|
||
|
||
db.add_event(ticket_id, message.from_user.id, name, "message", (body or "[фото]"))
|
||
|
||
# Кнопку «Закрыть чат» держим внизу: удаляем прошлое подтверждение, шлём новое.
|
||
prev = data.get("confirm_message_id")
|
||
if prev:
|
||
try:
|
||
await bot.delete_message(message.chat.id, prev)
|
||
except Exception:
|
||
logging.debug("Could not delete previous chat confirm", exc_info=True)
|
||
sent = await bot.send_message(
|
||
message.chat.id,
|
||
f"✓ Доставлено заявителю · заявка #{ticket_id}",
|
||
reply_markup=chat_session_kb(ticket_id, list_filter),
|
||
)
|
||
await state.update_data(confirm_message_id=sent.message_id)
|
||
|
||
|
||
@router.message(AdminFlow.find_number)
|
||
async def admin_find_number(message: Message, state: FSMContext) -> None:
|
||
raw = (message.text or "").strip().lstrip("#")
|
||
await state.clear()
|
||
if not raw.isdigit():
|
||
await message.answer("Нужен номер заявки. Откройте /admin и попробуйте снова.")
|
||
return
|
||
await show_ticket_for_admin(message, message.from_user.id, int(raw), "open")
|
||
|
||
|
||
async def escalation_loop(bot: Bot) -> None:
|
||
while True:
|
||
try:
|
||
for ticket in db.list_overdue_unescalated(config.sla_minutes):
|
||
db.mark_escalated(ticket["id"])
|
||
db.add_event(ticket["id"], 0, "Система", "escalate", "Превышен SLA")
|
||
fresh = db.get_ticket(ticket["id"])
|
||
for admin_id, deps in config.admin_departments.items():
|
||
if ticket["department"] not in deps:
|
||
continue
|
||
try:
|
||
await send_ticket_view(
|
||
bot,
|
||
admin_id,
|
||
fresh,
|
||
f"‼️ <b>Просрочен SLA</b>\n\n{ticket_text(fresh)}",
|
||
reply_markup=ticket_actions(fresh, admin_id, with_back=True),
|
||
)
|
||
except Exception:
|
||
logging.debug("Could not send escalation to %s", admin_id, exc_info=True)
|
||
except Exception:
|
||
logging.exception("Escalation loop iteration failed")
|
||
await asyncio.sleep(60)
|
||
|
||
|
||
async def main() -> None:
|
||
global config, db
|
||
logging.basicConfig(level=logging.INFO)
|
||
config = load_config()
|
||
db = Database(config.database_path)
|
||
|
||
bot = Bot(config.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||
dp = Dispatcher()
|
||
dp.include_router(router)
|
||
asyncio.create_task(escalation_loop(bot))
|
||
await dp.start_polling(bot)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|