This commit is contained in:
dv 2026-03-26 00:25:37 +03:00
commit 074ca16f7c
54 changed files with 2131 additions and 0 deletions

12
.env Normal file
View file

@ -0,0 +1,12 @@
MAX_BOT_TOKEN=f9LHodD0cOJHqFlGFuM7t9dFSt0jA36NxLc5aSP0CwDuCyWzssGKsFwuP4ymzoP64uqe6Ivl4_bEWKAOl6MI
# При локальном запуске (python main.py):
DB_PATH=support.db
# При запуске через Docker DB_PATH переопределяется в docker-compose.yml
# и указывает на /app/data/support.db (volume ./data/)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
ADMIN_HOST=0.0.0.0
ADMIN_PORT=8080

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
MAX_BOT_TOKEN=your_token_here
# При локальном запуске (python main.py):
DB_PATH=support.db
# При запуске через Docker DB_PATH переопределяется в docker-compose.yml
# и указывает на /app/data/support.db (volume ./data/)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
ADMIN_HOST=0.0.0.0
ADMIN_PORT=8080

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Устанавливаем maxapi из локального источника
# (build context = родительская папка caps/)
COPY maxapi-main/ /maxapi-main/
RUN pip install --no-cache-dir /maxapi-main/
# Устанавливаем зависимости приложения
COPY support_bot/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY support_bot/ .
# Папка для SQLite-файла
RUN mkdir -p /app/data
EXPOSE 8080
CMD ["python", "main.py"]

0
admin/__init__.py Normal file
View file

20
admin/app.py Normal file
View file

@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from admin.routes import organizations, org_users, bot_users, tickets, chat_log
def create_admin_app() -> FastAPI:
app = FastAPI(title="Support Bot Admin")
@app.get("/admin/")
async def root():
return RedirectResponse("/admin/orgs")
app.include_router(organizations.router, prefix="/admin")
app.include_router(org_users.router, prefix="/admin")
app.include_router(bot_users.router, prefix="/admin")
app.include_router(tickets.router, prefix="/admin")
app.include_router(chat_log.router, prefix="/admin")
return app

25
admin/auth.py Normal file
View file

@ -0,0 +1,25 @@
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import config
security = HTTPBasic()
def require_admin(
credentials: HTTPBasicCredentials = Depends(security),
) -> None:
ok_user = secrets.compare_digest(
credentials.username.encode(), config.ADMIN_USERNAME.encode()
)
ok_pass = secrets.compare_digest(
credentials.password.encode(), config.ADMIN_PASSWORD.encode()
)
if not (ok_user and ok_pass):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Basic"},
)

0
admin/routes/__init__.py Normal file
View file

41
admin/routes/bot_users.py Normal file
View file

@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin
from admin.templates_env import templates
from database.queries.bot_users import get_all_bot_users, unblock_bot_user
from database.queries.organizations import get_all_organizations
router = APIRouter(prefix="/bot-users", dependencies=[Depends(require_admin)])
@router.get("", response_class=HTMLResponse)
async def list_bot_users(
request: Request,
is_authorized: int | None = None,
is_blocked: int | None = None,
org_id: int | None = None,
):
users = await get_all_bot_users(
is_authorized=is_authorized,
is_blocked=is_blocked,
org_id=org_id,
)
orgs = await get_all_organizations()
return templates.TemplateResponse(
"bot_users/list.html",
{
"request": request,
"users": users,
"orgs": orgs,
"filter_authorized": is_authorized,
"filter_blocked": is_blocked,
"filter_org_id": org_id,
},
)
@router.post("/{user_id}/unblock")
async def unblock_user(user_id: int, org_id: int = Form(...)):
await unblock_bot_user(user_id=user_id, org_id=org_id)
return RedirectResponse("/admin/bot-users", status_code=303)

25
admin/routes/chat_log.py Normal file
View file

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from admin.auth import require_admin
from admin.templates_env import templates
from database.queries.message_log import get_chat_list, get_message_log
router = APIRouter(prefix="/chat-log", dependencies=[Depends(require_admin)])
@router.get("", response_class=HTMLResponse)
async def chat_list(request: Request):
chats = await get_chat_list()
return templates.TemplateResponse(
"chat_log/list.html", {"request": request, "chats": chats}
)
@router.get("/{max_user_id}", response_class=HTMLResponse)
async def chat_history(request: Request, max_user_id: int):
messages = await get_message_log(max_user_id=max_user_id, limit=200)
return templates.TemplateResponse(
"chat_log/history.html",
{"request": request, "messages": messages, "max_user_id": max_user_id},
)

63
admin/routes/org_users.py Normal file
View file

@ -0,0 +1,63 @@
import re
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin
from admin.templates_env import templates
from database.queries.org_users import (
create_org_user,
delete_org_user,
get_org_users,
)
from database.queries.organizations import get_all_organizations
import external.client as ext
router = APIRouter(prefix="/org-users", dependencies=[Depends(require_admin)])
def normalize_phone(raw: str) -> str:
digits = re.sub(r"\D", "", raw)
if digits.startswith("8") and len(digits) == 11:
digits = "7" + digits[1:]
return digits
@router.get("", response_class=HTMLResponse)
async def list_org_users(request: Request, org_id: int | None = None):
users = await get_org_users(org_id=org_id)
orgs = await get_all_organizations()
return templates.TemplateResponse(
"org_users/list.html",
{"request": request, "users": users, "orgs": orgs, "filter_org_id": org_id},
)
@router.get("/create", response_class=HTMLResponse)
async def create_org_user_form(request: Request):
orgs = await get_all_organizations()
return templates.TemplateResponse(
"org_users/create.html", {"request": request, "orgs": orgs}
)
@router.post("/create")
async def create_org_user_action(
org_id: int = Form(...),
phone: str = Form(...),
display_name: str = Form(""),
):
phone = normalize_phone(phone)
row_id = await create_org_user(
org_id=org_id,
phone=phone,
display_name=display_name or None,
)
ext.sync_user(row_id)
return RedirectResponse("/admin/org-users", status_code=303)
@router.post("/{user_id}/delete")
async def delete_org_user_action(user_id: int):
await delete_org_user(user_id)
return RedirectResponse("/admin/org-users", status_code=303)

View file

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin
from admin.templates_env import templates
from database.queries.organizations import (
create_organization,
delete_organization,
get_all_organizations,
get_organization,
update_organization,
)
import external.client as ext
router = APIRouter(prefix="/orgs", dependencies=[Depends(require_admin)])
@router.get("", response_class=HTMLResponse)
async def list_orgs(request: Request):
orgs = await get_all_organizations()
return templates.TemplateResponse(
"orgs/list.html", {"request": request, "orgs": orgs}
)
@router.get("/create", response_class=HTMLResponse)
async def create_org_form(request: Request):
return templates.TemplateResponse("orgs/create.html", {"request": request})
@router.post("/create")
async def create_org(name: str = Form(...)):
org_id = await create_organization(name)
ext.sync_org(org_id)
return RedirectResponse("/admin/orgs", status_code=303)
@router.get("/{org_id}/edit", response_class=HTMLResponse)
async def edit_org_form(request: Request, org_id: int):
org = await get_organization(org_id)
return templates.TemplateResponse(
"orgs/edit.html", {"request": request, "org": org}
)
@router.post("/{org_id}/edit")
async def edit_org(org_id: int, name: str = Form(...)):
await update_organization(org_id, name)
return RedirectResponse("/admin/orgs", status_code=303)
@router.post("/{org_id}/delete")
async def delete_org(org_id: int):
await delete_organization(org_id)
return RedirectResponse("/admin/orgs", status_code=303)

