commit 4548a57f837bb42e0de94f4cbf7359cbb347c444 Author: tikeev.k Date: Mon Jun 1 17:38:44 2026 +0300 . diff --git a/.env.backup b/.env.backup new file mode 100644 index 0000000..c2c03c5 --- /dev/null +++ b/.env.backup @@ -0,0 +1,5 @@ +BOT_TOKEN=8891367536:AAEjRPXoI0BT1abhSRM-bVSnP_UNi6z-9ZY +ADMIN_TELEGRAM_IDS= +ADMIN_DEPARTMENTS=7350810301:IT +DATABASE_PATH=/app/data/tickets.db +SLA_MINUTES=60 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d76ae9 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +BOT_TOKEN=123456:replace_me +ADMIN_TELEGRAM_IDS= +ADMIN_DEPARTMENTS=7350810301:IT +DATABASE_PATH=/app/data/tickets.db +SLA_MINUTES=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8d6c50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +__pycache__/ +*.pyc +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e74644a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot ./bot + +RUN mkdir -p /app/data + +CMD ["python", "-m", "bot.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..85637ad --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Telegram IT Ticket Bot + +Бот создает IT-заявки по логике из блок-схемы: сотрудник выбирает направление, тему проблемы, получает короткую инструкцию, а если проблема не решена, бот собирает детали и сохраняет заявку в SQLite. + +## Быстрый старт + +1. Создайте бота в Telegram через BotFather и получите токен. +2. Скопируйте настройки: + +```bash +cp .env.example .env +``` + +3. Заполните `.env`: + +```env +BOT_TOKEN=ваш_токен +ADMIN_DEPARTMENTS=7350810301:IT;123456789:HR,Финансы +DATABASE_PATH=/app/data/tickets.db +SLA_MINUTES=60 +``` + +`ADMIN_DEPARTMENTS` задает права администраторов по отделам: + +```env +telegram_id:Отдел +telegram_id:Отдел1,Отдел2 +``` + +Пример: `7350810301:IT` значит, что этот админ видит и управляет только заявками отдела `IT`. + +4. Запустите: + +```bash +docker compose up --build -d +``` + +5. Откройте Telegram и напишите боту `/start`. + +## Команды + +`/start` - главное меню сотрудника. + +`/admin` - панель администратора: сводка по статусам и фильтры заявок. + +`/find <номер>` - открыть конкретную заявку по номеру (только для администраторов). + +## Для сотрудника + +В разделе «Мои заявки» по активной заявке доступна кнопка **«💬 Добавить +комментарий»**: текст попадает в историю заявки и приходит уведомлением исполнителю +(а если заявку ещё не взяли — всем администраторам отдела). Внутренние заметки +администраторов сотруднику не показываются. + +**Проверка дубликатов.** Если перед созданием у сотрудника уже есть открытая заявка +по той же теме, бот не плодит дубль, а предлагает выбор: **«Дополнить заявку #N»** +(введённое описание и вложение уходят комментарием к существующей заявке и +уведомлением исполнителю) или **«Создать новую заявку»**. Если старая заявка к этому +моменту уже закрыта, бот просто создаёт новую. + +**Чат с поддержкой.** По активной заявке есть кнопка **«💬 Чат с поддержкой»** — это +живое общение, а не комментарии: сотрудник просто пишет сообщения, они идут исполнителю +(или администраторам отдела), «❌ Завершить чат» выходит из режима. Отдельная кнопка +**«💬 Добавить комментарий»** остаётся для заметок к заявке. + +**Закрыть свою заявку.** По активной заявке сотрудник может нажать **«✅ Вопрос решён, +закрыть»** (с подтверждением) — если проблема решилась сама. Исполнителю уходит +уведомление; при необходимости администратор может переоткрыть заявку. + +**Скриншот** запрашивается только для темы «Ошибка программы». Для остальных тем шаг +вложения пропускается. Шаг «удобное время визита» убран. + +## Панель администратора + +`/admin` открывает дашборд со счётчиками (открытые, новые, в работе, ожидание, +отложенные, просроченные) и кнопками-фильтрами, а также поиском по номеру и архивом. +Из карточки заявки доступны действия по всему жизненному циклу: + +- **Взять в работу / Перехватить / Вернуть в работу** — назначение на себя. +- **Назначить** — передать заявку другому администратору отдела. +- **Закрыть** — со своим текстом решения (а не фиксированным «Выполнено»). +- **Отклонить** — с указанием причины. +- **В ожидание** — статус «ждём ответа заявителя» с уведомлением сотрудника. +- **Отложить** — с выбором причины из готового списка «быстрых ответов» (ждём + запчасти, согласуем время и т.д.) или своей причины; причина уходит заявителю + и пишется в историю. +- **Переоткрыть** — вернуть закрытую/отклонённую заявку в работу. +- **Приоритет** — низкий / средний / высокий / критичный. +- **Заметка** — внутренний комментарий (не виден заявителю). +- **Чат** — живая переписка с заявителем. Исполнитель пишет сообщения (текст/фото), + заявитель получает их и, нажав «💬 Ответить», входит в чат и просто пишет в ответ — + без оформления «комментариев». Сообщения с обеих сторон попадают в историю заявки + как переписка. «Закрыть чат» / «Завершить чат» завершает режим у каждой стороны. +- **История** — полный журнал изменений по заявке. + +Статусы: `Новая`, `В работе`, `Ожидание ответа`, `Отложена`, `Закрыта`, `Отклонена`. +Новые заявки создаются с приоритетом «средний»; приоритет меняет администратор. + +### SLA и эскалация + +Дедлайн считается от времени создания плюс `SLA_MINUTES`. Просроченные заявки +помечаются 🔴 в списках и карточке. Фоновая задача раз в минуту находит просроченные +заявки и один раз отправляет администраторам отдела уведомление об эскалации. + +## Данные + +SQLite-база хранится в `./data/tickets.db`, каталог подключен в контейнер как volume. +При запуске на старой базе схема мигрирует автоматически (добавляются поля приоритета, +SLA и таблицы истории/пользователей) — данные не теряются. + +Таблицы: + +- `tickets` — заявки (статус, приоритет, исполнитель, время создания, SLA-флаг). +- `ticket_events` — журнал событий и комментарии по каждой заявке. +- `users` — имена пользователей/администраторов (для отображения исполнителя). + +## Что удобно добавить перед интеграцией с сайтом + +- Авторизацию по телефону, корпоративному ID или коду. +- Экспорт заявок и истории в API будущего сайта. +- Настраиваемый SLA и приоритет на уровне отдела/темы. +- Отчёты и выгрузку статистики по исполнителям. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..20dc5ae --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +"""Telegram ticket bot package.""" diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..d8be08f --- /dev/null +++ b/bot/config.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +import os + + +@dataclass(frozen=True) +class Config: + bot_token: str + admin_telegram_ids: set[int] + admin_departments: dict[int, set[str]] + database_path: str + sla_minutes: int + + +def parse_admin_departments(raw: str) -> dict[int, set[str]]: + result: dict[int, set[str]] = {} + for rule in raw.split(";"): + if ":" not in rule: + continue + admin_id_raw, departments_raw = rule.split(":", 1) + admin_id_raw = admin_id_raw.strip() + if not admin_id_raw.isdigit(): + continue + + departments = { + department.strip() + for department in departments_raw.split(",") + if department.strip() + } + if departments: + result[int(admin_id_raw)] = departments + return result + + +def load_config() -> Config: + token = os.getenv("BOT_TOKEN", "").strip() + if not token: + raise RuntimeError("BOT_TOKEN is not set") + + admin_ids_raw = os.getenv("ADMIN_TELEGRAM_IDS", "") + admin_ids = { + int(item.strip()) + for item in admin_ids_raw.split(",") + if item.strip().isdigit() + } + admin_departments = parse_admin_departments(os.getenv("ADMIN_DEPARTMENTS", "")) + # Если для админа не задан явный список разделов — он видит ВСЕ разделы. + for admin_id in admin_ids: + admin_departments.setdefault(admin_id, set()) + + return Config( + bot_token=token, + admin_telegram_ids=admin_ids, + admin_departments=admin_departments, + database_path=os.getenv("DATABASE_PATH", "/app/data/tickets.db"), + sla_minutes=int(os.getenv("SLA_MINUTES", "60")), + ) diff --git a/bot/db.py b/bot/db.py new file mode 100644 index 0000000..d55b955 --- /dev/null +++ b/bot/db.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import sqlite3 +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterator +from zoneinfo import ZoneInfo + +MOSCOW = ZoneInfo("Europe/Moscow") + +# Заявка считается завершённой в этих статусах. +TERMINAL_STATUSES = ("closed", "rejected") +# Статусы, по которым заявка ещё «в работе» и попадает в SLA/эскалацию. +ACTIVE_STATUSES = ("new", "in_progress", "waiting", "deferred") + +VALID_PRIORITIES = ("low", "medium", "high", "critical") + +# Рабочее окно SLA-таймера: пн-пт 9:00–18:00 МСК. Вне этих часов таймер «стоит». +WORK_START_HOUR = 9 +WORK_END_HOUR = 18 +WORK_DAYS = {0, 1, 2, 3, 4} # 0 — понедельник, 4 — пятница + + +def _work_window(d: datetime) -> tuple[datetime, datetime]: + start = d.replace(hour=WORK_START_HOUR, minute=0, second=0, microsecond=0) + end = d.replace(hour=WORK_END_HOUR, minute=0, second=0, microsecond=0) + return start, end + + +def work_minutes_between(start: datetime, end: datetime) -> float: + """Сколько минут «рабочего времени» прошло между двумя моментами (МСК, пн-пт 9-18).""" + if end <= start: + return 0.0 + start = start.astimezone(MOSCOW) + end = end.astimezone(MOSCOW) + total = 0.0 + cur = start + while cur.date() < end.date(): + if cur.weekday() in WORK_DAYS: + day_start, day_end = _work_window(cur) + lo = max(cur, day_start) + if lo < day_end: + total += (day_end - lo).total_seconds() / 60 + cur = (cur + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + if end.weekday() in WORK_DAYS: + day_start, day_end = _work_window(end) + lo = max(cur, day_start) + hi = min(end, day_end) + if lo < hi: + total += (hi - lo).total_seconds() / 60 + return total + + +def moscow_now() -> str: + return datetime.now(MOSCOW).strftime("%d.%m.%Y %H:%M:%S МСК") + + +def parse_moscow(value: str | None) -> datetime | None: + """Разбирает дату заявки обратно в datetime. + + Поддерживает текущий формат '25.05.2026 14:45:00 МСК', а также старый + дефолт SQLite CURRENT_TIMESTAMP ('2026-05-22 13:58:09', UTC). + """ + if not value: + return None + cleaned = value.replace("МСК", "").strip() + try: + return datetime.strptime(cleaned, "%d.%m.%Y %H:%M:%S").replace(tzinfo=MOSCOW) + except ValueError: + pass + try: + return datetime.strptime(cleaned, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _epoch(ticket: sqlite3.Row | dict) -> float | None: + try: + value = ticket["created_epoch"] + except (KeyError, IndexError): + value = None + if value: + return float(value) + parsed = parse_moscow(ticket["created_at"] if ticket["created_at"] else None) + return parsed.timestamp() if parsed else None + + +def is_overdue(ticket: sqlite3.Row | dict, sla_minutes: int) -> bool: + # SLA-таймер реакции: тикает только пока заявка «new» и только в рабочие часы. + if ticket["status"] != "new": + return False + base = _epoch(ticket) + if base is None: + return False + created = datetime.fromtimestamp(base, MOSCOW) + elapsed = work_minutes_between(created, datetime.now(MOSCOW)) + return elapsed > sla_minutes + + +def sla_remaining_minutes(ticket: sqlite3.Row | dict, sla_minutes: int) -> int | None: + """Минут до дедлайна реакции (по рабочему времени); отрицательное — насколько просрочено. + + Возвращает None для любого статуса кроме «new» — SLA реакции к ним не применяется. + """ + if ticket["status"] != "new": + return None + base = _epoch(ticket) + if base is None: + return None + created = datetime.fromtimestamp(base, MOSCOW) + elapsed = work_minutes_between(created, datetime.now(MOSCOW)) + return int(sla_minutes - elapsed) + + +class Database: + def __init__(self, path: str) -> None: + self.path = path + Path(path).parent.mkdir(parents=True, exist_ok=True) + self.init() + + @contextmanager + def connect(self) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + def init(self) -> None: + with self.connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT, + full_name TEXT, + department TEXT NOT NULL, + topic TEXT NOT NULL, + subtopic TEXT NOT NULL, + location TEXT NOT NULL, + description TEXT NOT NULL, + contact_time TEXT NOT NULL, + attachment_file_id TEXT, + attachments TEXT, + status TEXT NOT NULL DEFAULT 'new', + priority TEXT NOT NULL DEFAULT 'medium', + assignee_id INTEGER, + assignee_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_epoch REAL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TEXT, + resolution TEXT, + escalated INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ticket_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticket_id INTEGER NOT NULL, + actor_id INTEGER, + actor_name TEXT, + kind TEXT NOT NULL, + text TEXT, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + name TEXT, + username TEXT, + updated_at TEXT + ) + """ + ) + + # Аддитивные миграции для баз, созданных прежней версией бота. + columns = {row["name"] for row in conn.execute("PRAGMA table_info(tickets)")} + migrations = { + "assignee_name": "ALTER TABLE tickets ADD COLUMN assignee_name TEXT", + "priority": "ALTER TABLE tickets ADD COLUMN priority TEXT NOT NULL DEFAULT 'medium'", + "created_epoch": "ALTER TABLE tickets ADD COLUMN created_epoch REAL", + "escalated": "ALTER TABLE tickets ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0", + "attachments": "ALTER TABLE tickets ADD COLUMN attachments TEXT", + } + for column, statement in migrations.items(): + if column not in columns: + conn.execute(statement) + + # Заполняем created_epoch для старых заявок, разбирая текстовую дату. + for row in conn.execute( + "SELECT id, created_at FROM tickets WHERE created_epoch IS NULL" + ).fetchall(): + parsed = parse_moscow(row["created_at"]) + conn.execute( + "UPDATE tickets SET created_epoch = ? WHERE id = ?", + (parsed.timestamp() if parsed else None, row["id"]), + ) + + # ----------------------------------------------------------------- users + def touch_user(self, user_id: int, name: str | None, username: str | None) -> None: + if not user_id: + return + with self.connect() as conn: + conn.execute( + """ + INSERT INTO users (user_id, name, username, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + name = excluded.name, + username = excluded.username, + updated_at = excluded.updated_at + """, + (user_id, name, username, moscow_now()), + ) + + def user_name(self, user_id: int) -> str | None: + with self.connect() as conn: + row = conn.execute( + "SELECT name, username FROM users WHERE user_id = ?", (user_id,) + ).fetchone() + if not row: + return None + return row["name"] or (f"@{row['username']}" if row["username"] else None) + + # --------------------------------------------------------------- tickets + def create_ticket(self, payload: dict[str, Any]) -> int: + now = moscow_now() + with self.connect() as conn: + cursor = conn.execute( + """ + INSERT INTO tickets ( + user_id, username, full_name, department, topic, subtopic, + location, description, contact_time, attachment_file_id, attachments, + priority, created_at, created_epoch, updated_at + ) + VALUES ( + :user_id, :username, :full_name, :department, :topic, :subtopic, + :location, :description, :contact_time, :attachment_file_id, :attachments, + :priority, :created_at, :created_epoch, :updated_at + ) + """, + { + **payload, + "priority": payload.get("priority", "medium"), + "created_at": now, + "created_epoch": time.time(), + "updated_at": now, + }, + ) + ticket_id = int(cursor.lastrowid) + conn.execute( + """ + INSERT INTO ticket_events (ticket_id, actor_id, actor_name, kind, text, created_at) + VALUES (?, ?, ?, 'created', ?, ?) + """, + (ticket_id, payload.get("user_id"), payload.get("full_name"), "Заявка создана", now), + ) + return ticket_id + + def find_duplicate_ticket(self, user_id: int, topic: str) -> sqlite3.Row | None: + """Открытая заявка того же сотрудника по той же теме — кандидат в дубликаты.""" + if not user_id: + return None + terminal = ", ".join(f"'{s}'" for s in TERMINAL_STATUSES) + with self.connect() as conn: + return conn.execute( + f""" + SELECT * FROM tickets + WHERE user_id = ? AND topic = ? AND status NOT IN ({terminal}) + ORDER BY id DESC + LIMIT 1 + """, + (user_id, topic), + ).fetchone() + + def list_user_tickets(self, user_id: int, status_filter: str = "active") -> list[sqlite3.Row]: + terminal = ", ".join(f"'{s}'" for s in TERMINAL_STATUSES) + where_status = f"status NOT IN ({terminal})" + if status_filter == "closed": + where_status = f"status IN ({terminal})" + elif status_filter == "all": + where_status = "1 = 1" + + with self.connect() as conn: + return conn.execute( + f""" + SELECT * FROM tickets + WHERE user_id = ? AND {where_status} + ORDER BY id DESC + LIMIT 10 + """, + (user_id,), + ).fetchall() + + def list_admin_tickets( + self, + departments: set[str] | None, + list_filter: str, + admin_id: int, + sla_minutes: int, + ) -> list[sqlite3.Row]: + terminal = ", ".join(f"'{s}'" for s in TERMINAL_STATUSES) + clauses: list[str] = [] + params: list[Any] = [] + + if departments: + placeholders = ", ".join("?" for _ in departments) + clauses.append(f"department IN ({placeholders})") + params.extend(sorted(departments)) + + status_map = { + "open": f"status NOT IN ({terminal})", + "new": "status = 'new'", + "in_progress": "status = 'in_progress'", + "waiting": "status = 'waiting'", + "deferred": "status = 'deferred'", + "closed": f"status IN ({terminal})", + } + if list_filter == "mine": + clauses.append(f"status NOT IN ({terminal})") + clauses.append("assignee_id = ?") + params.append(admin_id) + elif list_filter == "overdue": + clauses.append(f"status NOT IN ({terminal})") + else: + clauses.append(status_map.get(list_filter, status_map["open"])) + + where = " AND ".join(clauses) if clauses else "1 = 1" + + # Архив сортируем по убыванию номера и показываем только последние 10, + # иначе список разрастётся. Активные списки — по приоритету и давности. + if list_filter == "closed": + order_by = "id DESC" + sql_limit = 10 + else: + order_by = ( + "CASE priority " + "WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, " + "COALESCE(created_epoch, 0) ASC, id ASC" + ) + sql_limit = 30 + + with self.connect() as conn: + rows = conn.execute( + f"SELECT * FROM tickets WHERE {where} ORDER BY {order_by} LIMIT {sql_limit}", + params, + ).fetchall() + + if list_filter == "overdue": + rows = [row for row in rows if is_overdue(row, sla_minutes)] + if list_filter == "closed": + return rows + return rows[:20] + + def dashboard(self, departments: set[str] | None, sla_minutes: int) -> dict[str, int]: + department_filter = "" + params: list[str] = [] + if departments: + placeholders = ", ".join("?" for _ in departments) + department_filter = f" WHERE department IN ({placeholders})" + params.extend(sorted(departments)) + + with self.connect() as conn: + rows = conn.execute( + f"SELECT status, priority, created_epoch, created_at FROM tickets{department_filter}", + params, + ).fetchall() + + stats = {key: 0 for key in ("new", "in_progress", "waiting", "deferred", "closed", "open", "overdue", "mine_na")} + for row in rows: + status = row["status"] + if status in TERMINAL_STATUSES: + stats["closed"] += 1 + continue + stats["open"] += 1 + if status in stats: + stats[status] += 1 + if is_overdue(row, sla_minutes): + stats["overdue"] += 1 + return stats + + def get_ticket(self, ticket_id: int) -> sqlite3.Row | None: + with self.connect() as conn: + return conn.execute( + "SELECT * FROM tickets WHERE id = ?", + (ticket_id,), + ).fetchone() + + def list_overdue_unescalated(self, sla_minutes: int) -> list[sqlite3.Row]: + terminal = ", ".join(f"'{s}'" for s in TERMINAL_STATUSES) + with self.connect() as conn: + rows = conn.execute( + f"SELECT * FROM tickets WHERE status NOT IN ({terminal}) AND escalated = 0" + ).fetchall() + return [row for row in rows if is_overdue(row, sla_minutes)] + + def mark_escalated(self, ticket_id: int) -> None: + with self.connect() as conn: + conn.execute("UPDATE tickets SET escalated = 1 WHERE id = ?", (ticket_id,)) + + # --------------------------------------------------------------- changes + def _touch(self, conn: sqlite3.Connection, ticket_id: int, fields: dict[str, Any]) -> None: + fields["updated_at"] = moscow_now() + assignments = ", ".join(f"{key} = :{key}" for key in fields) + conn.execute( + f"UPDATE tickets SET {assignments} WHERE id = :ticket_id", + {**fields, "ticket_id": ticket_id}, + ) + + def add_event( + self, + ticket_id: int, + actor_id: int | None, + actor_name: str | None, + kind: str, + text: str | None = None, + ) -> None: + with self.connect() as conn: + conn.execute( + """ + INSERT INTO ticket_events (ticket_id, actor_id, actor_name, kind, text, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (ticket_id, actor_id, actor_name, kind, text, moscow_now()), + ) + + def list_events(self, ticket_id: int, limit: int = 30) -> list[sqlite3.Row]: + with self.connect() as conn: + return conn.execute( + "SELECT * FROM ticket_events WHERE ticket_id = ? ORDER BY id ASC LIMIT ?", + (ticket_id, limit), + ).fetchall() + + def assign_ticket(self, ticket_id: int, assignee_id: int, assignee_name: str) -> None: + with self.connect() as conn: + self._touch( + conn, + ticket_id, + {"status": "in_progress", "assignee_id": assignee_id, "assignee_name": assignee_name}, + ) + + def set_status(self, ticket_id: int, status: str) -> None: + with self.connect() as conn: + self._touch(conn, ticket_id, {"status": status}) + + def set_priority(self, ticket_id: int, priority: str) -> None: + with self.connect() as conn: + self._touch(conn, ticket_id, {"priority": priority}) + + def close_ticket( + self, + ticket_id: int, + assignee_id: int, + assignee_name: str, + resolution: str, + status: str = "closed", + ) -> None: + now = moscow_now() + with self.connect() as conn: + conn.execute( + """ + UPDATE tickets + SET status = ?, + assignee_id = COALESCE(assignee_id, ?), + assignee_name = COALESCE(assignee_name, ?), + resolution = ?, + closed_at = ?, + updated_at = ? + WHERE id = ? + """, + (status, assignee_id, assignee_name, resolution, now, now, ticket_id), + ) + + def close_by_user(self, ticket_id: int, resolution: str) -> None: + """Заявитель сам закрывает заявку (вопрос решился). Исполнителя не меняем.""" + now = moscow_now() + with self.connect() as conn: + conn.execute( + """ + UPDATE tickets + SET status = 'closed', resolution = ?, closed_at = ?, updated_at = ? + WHERE id = ? + """, + (resolution, now, now, ticket_id), + ) + + def reopen_ticket(self, ticket_id: int, assignee_id: int, assignee_name: str) -> None: + now = moscow_now() + with self.connect() as conn: + conn.execute( + """ + UPDATE tickets + SET status = 'in_progress', + assignee_id = ?, + assignee_name = ?, + resolution = NULL, + closed_at = NULL, + escalated = 0, + updated_at = ? + WHERE id = ? + """, + (assignee_id, assignee_name, now, ticket_id), + ) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..fa9a4be --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,361 @@ +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup + +from bot.db import is_overdue +from bot.taxonomy import SECTIONS, SUBTOPICS, TOPICS + +STATUS_LABELS = { + "new": "🆕 Новая", + "in_progress": "🛠 В работе", + "waiting": "⏳ Ожидание ответа", + "deferred": "💤 Отложена", + "closed": "✅ Закрыта", + "rejected": "🚫 Отклонена", +} + +PRIORITY_LABELS = { + "low": "🟢 Низкий", + "medium": "🟡 Средний", + "high": "🟠 Высокий", + "critical": "🔴 Критичный", +} + +PRIORITY_EMOJI = { + "low": "🟢", + "medium": "🟡", + "high": "🟠", + "critical": "🔴", +} + + +def status_label(status: str) -> str: + return STATUS_LABELS.get(status, status) + + +def priority_label(priority: str) -> str: + return PRIORITY_LABELS.get(priority, priority) + + +def priority_emoji(priority: str) -> str: + return PRIORITY_EMOJI.get(priority, "⚪️") + + +def main_menu() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="Создать заявку")], + [KeyboardButton(text="Мои заявки"), KeyboardButton(text="FAQ")], + ], + resize_keyboard=True, + ) + + +def sections() -> InlineKeyboardMarkup: + """Корневое меню: 11 разделов согласно схеме.""" + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=label, callback_data=f"sec:{code}")] + for code, label in SECTIONS + ] + ) + + +def topics(section_code: str) -> InlineKeyboardMarkup: + """Подтемы выбранного раздела + кнопка «Назад к разделам».""" + rows: list[list[InlineKeyboardButton]] = [ + [InlineKeyboardButton(text=label, callback_data=f"tp:{section_code}:{code}")] + for code, label in TOPICS.get(section_code, []) + ] + rows.append([InlineKeyboardButton(text="⬅️ К разделам", callback_data="sec:back")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def subtopics(section_code: str, topic_code: str) -> InlineKeyboardMarkup: + """Уточнение для подтемы, у которой есть 3-й уровень (например, тип CRM).""" + rows: list[list[InlineKeyboardButton]] = [ + [InlineKeyboardButton(text=label, callback_data=f"sb:{section_code}:{topic_code}:{code}")] + for code, label in SUBTOPICS.get((section_code, topic_code), []) + ] + rows.append([InlineKeyboardButton(text="⬅️ К темам", callback_data=f"sec:{section_code}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def instruction_result() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Не помогло, создать заявку", callback_data="instruction:create")], + [InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")], + ] + ) + + +def cancel_creation() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")] + ] + ) + + +def attachment_actions(count: int = 0) -> InlineKeyboardMarkup: + """Кнопки на шаге прикрепления фото: завершить (с любым числом фото) или отменить.""" + done_text = f"✅ Готово ({count})" if count else "✅ Готово (без фото)" + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=done_text, callback_data="att:done")], + [InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")], + ] + ) + + +def duplicate_options(existing_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=f"➕ Дополнить заявку #{existing_id}", callback_data=f"dup:append:{existing_id}")], + [InlineKeyboardButton(text="🆕 Создать новую заявку", callback_data="dup:new")], + [InlineKeyboardButton(text="Отменить создание", callback_data="ticket:cancel")], + ] + ) + + +def user_tickets(tickets: list, status_filter: str = "active") -> InlineKeyboardMarkup: + archive_label = "Архив закрытых" if status_filter == "active" else "Активные заявки" + archive_filter = "closed" if status_filter == "active" else "active" + rows = [ + [ + InlineKeyboardButton( + text=f"#{ticket['id']} · {status_label(ticket['status'])} · {ticket['created_at']}", + callback_data=f"user_ticket:{ticket['id']}:{status_filter}", + ) + ] + for ticket in tickets + ] + rows.append([InlineKeyboardButton(text=archive_label, callback_data=f"user_tickets:{archive_filter}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def back_to_user_tickets(status_filter: str = "active") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Назад к моим заявкам", callback_data=f"user_tickets:{status_filter}")] + ] + ) + + +def user_ticket_actions(ticket_id: int, status: str, status_filter: str = "active") -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + if status not in ("closed", "rejected"): + rows.append( + [InlineKeyboardButton(text="💬 Добавить комментарий", callback_data=f"user_comment:{ticket_id}:{status_filter}")] + ) + rows.append( + [InlineKeyboardButton(text="💬 Чат с поддержкой", callback_data=f"user_chat:{ticket_id}")] + ) + rows.append( + [InlineKeyboardButton(text="✅ Вопрос решён, закрыть", callback_data=f"user_close:{ticket_id}:{status_filter}")] + ) + rows.append([InlineKeyboardButton(text="Назад к моим заявкам", callback_data=f"user_tickets:{status_filter}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def user_comment_cancel(ticket_id: int, status_filter: str = "active") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Отмена", callback_data=f"user_comment_cancel:{ticket_id}:{status_filter}")] + ] + ) + + +def user_close_confirm(ticket_id: int, status_filter: str = "active") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="✅ Да, закрыть заявку", callback_data=f"user_close_yes:{ticket_id}:{status_filter}")], + [InlineKeyboardButton(text="⬅️ Нет, оставить", callback_data=f"user_ticket:{ticket_id}:{status_filter}")], + ] + ) + + +def user_reply_kb(ticket_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="💬 Ответить", callback_data=f"user_chat:{ticket_id}")] + ] + ) + + +def user_chat_controls() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[[KeyboardButton(text="❌ Завершить чат")]], + resize_keyboard=True, + ) + + +def admin_reply_kb(ticket_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="💬 Ответить", callback_data=f"admin:chat:{ticket_id}:open")] + ] + ) + + +# ----------------------------------------------------------------- admin side +FILTER_TITLES = { + "open": "Открытые заявки", + "new": "Новые заявки", + "in_progress": "Заявки в работе", + "waiting": "Ожидают ответа", + "deferred": "Отложенные заявки", + "overdue": "Просроченные по SLA", + "mine": "Мои заявки", + "closed": "Архив (закрытые/отклонённые)", +} + + +def filter_title(list_filter: str) -> str: + return FILTER_TITLES.get(list_filter, "Заявки") + + +def admin_dashboard(stats: dict[str, int]) -> InlineKeyboardMarkup: + def btn(text: str, key: str) -> InlineKeyboardButton: + return InlineKeyboardButton(text=text, callback_data=f"adm:list:{key}") + + return InlineKeyboardMarkup( + inline_keyboard=[ + [btn(f"📋 Все открытые · {stats['open']}", "open")], + [btn(f"🆕 Новые · {stats['new']}", "new"), btn(f"🛠 В работе · {stats['in_progress']}", "in_progress")], + [btn(f"⏳ Ожидание · {stats['waiting']}", "waiting"), btn(f"💤 Отложены · {stats['deferred']}", "deferred")], + [btn(f"🔴 Просрочены · {stats['overdue']}", "overdue"), btn("🙋 Мои", "mine")], + [ + InlineKeyboardButton(text="🔎 Поиск по №", callback_data="adm:find"), + InlineKeyboardButton(text=f"🗄 Архив · {stats['closed']}", callback_data="adm:list:closed"), + ], + ] + ) + + +def open_tickets(tickets: list, list_filter: str, sla_minutes: int) -> InlineKeyboardMarkup: + rows = [] + for ticket in tickets: + mark = " 🔴" if is_overdue(ticket, sla_minutes) else "" + rows.append( + [ + InlineKeyboardButton( + text=f"{priority_emoji(ticket['priority'])} #{ticket['id']} · {ticket['topic']} · {status_label(ticket['status'])}{mark}", + callback_data=f"admin_ticket:{ticket['id']}:{list_filter}", + ) + ] + ) + rows.append([InlineKeyboardButton(text="⬅️ Дашборд", callback_data="adm:dash")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def priority_menu(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup: + rows = [ + [InlineKeyboardButton(text=label, callback_data=f"setprio:{ticket_id}:{level}")] + for level, label in PRIORITY_LABELS.items() + ] + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def assignee_menu(ticket_id: int, admins: list[tuple[int, str]], list_filter: str) -> InlineKeyboardMarkup: + rows = [ + [InlineKeyboardButton(text=f"👤 {name}", callback_data=f"assignto:{ticket_id}:{admin_id}")] + for admin_id, name in admins + ] + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def back_to_ticket(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="⬅️ К заявке", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")] + ] + ) + + +# Быстрые причины отложения заявки («быстрые ответы»). +DEFER_REASONS = [ + "Ожидаем запчасти / поставку", + "Согласуем время визита", + "Ждём ответ от поставщика / вендора", + "Ожидаем информацию от заявителя", + "Передано смежному отделу", +] + + +def defer_reasons_menu(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup: + rows = [ + [InlineKeyboardButton(text=reason, callback_data=f"defrsn:{ticket_id}:{index}:{list_filter}")] + for index, reason in enumerate(DEFER_REASONS) + ] + rows.append([InlineKeyboardButton(text="✏️ Своя причина", callback_data=f"defrsn:{ticket_id}:custom:{list_filter}")]) + rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_ticket:{ticket_id}:{list_filter}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def chat_session_kb(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="❌ Закрыть чат", callback_data=f"admin:chatend:{ticket_id}:{list_filter}")] + ] + ) + + +def admin_cancel(ticket_id: int, list_filter: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Отмена", callback_data=f"adm:back:{ticket_id}:{list_filter}")] + ] + ) + + +def ticket_actions(ticket, viewer_id: int, list_filter: str = "open", with_back: bool = False) -> InlineKeyboardMarkup: + ticket_id = ticket["id"] + status = ticket["status"] + is_holder = ticket["assignee_id"] == viewer_id + + def cb(action: str) -> str: + return f"admin:{action}:{ticket_id}:{list_filter}" + + rows: list[list[InlineKeyboardButton]] = [] + + if status in ("closed", "rejected"): + rows.append([InlineKeyboardButton(text="♻️ Переоткрыть", callback_data=cb("reopen"))]) + else: + if status == "new": + rows.append([InlineKeyboardButton(text="▶️ Взять в работу", callback_data=cb("assign"))]) + elif status in ("waiting", "deferred"): + rows.append([InlineKeyboardButton(text="▶️ Вернуть в работу", callback_data=cb("assign"))]) + elif status == "in_progress" and not is_holder: + rows.append([InlineKeyboardButton(text="✋ Перехватить", callback_data=cb("assign"))]) + + action_row: list[InlineKeyboardButton] = [] + if status != "new" and is_holder: + action_row.append(InlineKeyboardButton(text="✅ Закрыть", callback_data=cb("close"))) + if status == "in_progress": + action_row.append(InlineKeyboardButton(text="⏳ В ожидание", callback_data=cb("wait"))) + if status in ("new", "in_progress"): + action_row.append(InlineKeyboardButton(text="💤 Отложить", callback_data=cb("defer"))) + for i in range(0, len(action_row), 2): + rows.append(action_row[i : i + 2]) + + rows.append( + [ + InlineKeyboardButton(text="🎚 Приоритет", callback_data=cb("prio")), + InlineKeyboardButton(text="👥 Назначить", callback_data=cb("assignto")), + ] + ) + rows.append( + [ + InlineKeyboardButton(text="📝 Заметка", callback_data=cb("note")), + InlineKeyboardButton(text="💬 Чат", callback_data=cb("chat")), + ] + ) + rows.append([InlineKeyboardButton(text="🚫 Отклонить", callback_data=cb("reject"))]) + + rows.append([InlineKeyboardButton(text="🕓 История", callback_data=cb("history"))]) + if with_back: + rows.append([InlineKeyboardButton(text="⬅️ К списку", callback_data=f"adm:list:{list_filter}")]) + + return InlineKeyboardMarkup(inline_keyboard=rows) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..2dd088e --- /dev/null +++ b/bot/main.py @@ -0,0 +1,1700 @@ +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()) diff --git a/bot/states.py b/bot/states.py new file mode 100644 index 0000000..f237a77 --- /dev/null +++ b/bot/states.py @@ -0,0 +1,25 @@ +from aiogram.fsm.state import State, StatesGroup + + +class TicketFlow(StatesGroup): + section = State() + topic = State() + subtopic = State() + description = State() + attachment = State() + duplicate_decision = State() + + +class AdminFlow(StatesGroup): + close_resolution = State() + reject_reason = State() + note_text = State() + user_message = State() + find_number = State() + chat = State() + defer_reason = State() + + +class UserFlow(StatesGroup): + comment = State() + chat = State() diff --git a/bot/taxonomy.py b/bot/taxonomy.py new file mode 100644 index 0000000..214d281 --- /dev/null +++ b/bot/taxonomy.py @@ -0,0 +1,170 @@ +"""Справочник разделов и подтем заявок. + +Источник правды — схема ~/Рабочий стол/древо.drawio. Структура повторяет +дерево решений из этой схемы: SECTIONS — корневые разделы, TOPICS — подтемы +первого уровня, SUBTOPICS — дополнительный уровень для редких случаев. +""" +from __future__ import annotations + +# (code, label) — код хранится в callback_data, label показывается пользователю +# и пишется в БД (поле department). +SECTIONS: list[tuple[str, str]] = [ + ("phone", "Телефония"), + ("call_center", "Коллцентр / Скорозвон"), + ("amocrm", "AmoCRM"), + ("bitrix", "Битрикс"), + ("onec", "1С"), + ("mail", "Почта"), + ("pc", "Не работает ПК / программа"), + ("printer", "Принтер / сканер"), + ("order", "Заказ техники"), + ("employee", "Создание / удаление сотрудника"), + ("other", "Другое"), +] + +TOPICS: dict[str, list[tuple[str, str]]] = { + "phone": [ + ("no_calls", "Нет входящих / исходящих звонков"), + ("bad_sound", "Плохая слышимость / шум"), + ("headset", "Не работает гарнитура"), + ("ext_number", "Добавить / изменить внутренний номер"), + ("sip", "Настройка SIP / softphone"), + ("other", "Другое"), + ], + "call_center": [ + ("no_leads", "Не передаются лиды в CRM"), + ("new_script", "Нужно создать новый сценарий"), + ("edit_script", "Изменить сценарий"), + ("headset", "Не работает гарнитура в Скорозвоне"), + ("connect_emp", "Подключить сотрудника к Скорозвону"), + ("other", "Другое"), + ], + "amocrm": [ + ("no_login", "Не получается зайти в аккаунт"), + ("no_notify", "Не приходят уведомления"), + ("no_contact", "Не отображается контакт"), + ("no_funnel", "Не отображается воронка"), + ("create_lead", "Создать пустые карточки / лид"), + ("no_call", "Нет звонка"), + ("no_chat", "Нет переписки"), + ("no_tasks", "Не создаются задачи"), + ("other", "Другое"), + ], + "bitrix": [ + ("portal_down", "Не работает портал"), + ("no_funnel", "Не отображается воронка"), + ("crm_error", "Ошибка CRM"), + ("bp_broken", "Не работает бизнес-процесс"), + ("lead_route", "Проблема с распределением лидов"), + ("no_notify", "Не приходят уведомления"), + ("no_fields", "Не отображаются поля"), + ("no_deals", "Не отображаются сделки"), + ("other", "Другое"), + ], + "onec": [ + ("no_start", "Не запускается"), + ("no_access", "Нет доступа"), + ("db_error", "Ошибка базы"), + ("slow", "Медленно работает"), + ("sync_error", "Ошибка синхронизации"), + ("no_print", "Не печатает документы"), + ("user_setup", "Нужна настройка пользователя"), + ("other", "Другое"), + ], + "mail": [ + ("send_recv", "Не приходят / не отправляются письма"), + ("forgot_pass", "Забыт пароль"), + ("setup", "Настройка почты"), + ("spam", "Спам / вирусное письмо"), + ("attachments", "Проблема с вложениями"), + ("other", "Другое"), + ], + "pc": [ + ("no_power", "ПК не включается"), + ("no_internet", "Нет интернета"), + ("slow", "Медленно работает"), + ("app_no_start", "Не запускается программа"), + ("rdp", "Не работает RDP"), + ("av_devices", "Нет звука / микрофона / камеры"), + ("files_access", "Проблема с файлами / доступом"), + ("install", "Нужна установка / обновление ПО"), + ("other", "Другое"), + ], + "printer": [ + ("no_print", "Не печатает"), + ("no_scan", "Не сканирует"), + ("paper_jam", "Замятие бумаги"), + ("no_conn", "Нет подключения"), + ("no_toner", "Закончился тонер / краска"), + ("print_errors", "Печатает с ошибками"), + ("other", "Другое"), + ], + "order": [ + ("laptop", "Ноутбук"), + ("aio", "Моноблок"), + ("headset", "Гарнитура"), + ("phone", "Телефон"), + ("peripherals", "Клавиатура / мышь / периферия"), + ("other", "Другое"), + ], + "employee": [ + ("create", "Создать сотрудника"), + ("remove", "Удалить сотрудника"), + ("grant", "Выдать доступы"), + ("revoke", "Заблокировать доступы"), + ("mail", "Создать почту"), + ("setup_place", "Настроить рабочее место"), + ("change_dept", "Изменить отдел / должность"), + ("other", "Другое"), + ], + "other": [ + ("manual", "Описать проблему вручную"), + ("consult", "Консультация"), + ("attach", "Прикрепить файл / скриншот"), + ("specialist", "Связаться со специалистом"), + ], +} + +# Третий уровень: уточнение для конкретной подтемы. В схеме сейчас есть только +# один такой случай — «Нет звонков» в Телефонии: уточняем в какой CRM. +SUBTOPICS: dict[tuple[str, str], list[tuple[str, str]]] = { + ("phone", "no_calls"): [ + ("amocrm", "AmoCRM"), + ("info_hub", "Info-hub"), + ("crm", "CRM"), + ], +} + +# Инструкции, которые показываем пользователю перед созданием заявки. +# Ключ — (section_code, topic_code). Структура готова, тексты впишутся отдельно. +# Если записи нет — после выбора темы сразу идём дальше (без шага инструкции). +INSTRUCTIONS: dict[tuple[str, str], str] = {} + + +def section_label(code: str) -> str: + for c, label in SECTIONS: + if c == code: + return label + return code + + +def topic_label(section_code: str, topic_code: str) -> str: + for c, label in TOPICS.get(section_code, []): + if c == topic_code: + return label + return topic_code + + +def subtopic_label(section_code: str, topic_code: str, subtopic_code: str) -> str: + for c, label in SUBTOPICS.get((section_code, topic_code), []): + if c == subtopic_code: + return label + return subtopic_code + + +def has_subtopics(section_code: str, topic_code: str) -> bool: + return bool(SUBTOPICS.get((section_code, topic_code))) + + +def instruction_for(section_code: str, topic_code: str) -> str | None: + return INSTRUCTIONS.get((section_code, topic_code)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1bfcbc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + ticket-bot: + build: . + container_name: tg-ticket-bot + restart: unless-stopped + env_file: + - .env + volumes: + - ./data:/app/data diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..821675c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aiogram==3.7.0