This commit is contained in:
tikeev.k
2026-06-01 17:38:44 +03:00
commit 4548a57f83
14 changed files with 2989 additions and 0 deletions

5
.env.backup Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
.env
data/
__pycache__/
*.pyc
.venv/

15
Dockerfile Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
"""Telegram ticket bot package."""

56
bot/config.py Normal file
View 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
View 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:0018: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
View 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

File diff suppressed because it is too large Load Diff

25
bot/states.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
aiogram==3.7.0