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