54
admin/routes/tickets.py Normal file
View file

@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.auth import require_admin
from admin.templates_env import templates
from database.queries.ticket_attachments import get_attachments
from database.queries.tickets import get_all_tickets, get_ticket, update_ticket_status
from database.queries.organizations import get_all_organizations
router = APIRouter(prefix="/tickets", dependencies=[Depends(require_admin)])
@router.get("", response_class=HTMLResponse)
async def list_tickets(
request: Request,
org_id: int | None = None,
status: str | None = None,
):
tickets = await get_all_tickets(org_id=org_id, status=status)
orgs = await get_all_organizations()
return templates.TemplateResponse(
"tickets/list.html",
{
"request": request,
"tickets": tickets,
"orgs": orgs,
"filter_org_id": org_id,
"filter_status": status,
},
)
@router.get("/{ticket_id}", response_class=HTMLResponse)
async def ticket_detail(request: Request, ticket_id: int):
ticket = await get_ticket(ticket_id)
attachments = await get_attachments(ticket_id)
return templates.TemplateResponse(
"tickets/detail.html",
{"request": request, "ticket": ticket, "attachments": attachments},
)
@router.post("/{ticket_id}/status")
async def update_status(
ticket_id: int,
status: str = Form(...),
last_comment: str = Form(""),
):
await update_ticket_status(
ticket_id=ticket_id,
status=status,
last_comment=last_comment or None,
)
return RedirectResponse(f"/admin/tickets/{ticket_id}", status_code=303)

