init
This commit is contained in:
commit
074ca16f7c
54 changed files with 2131 additions and 0 deletions
12
.env
Normal file
12
.env
Normal 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
12
.env.example
Normal 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
22
Dockerfile
Normal 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
0
admin/__init__.py
Normal file
20
admin/app.py
Normal file
20
admin/app.py
Normal 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
25
admin/auth.py
Normal 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
0
admin/routes/__init__.py
Normal file
41
admin/routes/bot_users.py
Normal file
41
admin/routes/bot_users.py
Normal 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
25
admin/routes/chat_log.py
Normal 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
63
admin/routes/org_users.py
Normal 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)
|
||||
55
admin/routes/organizations.py
Normal file
55
admin/routes/organizations.py
Normal 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
54
admin/routes/tickets.py
Normal 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
64
admin/templates/base.html
Normal 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>
|
||||
74
admin/templates/bot_users/list.html
Normal file
74
admin/templates/bot_users/list.html
Normal 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 %}
|
||||
38
admin/templates/chat_log/history.html
Normal file
38
admin/templates/chat_log/history.html
Normal 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 %}
|
||||
27
admin/templates/chat_log/list.html
Normal file
27
admin/templates/chat_log/list.html
Normal 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 %}
|
||||
31
admin/templates/org_users/create.html
Normal file
31
admin/templates/org_users/create.html
Normal 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 %}
|
||||
40
admin/templates/org_users/list.html
Normal file
40
admin/templates/org_users/list.html
Normal 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 %}
|
||||
17
admin/templates/orgs/create.html
Normal file
17
admin/templates/orgs/create.html
Normal 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 %}
|
||||
17
admin/templates/orgs/edit.html
Normal file
17
admin/templates/orgs/edit.html
Normal 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 %}
|
||||
32
admin/templates/orgs/list.html
Normal file
32
admin/templates/orgs/list.html
Normal 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 %}
|
||||
84
admin/templates/tickets/detail.html
Normal file
84
admin/templates/tickets/detail.html
Normal 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 %}
|
||||
50
admin/templates/tickets/list.html
Normal file
50
admin/templates/tickets/list.html
Normal 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
5
admin/templates_env.py
Normal 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
0
bot/__init__.py
Normal file
17
bot/filters.py
Normal file
17
bot/filters.py
Normal 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
0
bot/handlers/__init__.py
Normal file
180
bot/handlers/create_ticket.py
Normal file
180
bot/handlers/create_ticket.py
Normal 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
25
bot/handlers/menu.py
Normal 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
201
bot/handlers/start.py
Normal 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()],
|
||||
)
|
||||
134
bot/handlers/view_tickets.py
Normal file
134
bot/handlers/view_tickets.py
Normal 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
70
bot/keyboards.py
Normal 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
78
bot/middleware.py
Normal 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
24
bot/setup.py
Normal 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
10
bot/states.py
Normal 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
11
config.py
Normal 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
BIN
data/support.db
Normal file
Binary file not shown.
BIN
data/support.db-shm
Normal file
BIN
data/support.db-shm
Normal file
Binary file not shown.
BIN
data/support.db-wal
Normal file
BIN
data/support.db-wal
Normal file
Binary file not shown.
0
database/__init__.py
Normal file
0
database/__init__.py
Normal file
30
database/connection.py
Normal file
30
database/connection.py
Normal 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
|
||||
0
database/queries/__init__.py
Normal file
0
database/queries/__init__.py
Normal file
115
database/queries/bot_users.py
Normal file
115
database/queries/bot_users.py
Normal 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]
|
||||
70
database/queries/message_log.py
Normal file
70
database/queries/message_log.py
Normal 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]
|
||||
48
database/queries/org_users.py
Normal file
48
database/queries/org_users.py
Normal 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()
|
||||
40
database/queries/organizations.py
Normal file
40
database/queries/organizations.py
Normal 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()
|
||||
28
database/queries/ticket_attachments.py
Normal file
28
database/queries/ticket_attachments.py
Normal 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]
|
||||
97
database/queries/tickets.py
Normal file
97
database/queries/tickets.py
Normal 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
66
database/schema.sql
Normal 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
14
docker-compose.yml
Normal 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
0
external/__init__.py
vendored
Normal file
26
external/client.py
vendored
Normal file
26
external/client.py
vendored
Normal 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
32
main.py
Normal 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
7
requirements.txt
Normal 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
|
||||
Loading…
Add table
Reference in a new issue