.
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