64
admin/templates/base.html Normal file
View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Support Bot Admin{% endblock %}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #333; }
nav {
background: #1a73e8; color: white; padding: 12px 24px;
display: flex; align-items: center; gap: 24px;
}
nav a { color: white; text-decoration: none; font-weight: 500; }
nav a:hover { text-decoration: underline; }
nav .brand { font-size: 1.2rem; font-weight: 700; margin-right: 16px; }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
h1 { font-size: 1.5rem; margin-bottom: 16px; }
h2 { font-size: 1.2rem; margin-bottom: 12px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 0.9rem; }
th { background: #f8f8f8; font-weight: 600; }
tr:hover td { background: #fafafa; }
.btn { display: inline-block; padding: 7px 14px; border-radius: 5px; font-size: 0.85rem; font-weight: 500; cursor: pointer; border: none; text-decoration: none; }
.btn-primary { background: #1a73e8; color: white; }
.btn-primary:hover { background: #1557b0; }
.btn-danger { background: #d93025; color: white; }
.btn-danger:hover { background: #b31412; }
.btn-secondary { background: #eee; color: #333; }
.btn-secondary:hover { background: #ddd; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.badge-open { background: #fce8e6; color: #d93025; }
.badge-in_progress { background: #fef7e0; color: #b06000; }
.badge-closed { background: #e6f4ea; color: #137333; }
.badge-auth { background: #e6f4ea; color: #137333; }
.badge-blocked { background: #fce8e6; color: #d93025; }
.badge-pending { background: #f1f3f4; color: #666; }
form { display: inline; }
.form-row { margin-bottom: 14px; }
label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 4px; }
input[type=text], input[type=number], select, textarea {
width: 100%; padding: 8px 10px; border: 1px solid #ccc;
border-radius: 5px; font-size: 0.9rem; font-family: inherit;
}
textarea { min-height: 80px; resize: vertical; }
.filter-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.filter-bar select, .filter-bar input { width: auto; }
</style>
</head>
<body>
<nav>
<span class="brand">🤖 Support Admin</span>
<a href="/admin/orgs">Организации</a>
<a href="/admin/org-users">Клиенты (белый список)</a>
<a href="/admin/bot-users">Пользователи бота</a>
<a href="/admin/tickets">Заявки</a>
<a href="/admin/chat-log">Чаты</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Пользователи бота{% endblock %}
{% block content %}
<h1>Пользователи бота</h1>
<div class="card">
<div class="filter-bar">
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap">
<select name="is_authorized" onchange="this.form.submit()">
<option value="">Все статусы</option>
<option value="1" {% if filter_authorized == 1 %}selected{% endif %}>Авторизованные</option>
<option value="0" {% if filter_authorized == 0 %}selected{% endif %}>Не авторизованные</option>
</select>
<select name="is_blocked" onchange="this.form.submit()">
<option value="">Блокировка: все</option>
<option value="1" {% if filter_blocked == 1 %}selected{% endif %}>Заблокированные</option>
<option value="0" {% if filter_blocked == 0 %}selected{% endif %}>Не заблокированные</option>
</select>
<select name="org_id" onchange="this.form.submit()">
<option value="">Все организации</option>
{% for org in orgs %}
<option value="{{ org.id }}" {% if filter_org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
</form>
</div>
<table>
<thead>
<tr>
<th>#</th><th>MAX ID</th><th>Имя</th><th>Телефон</th>
<th>Организация</th><th>Статус</th><th>Дата</th><th>Разблокировать</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.max_user_id }}</td>
<td>{{ u.first_name or u.username or '—' }}</td>
<td>{{ u.phone or '—' }}</td>
<td>{{ u.org_name or '—' }}</td>
<td>
{% if u.is_blocked %}
<span class="badge badge-blocked">Заблокирован</span>
{% elif u.is_authorized %}
<span class="badge badge-auth">Авторизован</span>
{% else %}
<span class="badge badge-pending">Ожидает</span>
{% endif %}
</td>
<td>{{ u.created_at[:16] }}</td>
<td>
{% if u.is_blocked or not u.is_authorized %}
<form action="/admin/bot-users/{{ u.id }}/unblock" method="post"
style="display:flex;gap:6px;align-items:center">
<select name="org_id" required style="width:auto">
<option value="">орг</option>
{% for org in orgs %}
<option value="{{ org.id }}" {% if u.org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">Разблокировать</button>
</form>
{% else %}
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" style="text-align:center;color:#999">Нет пользователей</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}История чата {{ max_user_id }}{% endblock %}
{% block content %}
<h1>История чата</h1>
<a href="/admin/chat-log" class="btn btn-secondary" style="margin-bottom:16px;display:inline-block">К списку чатов</a>
<div class="card">
<div style="display:flex;flex-direction:column;gap:10px">
{% for msg in messages|reverse %}
<div style="display:flex;{% if msg.direction == 'out' %}justify-content:flex-end{% endif %}">
<div style="
max-width:70%;
padding:10px 14px;
border-radius:12px;
font-size:.9rem;
line-height:1.5;
{% if msg.direction == 'out' %}
background:#1a73e8;color:white;border-bottom-right-radius:2px;
{% else %}
background:#f1f3f4;color:#333;border-bottom-left-radius:2px;
{% endif %}
">
{% if msg.text %}<div>{{ msg.text }}</div>{% endif %}
{% if msg.attachment_json and msg.attachment_json != 'null' %}
<div style="font-size:.8rem;opacity:.8;margin-top:4px">📎 Вложение</div>
{% endif %}
<div style="font-size:.75rem;opacity:.7;margin-top:4px;text-align:right">
{{ msg.created_at[:16] }}
{% if msg.direction == 'in' %} · входящее{% else %} · исходящее{% endif %}
</div>
</div>
</div>
{% else %}
<p style="color:#999;text-align:center">История пуста</p>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Чаты{% endblock %}
{% block content %}
<h1>Чаты с клиентами</h1>
<div class="card">
<table>
<thead>
<tr><th>Пользователь</th><th>MAX ID</th><th>Последнее сообщение</th><th>Время</th><th></th></tr>
</thead>
<tbody>
{% for c in chats %}
<tr>
<td>{{ c.first_name or c.username or '—' }}</td>
<td>{{ c.max_user_id }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ c.text or '[вложение]' }}
</td>
<td>{{ c.created_at[:16] }}</td>
<td><a href="/admin/chat-log/{{ c.max_user_id }}" class="btn btn-secondary">История</a></td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align:center;color:#999">Сообщений нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Добавить клиента{% endblock %}
{% block content %}
<h1>Добавить клиента в белый список</h1>
<div class="card" style="max-width:480px">
<form action="/admin/org-users/create" method="post">
<div class="form-row">
<label>Организация</label>
<select name="org_id" required>
<option value="">— выберите —</option>
{% for org in orgs %}
<option value="{{ org.id }}">{{ org.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<label>Телефон</label>
<input type="text" name="phone" required placeholder="+7 900 123-45-67">
<small style="color:#666">Будет нормализован автоматически</small>
</div>
<div class="form-row">
<label>Имя (необязательно)</label>
<input type="text" name="display_name" placeholder="Иван Иванов">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" type="submit">Добавить</button>
<a href="/admin/org-users" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Клиенты (белый список){% endblock %}
{% block content %}
<h1>Клиенты (белый список телефонов)</h1>
<div class="card">
<div class="filter-bar">
<form method="get">
<select name="org_id" onchange="this.form.submit()">
<option value="">Все организации</option>
{% for org in orgs %}
<option value="{{ org.id }}" {% if filter_org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
</form>
<a href="/admin/org-users/create" class="btn btn-primary">+ Добавить</a>
</div>
<table>
<thead><tr><th>#</th><th>Организация</th><th>Телефон</th><th>Имя</th><th>Добавлен</th><th>Действия</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.org_name }}</td>
<td>{{ u.phone }}</td>
<td>{{ u.display_name or '—' }}</td>
<td>{{ u.created_at[:16] }}</td>
<td>
<form action="/admin/org-users/{{ u.id }}/delete" method="post"
onsubmit="return confirm('Удалить {{ u.phone }}?')">
<button class="btn btn-danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="text-align:center;color:#999">Список пуст</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Новая организация{% endblock %}
{% block content %}
<h1>Новая организация</h1>
<div class="card" style="max-width:480px">
<form action="/admin/orgs/create" method="post">
<div class="form-row">
<label>Название организации</label>
<input type="text" name="name" required placeholder="ООО Ромашка">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" type="submit">Создать</button>
<a href="/admin/orgs" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Редактировать организацию{% endblock %}
{% block content %}
<h1>Редактировать организацию</h1>
<div class="card" style="max-width:480px">
<form action="/admin/orgs/{{ org.id }}/edit" method="post">
<div class="form-row">
<label>Название организации</label>
<input type="text" name="name" required value="{{ org.name }}">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" type="submit">Сохранить</button>
<a href="/admin/orgs" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Организации{% endblock %}
{% block content %}
<h1>Организации</h1>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<span>Всего: {{ orgs|length }}</span>
<a href="/admin/orgs/create" class="btn btn-primary">+ Добавить</a>
</div>
<table>
<thead><tr><th>#</th><th>Название</th><th>Создана</th><th>Действия</th></tr></thead>
<tbody>
{% for org in orgs %}
<tr>
<td>{{ org.id }}</td>
<td>{{ org.name }}</td>
<td>{{ org.created_at[:16] }}</td>
<td>
<a href="/admin/orgs/{{ org.id }}/edit" class="btn btn-secondary">Изменить</a>
<form action="/admin/orgs/{{ org.id }}/delete" method="post"
onsubmit="return confirm('Удалить организацию {{ org.name }}?')">
<button class="btn btn-danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;color:#999">Организаций нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Заявка #{{ ticket.id }}{% endblock %}
{% block content %}
<h1>Заявка #{{ ticket.id }}</h1>
<a href="/admin/tickets" class="btn btn-secondary" style="margin-bottom:16px;display:inline-block">К списку</a>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:16px">
<div>
<div class="card">
<h2>Описание</h2>
<p style="white-space:pre-wrap;line-height:1.6">{{ ticket.description }}</p>
</div>
{% if attachments %}
<div class="card">
<h2>Вложения</h2>
<ul style="list-style:none;display:flex;flex-direction:column;gap:8px">
{% for a in attachments %}
<li>
{% if 'IMAGE' in a.attachment_type %}🖼
{% elif 'VIDEO' in a.attachment_type %}🎬
{% elif 'AUDIO' in a.attachment_type %}🎙
{% else %}📎{% endif %}
{{ a.attachment_type.split('.')[-1] }}
{% if a.attachment_url %}
<a href="{{ a.attachment_url }}" target="_blank">Открыть</a>
{% endif %}
<span style="color:#999;font-size:.8rem">{{ a.created_at[:16] }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if ticket.last_comment %}
<div class="card">
<h2>Последний комментарий</h2>
<p style="white-space:pre-wrap;line-height:1.6">{{ ticket.last_comment }}</p>
</div>
{% endif %}
</div>
<div>
<div class="card">
<h2>Сведения</h2>
<table style="width:100%">
<tr><td style="color:#666;font-size:.85rem">Организация</td><td>{{ ticket.org_name }}</td></tr>
<tr><td style="color:#666;font-size:.85rem">Автор</td><td>{{ ticket.author_name or ticket.author_username or '—' }}</td></tr>
<tr><td style="color:#666;font-size:.85rem">Создана</td><td>{{ ticket.created_at[:16] }}</td></tr>
<tr><td style="color:#666;font-size:.85rem">Обновлена</td><td>{{ ticket.updated_at[:16] }}</td></tr>
<tr>
<td style="color:#666;font-size:.85rem">Статус</td>
<td>
<span class="badge badge-{{ ticket.status }}">
{% if ticket.status == 'open' %}Открыта
{% elif ticket.status == 'in_progress' %}В работе
{% else %}Закрыта{% endif %}
</span>
</td>
</tr>
</table>
</div>
<div class="card">
<h2>Обновить статус</h2>
<form action="/admin/tickets/{{ ticket.id }}/status" method="post">
<div class="form-row">
<label>Новый статус</label>
<select name="status">
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Открыта</option>
<option value="in_progress" {% if ticket.status == 'in_progress' %}selected{% endif %}>В работе</option>
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Закрыта</option>
</select>
</div>
<div class="form-row">
<label>Комментарий</label>
<textarea name="last_comment" placeholder="Необязательно">{{ ticket.last_comment or '' }}</textarea>
</div>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Заявки{% endblock %}
{% block content %}
<h1>Заявки</h1>
<div class="card">
<div class="filter-bar">
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap">
<select name="org_id" onchange="this.form.submit()">
<option value="">Все организации</option>
{% for org in orgs %}
<option value="{{ org.id }}" {% if filter_org_id == org.id %}selected{% endif %}>{{ org.name }}</option>
{% endfor %}
</select>
<select name="status" onchange="this.form.submit()">
<option value="">Все статусы</option>
<option value="open" {% if filter_status == 'open' %}selected{% endif %}>Открыта</option>
<option value="in_progress" {% if filter_status == 'in_progress' %}selected{% endif %}>В работе</option>
<option value="closed" {% if filter_status == 'closed' %}selected{% endif %}>Закрыта</option>
</select>
</form>
<span style="color:#666;font-size:.9rem">Всего: {{ tickets|length }}</span>
</div>
<table>
<thead>
<tr><th>#</th><th>Организация</th><th>Автор</th><th>Описание</th><th>Статус</th><th>Создана</th><th></th></tr>
</thead>
<tbody>
{% for t in tickets %}
<tr>
<td>{{ t.id }}</td>
<td>{{ t.org_name }}</td>
<td>{{ t.author_name or '—' }}</td>
<td>{{ (t.description or '')[:60] }}{% if (t.description or '')|length > 60 %}…{% endif %}</td>
<td>
<span class="badge badge-{{ t.status }}">
{% if t.status == 'open' %}Открыта
{% elif t.status == 'in_progress' %}В работе
{% else %}Закрыта{% endif %}
</span>
</td>
<td>{{ t.created_at[:16] }}</td>
<td><a href="/admin/tickets/{{ t.id }}" class="btn btn-secondary">Открыть</a></td>
</tr>
{% else %}
<tr><td colspan="7" style="text-align:center;color:#999">Заявок нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

5
admin/templates_env.py Normal file
View file

@ -0,0 +1,5 @@
from pathlib import Path
from fastapi.templating import Jinja2Templates
# Один общий экземпляр с абсолютным путём — избегает конфликта кеша Jinja2
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))

0
bot/__init__.py Normal file
View file

17
bot/filters.py Normal file
View file

@ -0,0 +1,17 @@
from typing import Any
from maxapi.filters.filter import BaseFilter
from maxapi.types.updates.message_callback import MessageCallback
class PayloadStartsWith(BaseFilter):
"""Фильтр callback-событий по префиксу payload."""
def __init__(self, prefix: str) -> None:
self.prefix = prefix
async def __call__(self, event: Any) -> bool:
if not isinstance(event, MessageCallback):
return False
payload = event.callback.payload or ""
return payload.startswith(self.prefix)

0
bot/handlers/__init__.py Normal file
View file

View file

@ -0,0 +1,180 @@
"""
Обработчики создания заявки.
Поток:
1. Нажатие "Создать заявку" переход в waiting_description
2. Первое сообщение с текстом сохранение описания, переход в collecting_attachments
3. Дополнительные сообщения накопление вложений
4. Нажатие "Готово" сохранение заявки в БД, подтверждение
"""
import asyncio
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.enums.attachment import AttachmentType
from maxapi.filters import F
from maxapi.types.attachments.attachment import (
OtherAttachmentPayload,
PhotoAttachmentPayload,
)
from maxapi.types.updates.message_callback import MessageCallback
from maxapi.types.updates.message_created import MessageCreated
from bot.keyboards import done_ticket_kb, main_menu_kb
from bot.states import TicketStates
from database.queries.ticket_attachments import add_attachment
from database.queries.tickets import create_ticket
import external.client as ext
router = Router(router_id="create_ticket")
_MEDIA_TYPES = {
AttachmentType.IMAGE,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE,
}
def _extract_attachments(event: MessageCreated) -> list[dict]:
"""Извлекает метаданные вложений из сообщения."""
result = []
body = event.message.body
if not body or not body.attachments:
return result
for att in body.attachments:
att_type = getattr(att, "type", None)
if att_type not in _MEDIA_TYPES:
continue
url = None
payload = getattr(att, "payload", None)
if isinstance(payload, (PhotoAttachmentPayload, OtherAttachmentPayload)):
url = payload.url
result.append({"type": str(att_type), "url": url})
return result
@router.message_callback(F.callback.payload == "create_ticket")
async def cb_create_ticket(
event: MessageCallback, context: BaseContext
) -> None:
await context.set_state(TicketStates.waiting_description)
await context.set_data({"pending_attachments": []})
await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await event.bot.send_message(
chat_id=chat_id,
text=(
"📝 Опишите вашу проблему.\n\n"
"Вы также можете прикрепить файлы, фото или голосовые сообщения.\n"
"Когда закончите — нажмите кнопку «Готово»."
),
attachments=[done_ticket_kb()],
)
@router.message_created(states=TicketStates.waiting_description)
async def on_ticket_description(
event: MessageCreated, context: BaseContext
) -> None:
chat_id, _ = event.get_ids()
if chat_id is None:
return
body = event.message.body
text = (body.text or "").strip() if body else ""
attachments = _extract_attachments(event)
await context.update_data(
description=text,
pending_attachments=attachments,
)
await context.set_state(TicketStates.collecting_attachments)
await event.bot.send_message(
chat_id=chat_id,
text=(
"✏️ Описание принято. Вы можете добавить ещё файлы "
"или нажмите «Готово» для отправки заявки."
),
attachments=[done_ticket_kb()],
)
@router.message_created(states=TicketStates.collecting_attachments)
async def on_ticket_attachment(
event: MessageCreated, context: BaseContext
) -> None:
data = await context.get_data()
body = event.message.body
extra_text = (body.text or "").strip() if body else ""
new_atts = _extract_attachments(event)
if extra_text:
current = data.get("description", "")
data["description"] = (current + "\n" + extra_text).strip()
pending = data.get("pending_attachments", [])
pending.extend(new_atts)
data["pending_attachments"] = pending
await context.set_data(data)
@router.message_callback(
F.callback.payload == "done_ticket",
states=[TicketStates.waiting_description, TicketStates.collecting_attachments],
)
async def cb_done_ticket(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
data = await context.get_data()
description = (data.get("description") or "").strip()
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
if not description:
await event.answer(notification="Сначала опишите проблему!")
await event.bot.send_message(
chat_id=chat_id,
text="❗ Пожалуйста, сначала напишите описание проблемы.",
attachments=[done_ticket_kb()],
)
return
ticket_id = await create_ticket(
org_id=bot_user["org_id"],
created_by=bot_user["id"],
description=description,
)
for att in data.get("pending_attachments", []):
await add_attachment(
ticket_id=ticket_id,
attachment_type=att["type"],
attachment_url=att.get("url"),
)
await context.clear()
await event.answer(notification="Заявка создана!")
await event.bot.send_message(
chat_id=chat_id,
text=(
f"✅ Заявка №{ticket_id} успешно создана!\n"
"Наши специалисты свяжутся с вами в ближайшее время."
),
attachments=[main_menu_kb()],
)
# Fire-and-forget: отправка во внешнюю систему
loop = asyncio.get_event_loop()
asyncio.create_task(
loop.run_in_executor(None, ext.sync_ticket, ticket_id)
)

25
bot/handlers/menu.py Normal file
View file

@ -0,0 +1,25 @@
"""Обработчик главного меню."""
from maxapi.dispatcher import Router
from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
from maxapi.context import BaseContext
from bot.keyboards import main_menu_kb
router = Router(router_id="menu")
@router.message_callback(F.callback.payload == "main_menu")
async def cb_main_menu(event: MessageCallback, context: BaseContext) -> None:
await context.set_state(None)
await event.answer(notification="")
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
await event.bot.send_message(
chat_id=chat_id,
text="Главное меню:",
attachments=[main_menu_kb()],
)

201
bot/handlers/start.py Normal file
View file

@ -0,0 +1,201 @@
"""
Обработчики авторизации:
- bot_started первый запуск / перезапуск
- /start команда
- Шаринг контакта
"""
import re
from maxapi.dispatcher import Router
from maxapi.filters import F
from maxapi.filters.contact import ContactFilter
from maxapi.types.attachments.attachment import ContactAttachmentPayload
from maxapi.types.attachments.contact import Contact as ContactAttachment
from maxapi.types.updates.bot_started import BotStarted
from maxapi.types.updates.message_created import MessageCreated
from maxapi.context import BaseContext
from maxapi.filters.command import Command
from bot.keyboards import contact_request_kb, main_menu_kb
from bot.states import AuthStates
from database.queries.bot_users import (
authorize_bot_user,
block_bot_user,
get_bot_user_by_max_id,
upsert_bot_user,
)
from database.queries.org_users import find_by_phone
from database.queries.message_log import log_message
router = Router(router_id="start")
def normalize_phone(raw: str) -> str:
"""Убираем всё кроме цифр, заменяем ведущую 8 → 7."""
digits = re.sub(r"\D", "", raw)
if digits.startswith("8") and len(digits) == 11:
digits = "7" + digits[1:]
return digits
@router.bot_started()
async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
user = event.user
bot_user = await upsert_bot_user(
max_user_id=user.user_id,
max_chat_id=event.chat_id,
username=getattr(user, "username", None),
first_name=getattr(user, "name", None),
)
if bot_user["is_blocked"]:
await event.bot.send_message(
chat_id=event.chat_id,
text=(
"⛔ Ваш номер не найден в системе. Доступ заблокирован.\n"
"Обратитесь к администратору для подключения."
),
)
return
if bot_user["is_authorized"]:
await context.set_state(None)
await event.bot.send_message(
chat_id=event.chat_id,
text="👋 С возвращением! Выберите действие:",
attachments=[main_menu_kb()],
)
return
# Новый пользователь — запрашиваем контакт
await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message(
chat_id=event.chat_id,
text=(
"👋 Добро пожаловать в систему технической поддержки!\n\n"
"Для входа нажмите кнопку ниже и поделитесь своим номером телефона."
),
attachments=[contact_request_kb()],
)
@router.message_created(Command("start"))
async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
chat_id, user_id = event.get_ids()
if user_id is None or chat_id is None:
return
bot_user = await get_bot_user_by_max_id(user_id)
if bot_user is None:
# Первый раз — как bot_started
await upsert_bot_user(
max_user_id=user_id,
max_chat_id=chat_id,
username=None,
first_name=None,
)
await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message(
chat_id=chat_id,
text=(
"👋 Добро пожаловать!\n\n"
"Поделитесь номером телефона для доступа к системе."
),
attachments=[contact_request_kb()],
)
return
if bot_user["is_blocked"]:
await event.bot.send_message(
chat_id=chat_id,
text="⛔ Доступ заблокирован. Обратитесь к администратору.",
)
return
if not bot_user["is_authorized"]:
await context.set_state(AuthStates.waiting_contact)
await event.bot.send_message(
chat_id=chat_id,
text="Поделитесь номером телефона для доступа:",
attachments=[contact_request_kb()],
)
return
await context.set_state(None)
await event.bot.send_message(
chat_id=chat_id,
text="Главное меню:",
attachments=[main_menu_kb()],
)
@router.message_created(ContactFilter(), states=AuthStates.waiting_contact)
async def on_contact_shared(
event: MessageCreated,
contact: ContactAttachment,
context: BaseContext,
) -> None:
chat_id, user_id = event.get_ids()
if user_id is None or chat_id is None:
return
# Извлекаем телефон из VCF
payload = contact.payload
if not isinstance(payload, ContactAttachmentPayload):
await event.bot.send_message(
chat_id=chat_id,
text="Не удалось прочитать контакт. Попробуйте ещё раз.",
attachments=[contact_request_kb()],
)
return
vcf = payload.vcf
raw_phone = vcf.phone
if not raw_phone:
await event.bot.send_message(
chat_id=chat_id,
text="В контакте не найден номер телефона. Попробуйте ещё раз.",
attachments=[contact_request_kb()],
)
return
phone = normalize_phone(raw_phone)
display_name = vcf.full_name
# Логируем входящий контакт
await log_message(
direction="in",
max_user_id=user_id,
max_chat_id=chat_id,
message_id=None,
text=f"[Контакт: {raw_phone}]",
)
# Ищем в белом списке
org_user = await find_by_phone(phone)
if org_user is None:
await block_bot_user(max_user_id=user_id, phone=phone)
await context.set_state(None)
await event.bot.send_message(
chat_id=chat_id,
text=(
"⛔ Ваш номер не найден в системе.\n"
"Доступ заблокирован. Обратитесь к администратору для подключения."
),
)
return
# Авторизуем
await authorize_bot_user(
max_user_id=user_id,
phone=phone,
org_id=org_user["org_id"],
first_name=display_name,
)
await context.set_state(None)
await event.bot.send_message(
chat_id=chat_id,
text="✅ Вы успешно авторизованы! Выберите действие:",
attachments=[main_menu_kb()],
)

View file

@ -0,0 +1,134 @@
"""
Обработчики просмотра заявок.
Навигация полностью через callback payload FSM-состояния не нужны.
"view_tickets" список, страница 0
"tickets_page:{n}" список, страница n
"ticket_detail:{id}" карточка заявки
"main_menu" в menu.py
"""
from maxapi.context import BaseContext
from maxapi.dispatcher import Router
from maxapi.filters import F
from maxapi.types.updates.message_callback import MessageCallback
from bot.filters import PayloadStartsWith
from bot.keyboards import ticket_detail_kb, tickets_list_kb
from database.queries.ticket_attachments import get_attachments
from database.queries.tickets import (
PAGE_SIZE,
count_tickets_for_org,
get_ticket,
get_tickets_for_org,
)
router = Router(router_id="view_tickets")
STATUS_LABELS = {
"open": "🔴 Открыта",
"in_progress": "🟡 В работе",
"closed": "🟢 Закрыта",
}
async def _send_tickets_page(
event: MessageCallback, page: int, bot_user: dict
) -> None:
org_id = bot_user["org_id"]
tickets = await get_tickets_for_org(org_id, page=page)
total = await count_tickets_for_org(org_id)
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
if not tickets:
await event.bot.send_message(
chat_id=chat_id,
text="📭 У вашей организации пока нет заявок.",
attachments=[ticket_detail_kb(page=0)],
)
return
await event.bot.send_message(
chat_id=chat_id,
text=f"📋 Заявки организации (стр. {page + 1}):",
attachments=[tickets_list_kb(tickets, page=page, total=total)],
)
@router.message_callback(F.callback.payload == "view_tickets")
async def cb_view_tickets(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
await _send_tickets_page(event, page=0, bot_user=bot_user)
@router.message_callback(PayloadStartsWith("tickets_page:"))
async def cb_tickets_page(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
await event.answer(notification="")
try:
page = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
page = 0
await _send_tickets_page(event, page=page, bot_user=bot_user)
@router.message_callback(PayloadStartsWith("ticket_detail:"))
async def cb_ticket_detail(
event: MessageCallback, context: BaseContext, bot_user: dict
) -> None:
try:
ticket_id = int(event.callback.payload.split(":")[1])
except (IndexError, ValueError):
await event.answer(notification="Ошибка")
return
ticket = await get_ticket(ticket_id)
chat_id = event.message.recipient.chat_id if event.message else None
if chat_id is None:
return
# Проверка доступа: заявка должна принадлежать той же организации
if ticket is None or ticket["org_id"] != bot_user["org_id"]:
await event.answer(notification="Нет доступа")
return
await event.answer(notification="")
status_label = STATUS_LABELS.get(ticket["status"], ticket["status"])
text = (
f"📌 Заявка #{ticket['id']}\n"
f"Статус: {status_label}\n"
f"Создана: {ticket['created_at'][:16]}\n\n"
f"📄 Описание:\n{ticket['description']}"
)
if ticket.get("last_comment"):
text += f"\n\n💬 Последний комментарий:\n{ticket['last_comment']}"
# Вложения
attachments = await get_attachments(ticket_id)
if attachments:
att_lines = []
for a in attachments:
icon = {
"AttachmentType.IMAGE": "🖼",
"AttachmentType.VIDEO": "🎬",
"AttachmentType.AUDIO": "🎙",
"AttachmentType.FILE": "📎",
}.get(a["attachment_type"], "📎")
line = f"{icon} {a['attachment_type'].split('.')[-1].capitalize()}"
if a.get("attachment_url"):
line += f": {a['attachment_url']}"
att_lines.append(line)
text += "\n\n📎 Вложения:\n" + "\n".join(att_lines)
await event.bot.send_message(
chat_id=chat_id,
text=text,
attachments=[ticket_detail_kb(page=0)],
)

70
bot/keyboards.py Normal file
View file

@ -0,0 +1,70 @@
from maxapi.types.attachments.attachment import Attachment
from maxapi.types.attachments.buttons import (
CallbackButton,
RequestContactButton,
)
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
def main_menu_kb() -> Attachment:
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="📝 Создать заявку", payload="create_ticket"))
.row(CallbackButton(text="📋 Просмотреть заявки", payload="view_tickets"))
.as_markup()
)
def contact_request_kb() -> Attachment:
return (
InlineKeyboardBuilder()
.row(RequestContactButton(text="📱 Поделиться контактом"))
.as_markup()
)
def done_ticket_kb() -> Attachment:
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="✅ Готово", payload="done_ticket"))
.as_markup()
)
def tickets_list_kb(
tickets: list[dict], page: int, total: int, page_size: int = 8
) -> Attachment:
builder = InlineKeyboardBuilder()
for t in tickets:
desc_preview = (t["description"] or "")[:35]
if len(t["description"] or "") > 35:
desc_preview += ""
status_icon = {"open": "🔴", "in_progress": "🟡", "closed": "🟢"}.get(
t["status"], ""
)
builder.row(
CallbackButton(
text=f"#{t['id']} {status_icon} {desc_preview}",
payload=f"ticket_detail:{t['id']}",
)
)
nav = []
if page > 0:
nav.append(CallbackButton(text="← Назад", payload=f"tickets_page:{page - 1}"))
if (page + 1) * page_size < total:
nav.append(CallbackButton(text="Вперёд →", payload=f"tickets_page:{page + 1}"))
if nav:
builder.row(*nav)
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
return builder.as_markup()
def ticket_detail_kb(page: int = 0) -> Attachment:
return (
InlineKeyboardBuilder()
.row(CallbackButton(text="К списку", payload=f"tickets_page:{page}"))
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
.as_markup()
)

78
bot/middleware.py Normal file
View file

@ -0,0 +1,78 @@
from collections.abc import Awaitable, Callable
from typing import Any
from maxapi.filters.middleware import BaseMiddleware
from maxapi.types.updates.bot_started import BotStarted
from maxapi.types.updates.message_created import MessageCreated
from database.queries.bot_users import get_bot_user_by_max_id
from database.queries.message_log import log_message
class AuthMiddleware(BaseMiddleware):
"""
Проверяет авторизацию пользователя перед передачей события в обработчик.
Пропускает без проверки:
- BotStarted (первый запуск)
- Сообщение с командой /start
Для остальных событий:
- Блокированные или незарегистрированные игнорируем
- Авторизованные добавляем bot_user в data и логируем сообщение
"""
async def __call__(
self,
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event: Any,
data: dict[str, Any],
) -> Any:
# Пропускаем bot_started — он обрабатывается без авторизации
if isinstance(event, BotStarted):
return await handler(event, data)
# Пропускаем команду /start
if isinstance(event, MessageCreated):
body = event.message.body
text = (body.text or "").strip() if body else ""
if text.lower().startswith("/start"):
return await handler(event, data)
# Получаем user_id из события
ids = event.get_ids()
user_id = ids[1] if ids else None
if user_id is None:
return
bot_user = await get_bot_user_by_max_id(user_id)
if bot_user is None:
# Незнакомый пользователь — игнорируем
return
if bot_user["is_blocked"]:
return
if not bot_user["is_authorized"]:
# Ещё не авторизовался — игнорируем (повторный промпт не нужен)
return
data["bot_user"] = bot_user
# Логируем входящее сообщение
if isinstance(event, MessageCreated):
chat_id, _ = event.get_ids()
body = event.message.body
atts = []
if body and body.attachments:
for a in body.attachments:
atts.append({"type": str(getattr(a, "type", ""))})
await log_message(
direction="in",
max_user_id=user_id,
max_chat_id=chat_id,
message_id=event.message.body.mid if body else None,
text=body.text if body else None,
attachments=atts or None,
)
return await handler(event, data)

24
bot/setup.py Normal file
View file

@ -0,0 +1,24 @@
from maxapi.bot import Bot
from maxapi.context import MemoryContext
from maxapi.dispatcher import Dispatcher, Router
import config
from bot.middleware import AuthMiddleware
from bot.handlers import start, menu, create_ticket, view_tickets
def create_bot_and_dispatcher() -> tuple[Bot, Dispatcher]:
bot = Bot(token=config.BOT_TOKEN)
dp = Dispatcher(router_id="main", storage=MemoryContext)
dp.outer_middleware(AuthMiddleware())
# Подключаем роутеры
dp.include_routers(
start.router,
menu.router,
create_ticket.router,
view_tickets.router,
)
return bot, dp

10
bot/states.py Normal file
View file

@ -0,0 +1,10 @@
from maxapi.context import State, StatesGroup
class AuthStates(StatesGroup):
waiting_contact = State() # Ожидаем шаринг контакта
class TicketStates(StatesGroup):
waiting_description = State() # Ожидаем первое сообщение с описанием
collecting_attachments = State() # Накапливаем доп. файлы/текст

11
config.py Normal file
View file

@ -0,0 +1,11 @@
import os
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN: str = os.environ["MAX_BOT_TOKEN"]
DB_PATH: str = os.environ.get("DB_PATH", "support.db")
ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "changeme")
ADMIN_HOST: str = os.environ.get("ADMIN_HOST", "0.0.0.0")
ADMIN_PORT: int = int(os.environ.get("ADMIN_PORT", "8080"))

BIN
data/support.db Normal file

Binary file not shown.

BIN
data/support.db-shm Normal file

Binary file not shown.

BIN
data/support.db-wal Normal file

Binary file not shown.

0
database/__init__.py Normal file
View file

30
database/connection.py Normal file
View file

@ -0,0 +1,30 @@
import aiosqlite
import os
from pathlib import Path
import config
_db: aiosqlite.Connection | None = None
async def init_db() -> None:
global _db
_db = await aiosqlite.connect(config.DB_PATH)
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
schema_path = Path(__file__).parent / "schema.sql"
schema = schema_path.read_text(encoding="utf-8")
# Выполняем каждый оператор отдельно
for statement in schema.split(";"):
stmt = statement.strip()
if stmt and not stmt.startswith("PRAGMA"):
await _db.execute(stmt)
await _db.commit()
def get_db() -> aiosqlite.Connection:
if _db is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return _db

View file

View file

@ -0,0 +1,115 @@
from database.connection import get_db
async def upsert_bot_user(
max_user_id: int,
max_chat_id: int | None,
username: str | None,
first_name: str | None,
) -> dict:
db = get_db()
await db.execute(
"""
INSERT INTO bot_users (max_user_id, max_chat_id, username, first_name)
VALUES (?, ?, ?, ?)
ON CONFLICT(max_user_id) DO UPDATE SET
max_chat_id = COALESCE(excluded.max_chat_id, max_chat_id),
username = COALESCE(excluded.username, username),
first_name = COALESCE(excluded.first_name, first_name),
updated_at = datetime('now')
""",
(max_user_id, max_chat_id, username, first_name),
)
await db.commit()
return await get_bot_user_by_max_id(max_user_id)
async def get_bot_user_by_max_id(max_user_id: int) -> dict | None:
db = get_db()
async with db.execute(
"SELECT * FROM bot_users WHERE max_user_id = ?", (max_user_id,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_bot_user_by_id(user_id: int) -> dict | None:
db = get_db()
async with db.execute(
"SELECT * FROM bot_users WHERE id = ?", (user_id,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def authorize_bot_user(
max_user_id: int, phone: str, org_id: int, first_name: str | None = None
) -> None:
db = get_db()
await db.execute(
"""
UPDATE bot_users
SET phone = ?, org_id = ?, is_authorized = 1, is_blocked = 0,
first_name = COALESCE(?, first_name), updated_at = datetime('now')
WHERE max_user_id = ?
""",
(phone, org_id, first_name, max_user_id),
)
await db.commit()
async def block_bot_user(max_user_id: int, phone: str | None = None) -> None:
db = get_db()
await db.execute(
"""
UPDATE bot_users
SET is_blocked = 1, phone = COALESCE(?, phone), updated_at = datetime('now')
WHERE max_user_id = ?
""",
(phone, max_user_id),
)
await db.commit()
async def unblock_bot_user(user_id: int, org_id: int) -> None:
db = get_db()
await db.execute(
"""
UPDATE bot_users
SET is_blocked = 0, is_authorized = 1, org_id = ?, updated_at = datetime('now')
WHERE id = ?
""",
(org_id, user_id),
)
await db.commit()
async def get_all_bot_users(
is_authorized: int | None = None,
is_blocked: int | None = None,
org_id: int | None = None,
) -> list[dict]:
db = get_db()
conditions = []
params: list = []
if is_authorized is not None:
conditions.append("bu.is_authorized = ?")
params.append(is_authorized)
if is_blocked is not None:
conditions.append("bu.is_blocked = ?")
params.append(is_blocked)
if org_id is not None:
conditions.append("bu.org_id = ?")
params.append(org_id)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
query = f"""
SELECT bu.*, o.name as org_name
FROM bot_users bu
LEFT JOIN organizations o ON o.id = bu.org_id
{where}
ORDER BY bu.created_at DESC
"""
async with db.execute(query, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,70 @@
import json
from database.connection import get_db
async def log_message(
direction: str,
max_user_id: int | None,
max_chat_id: int | None,
message_id: str | None,
text: str | None,
attachments: list | None = None,
) -> None:
db = get_db()
att_json = json.dumps(attachments, ensure_ascii=False) if attachments else None
await db.execute(
"""
INSERT INTO message_log
(direction, max_user_id, max_chat_id, message_id, text, attachment_json)
VALUES (?, ?, ?, ?, ?, ?)
""",
(direction, max_user_id, max_chat_id, message_id, text, att_json),
)
await db.commit()
async def get_message_log(
max_user_id: int | None = None,
max_chat_id: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
db = get_db()
conditions = []
params: list = []
if max_user_id is not None:
conditions.append("max_user_id = ?")
params.append(max_user_id)
if max_chat_id is not None:
conditions.append("max_chat_id = ?")
params.append(max_chat_id)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
params += [limit, offset]
query = f"""
SELECT * FROM message_log
{where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"""
async with db.execute(query, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def get_chat_list() -> list[dict]:
"""Последнее сообщение на каждый чат для списка чатов в панели."""
db = get_db()
async with db.execute(
"""
SELECT ml.max_chat_id, ml.max_user_id, ml.text, ml.created_at,
bu.first_name, bu.username
FROM message_log ml
LEFT JOIN bot_users bu ON bu.max_user_id = ml.max_user_id
WHERE ml.max_chat_id IS NOT NULL
GROUP BY ml.max_chat_id
HAVING ml.id = MAX(ml.id)
ORDER BY ml.created_at DESC
"""
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,48 @@
from database.connection import get_db
async def find_by_phone(phone: str) -> dict | None:
"""Найти запись в белом списке по нормализованному номеру."""
db = get_db()
async with db.execute(
"SELECT * FROM org_users WHERE phone = ?", (phone,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_org_users(org_id: int | None = None) -> list[dict]:
db = get_db()
if org_id is not None:
async with db.execute(
"SELECT ou.*, o.name as org_name FROM org_users ou "
"JOIN organizations o ON o.id = ou.org_id "
"WHERE ou.org_id = ? ORDER BY ou.display_name",
(org_id,),
) as cur:
rows = await cur.fetchall()
else:
async with db.execute(
"SELECT ou.*, o.name as org_name FROM org_users ou "
"JOIN organizations o ON o.id = ou.org_id "
"ORDER BY o.name, ou.display_name"
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def create_org_user(org_id: int, phone: str, display_name: str | None) -> int:
db = get_db()
async with db.execute(
"INSERT INTO org_users (org_id, phone, display_name) VALUES (?, ?, ?)",
(org_id, phone, display_name),
) as cur:
row_id = cur.lastrowid
await db.commit()
return row_id
async def delete_org_user(user_id: int) -> None:
db = get_db()
await db.execute("DELETE FROM org_users WHERE id = ?", (user_id,))
await db.commit()

View file

@ -0,0 +1,40 @@
from typing import Any
from database.connection import get_db
async def get_all_organizations() -> list[dict]:
db = get_db()
async with db.execute("SELECT * FROM organizations ORDER BY name") as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def get_organization(org_id: int) -> dict | None:
db = get_db()
async with db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def create_organization(name: str) -> int:
db = get_db()
async with db.execute(
"INSERT INTO organizations (name) VALUES (?)", (name,)
) as cur:
org_id = cur.lastrowid
await db.commit()
return org_id
async def update_organization(org_id: int, name: str) -> None:
db = get_db()
await db.execute(
"UPDATE organizations SET name = ? WHERE id = ?", (name, org_id)
)
await db.commit()
async def delete_organization(org_id: int) -> None:
db = get_db()
await db.execute("DELETE FROM organizations WHERE id = ?", (org_id,))
await db.commit()

View file

@ -0,0 +1,28 @@
from database.connection import get_db
async def add_attachment(
ticket_id: int,
attachment_type: str,
attachment_url: str | None,
filename: str | None = None,
) -> None:
db = get_db()
await db.execute(
"""
INSERT INTO ticket_attachments (ticket_id, attachment_type, attachment_url, filename)
VALUES (?, ?, ?, ?)
""",
(ticket_id, attachment_type, attachment_url, filename),
)
await db.commit()
async def get_attachments(ticket_id: int) -> list[dict]:
db = get_db()
async with db.execute(
"SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at",
(ticket_id,),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,97 @@
from database.connection import get_db
PAGE_SIZE = 8
async def create_ticket(org_id: int, created_by: int, description: str) -> int:
db = get_db()
async with db.execute(
"INSERT INTO tickets (org_id, created_by, description) VALUES (?, ?, ?)",
(org_id, created_by, description),
) as cur:
ticket_id = cur.lastrowid
await db.commit()
return ticket_id
async def get_ticket(ticket_id: int) -> dict | None:
db = get_db()
async with db.execute(
"""
SELECT t.*, bu.first_name as author_name, bu.username as author_username
FROM tickets t
LEFT JOIN bot_users bu ON bu.id = t.created_by
WHERE t.id = ?
""",
(ticket_id,),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_tickets_for_org(org_id: int, page: int = 0) -> list[dict]:
db = get_db()
offset = page * PAGE_SIZE
async with db.execute(
"""
SELECT id, description, status, created_at, last_comment
FROM tickets
WHERE org_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(org_id, PAGE_SIZE, offset),
) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def count_tickets_for_org(org_id: int) -> int:
db = get_db()
async with db.execute(
"SELECT COUNT(*) FROM tickets WHERE org_id = ?", (org_id,)
) as cur:
row = await cur.fetchone()
return row[0] if row else 0
async def get_all_tickets(
org_id: int | None = None, status: str | None = None
) -> list[dict]:
db = get_db()
conditions = []
params: list = []
if org_id is not None:
conditions.append("t.org_id = ?")
params.append(org_id)
if status is not None:
conditions.append("t.status = ?")
params.append(status)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
query = f"""
SELECT t.*, o.name as org_name, bu.first_name as author_name
FROM tickets t
LEFT JOIN organizations o ON o.id = t.org_id
LEFT JOIN bot_users bu ON bu.id = t.created_by
{where}
ORDER BY t.created_at DESC
"""
async with db.execute(query, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def update_ticket_status(
ticket_id: int, status: str, last_comment: str | None
) -> None:
db = get_db()
await db.execute(
"""
UPDATE tickets
SET status = ?, last_comment = COALESCE(?, last_comment),
updated_at = datetime('now')
WHERE id = ?
""",
(status, last_comment, ticket_id),
)
await db.commit()

66
database/schema.sql Normal file
View file

@ -0,0 +1,66 @@
PRAGMA journal_mode=WAL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Белый список телефонов по организациям.
-- phone хранится только цифрами, напр. "79001234567"
CREATE TABLE IF NOT EXISTS org_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
phone TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(phone)
);
-- Реальные пользователи MAX, которые написали боту
CREATE TABLE IF NOT EXISTS bot_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
max_user_id INTEGER NOT NULL UNIQUE,
max_chat_id INTEGER,
username TEXT,
first_name TEXT,
phone TEXT, -- заполняется после шаринга контакта
org_id INTEGER REFERENCES organizations(id),
is_authorized INTEGER NOT NULL DEFAULT 0, -- 1 = авторизован
is_blocked INTEGER NOT NULL DEFAULT 0, -- 1 = заблокирован
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL REFERENCES organizations(id),
created_by INTEGER NOT NULL REFERENCES bot_users(id),
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open / in_progress / closed
last_comment TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
attachment_type TEXT NOT NULL, -- image / audio / file / video
attachment_url TEXT,
filename TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Лог всех входящих и исходящих сообщений
CREATE TABLE IF NOT EXISTS message_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT NOT NULL, -- 'in' / 'out'
max_user_id INTEGER,
max_chat_id INTEGER,
message_id TEXT,
text TEXT,
attachment_json TEXT, -- JSON-строка списка вложений
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
support_bot:
build:
context: .. # родительская папка caps/ — нужна для доступа к maxapi-main/
dockerfile: support_bot/Dockerfile
env_file: .env
environment:
DB_PATH: /app/data/support.db # переопределяем путь внутри контейнера
ADMIN_HOST: "0.0.0.0"
ports:
- "${ADMIN_PORT:-8080}:8080"
volumes:
- ./data:/app/data # SQLite-файл живёт здесь — данные сохраняются между перезапусками
restart: unless-stopped

0
external/__init__.py vendored Normal file
View file

26
external/client.py vendored Normal file
View file

@ -0,0 +1,26 @@
"""
Заглушки для интеграции с внешней системой.
TODO: Заменить реализацию после получения API-спецификации.
Все функции синхронные вызов из async-кода через:
asyncio.create_task(loop.run_in_executor(None, sync_ticket, ticket_id))
"""
import logging
logger = logging.getLogger(__name__)
def sync_ticket(ticket_id: int) -> None:
"""Синхронизировать заявку с внешней системой."""
logger.debug("sync_ticket(%s) — stub, not implemented", ticket_id)
def sync_user(user_id: int) -> None:
"""Синхронизировать пользователя с внешней системой."""
logger.debug("sync_user(%s) — stub, not implemented", user_id)
def sync_org(org_id: int) -> None:
"""Синхронизировать организацию с внешней системой."""
logger.debug("sync_org(%s) — stub, not implemented", org_id)

32
main.py Normal file
View file

@ -0,0 +1,32 @@
import asyncio
import uvicorn
from database.connection import init_db
from bot.setup import create_bot_and_dispatcher
from admin.app import create_admin_app
import config
async def main() -> None:
await init_db()
bot, dp = create_bot_and_dispatcher()
admin_app = create_admin_app()
server = uvicorn.Server(
uvicorn.Config(
admin_app,
host=config.ADMIN_HOST,
port=config.ADMIN_PORT,
log_level="info",
)
)
await asyncio.gather(
dp.start_polling(bot, skip_updates=True),
server.serve(),
)
if __name__ == "__main__":
asyncio.run(main())

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
maxapi>=0.9.17,<1.0
fastapi==0.118.0
uvicorn[standard]==0.37.0
jinja2==3.1.6
aiosqlite
python-dotenv
python-multipart