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 = """
FAQ
• Если нет интернета: проверьте кабель/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"SLA: 🔴 просрочено на {abs(remaining)} мин"
if remaining <= 10:
return f"SLA: 🟠 осталось {remaining} мин"
return f"SLA: 🟢 осталось {remaining} мин"
def ticket_text(ticket: dict | object, for_admin: bool = True) -> str:
lines = [
f"📋 Заявка #{ticket['id']}",
f"Статус: {escape(status_label(ticket['status']))}",
f"Приоритет: {escape(priority_label(ticket['priority']))}",
f"Сотрудник: {escape(ticket['full_name'] or '-')}",
f"Раздел: {escape(ticket['department'])}",
f"Тема: {escape(ticket['topic'])}",
]
if ticket["subtopic"] and ticket["subtopic"] != ticket["topic"]:
lines.append(f"Уточнение: {escape(ticket['subtopic'])}")
lines += [
f"Описание: {escape(ticket['description'])}",
f"Создана: {escape(ticket['created_at'])}",
]
photo_count = len(ticket_photos(ticket))
if photo_count:
lines.append(f"📎 Фото: {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"Исполнитель: {escape(assignee)}")
if ticket["status"] in ("closed", "rejected"):
if ticket["closed_at"]:
lines.append(f"Закрыта: {escape(ticket['closed_at'])}")
if ticket["resolution"]:
label = "Причина отклонения" if ticket["status"] == "rejected" else "Решение"
lines.append(f"{label}: {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📝 Последняя заметка: {escape(notes[-1]['text'] or '')}")
comments = [event for event in events if event["kind"] == "user_comment"]
if comments:
lines.append("\n💬 Комментарии заявителя:")
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"История заявки #{ticket_id}\n\nСобытий пока нет."
lines = [f"История заявки #{ticket_id}", ""]
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"💬 Заявитель по заявке #{ticket['id']}"
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"Панель заявок\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"Панель заявок\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"{label}\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"{label}\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:".
sec_code = callback.data.split(":", 1)[1]
await state.set_state(TicketFlow.topic)
await callback.answer()
await callback.message.edit_text(
f"{section_label(sec_code)}\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"💬 Чат по заявке #{ticket_id} с заявителем ({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"💬 Сообщение от поддержки по заявке #{ticket_id}"
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"‼️ Просрочен SLA\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())