.
This commit is contained in:
5
.env.backup
Normal file
5
.env.backup
Normal file
@@ -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
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -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"]
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@@ -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 и приоритет на уровне отдела/темы.
|
||||||
|
- Отчёты и выгрузку статистики по исполнителям.
|
||||||
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Telegram ticket bot package."""
|
||||||
56
bot/config.py
Normal file
56
bot/config.py
Normal file
@@ -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")),
|
||||||
|
)
|
||||||
514
bot/db.py
Normal file
514
bot/db.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
361
bot/keyboards.py
Normal file
361
bot/keyboards.py
Normal file
@@ -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)
|
||||||
1700
bot/main.py
Normal file
1700
bot/main.py
Normal file
File diff suppressed because it is too large
Load Diff
25
bot/states.py
Normal file
25
bot/states.py
Normal file
@@ -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()
|
||||||
170
bot/taxonomy.py
Normal file
170
bot/taxonomy.py
Normal file
@@ -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))
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
ticket-bot:
|
||||||
|
build: .
|
||||||
|
container_name: tg-ticket-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
aiogram==3.7.0
|
||||||
Reference in New Issue
Block a user