From 074ca16f7c1234fcc396269b4adff540fcb1e5e5 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 26 Mar 2026 00:25:37 +0300 Subject: [PATCH] init --- .env | 12 ++ .env.example | 12 ++ Dockerfile | 22 +++ admin/__init__.py | 0 admin/app.py | 20 +++ admin/auth.py | 25 +++ admin/routes/__init__.py | 0 admin/routes/bot_users.py | 41 +++++ admin/routes/chat_log.py | 25 +++ admin/routes/org_users.py | 63 ++++++++ admin/routes/organizations.py | 55 +++++++ admin/routes/tickets.py | 54 +++++++ admin/templates/base.html | 64 ++++++++ admin/templates/bot_users/list.html | 74 +++++++++ admin/templates/chat_log/history.html | 38 +++++ admin/templates/chat_log/list.html | 27 ++++ admin/templates/org_users/create.html | 31 ++++ admin/templates/org_users/list.html | 40 +++++ admin/templates/orgs/create.html | 17 +++ admin/templates/orgs/edit.html | 17 +++ admin/templates/orgs/list.html | 32 ++++ admin/templates/tickets/detail.html | 84 +++++++++++ admin/templates/tickets/list.html | 50 ++++++ admin/templates_env.py | 5 + bot/__init__.py | 0 bot/filters.py | 17 +++ bot/handlers/__init__.py | 0 bot/handlers/create_ticket.py | 180 ++++++++++++++++++++++ bot/handlers/menu.py | 25 +++ bot/handlers/start.py | 201 +++++++++++++++++++++++++ bot/handlers/view_tickets.py | 134 +++++++++++++++++ bot/keyboards.py | 70 +++++++++ bot/middleware.py | 78 ++++++++++ bot/setup.py | 24 +++ bot/states.py | 10 ++ config.py | 11 ++ data/support.db | Bin 0 -> 4096 bytes data/support.db-shm | Bin 0 -> 32768 bytes data/support.db-wal | Bin 0 -> 61832 bytes database/__init__.py | 0 database/connection.py | 30 ++++ database/queries/__init__.py | 0 database/queries/bot_users.py | 115 ++++++++++++++ database/queries/message_log.py | 70 +++++++++ database/queries/org_users.py | 48 ++++++ database/queries/organizations.py | 40 +++++ database/queries/ticket_attachments.py | 28 ++++ database/queries/tickets.py | 97 ++++++++++++ database/schema.sql | 66 ++++++++ docker-compose.yml | 14 ++ external/__init__.py | 0 external/client.py | 26 ++++ main.py | 32 ++++ requirements.txt | 7 + 54 files changed, 2131 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 admin/__init__.py create mode 100644 admin/app.py create mode 100644 admin/auth.py create mode 100644 admin/routes/__init__.py create mode 100644 admin/routes/bot_users.py create mode 100644 admin/routes/chat_log.py create mode 100644 admin/routes/org_users.py create mode 100644 admin/routes/organizations.py create mode 100644 admin/routes/tickets.py create mode 100644 admin/templates/base.html create mode 100644 admin/templates/bot_users/list.html create mode 100644 admin/templates/chat_log/history.html create mode 100644 admin/templates/chat_log/list.html create mode 100644 admin/templates/org_users/create.html create mode 100644 admin/templates/org_users/list.html create mode 100644 admin/templates/orgs/create.html create mode 100644 admin/templates/orgs/edit.html create mode 100644 admin/templates/orgs/list.html create mode 100644 admin/templates/tickets/detail.html create mode 100644 admin/templates/tickets/list.html create mode 100644 admin/templates_env.py create mode 100644 bot/__init__.py create mode 100644 bot/filters.py create mode 100644 bot/handlers/__init__.py create mode 100644 bot/handlers/create_ticket.py create mode 100644 bot/handlers/menu.py create mode 100644 bot/handlers/start.py create mode 100644 bot/handlers/view_tickets.py create mode 100644 bot/keyboards.py create mode 100644 bot/middleware.py create mode 100644 bot/setup.py create mode 100644 bot/states.py create mode 100644 config.py create mode 100644 data/support.db create mode 100644 data/support.db-shm create mode 100644 data/support.db-wal create mode 100644 database/__init__.py create mode 100644 database/connection.py create mode 100644 database/queries/__init__.py create mode 100644 database/queries/bot_users.py create mode 100644 database/queries/message_log.py create mode 100644 database/queries/org_users.py create mode 100644 database/queries/organizations.py create mode 100644 database/queries/ticket_attachments.py create mode 100644 database/queries/tickets.py create mode 100644 database/schema.sql create mode 100644 docker-compose.yml create mode 100644 external/__init__.py create mode 100644 external/client.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..e7741a9 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e8065f --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0d7084 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/app.py b/admin/app.py new file mode 100644 index 0000000..5e2bc35 --- /dev/null +++ b/admin/app.py @@ -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 diff --git a/admin/auth.py b/admin/auth.py new file mode 100644 index 0000000..aa5c416 --- /dev/null +++ b/admin/auth.py @@ -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"}, + ) diff --git a/admin/routes/__init__.py b/admin/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin/routes/bot_users.py b/admin/routes/bot_users.py new file mode 100644 index 0000000..38c8c13 --- /dev/null +++ b/admin/routes/bot_users.py @@ -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) diff --git a/admin/routes/chat_log.py b/admin/routes/chat_log.py new file mode 100644 index 0000000..a6a4406 --- /dev/null +++ b/admin/routes/chat_log.py @@ -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}, + ) diff --git a/admin/routes/org_users.py b/admin/routes/org_users.py new file mode 100644 index 0000000..876025d --- /dev/null +++ b/admin/routes/org_users.py @@ -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) diff --git a/admin/routes/organizations.py b/admin/routes/organizations.py new file mode 100644 index 0000000..dcc98ac --- /dev/null +++ b/admin/routes/organizations.py @@ -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) diff --git a/admin/routes/tickets.py b/admin/routes/tickets.py new file mode 100644 index 0000000..64fe796 --- /dev/null +++ b/admin/routes/tickets.py @@ -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) diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..ca0cd63 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,64 @@ + + + + + + {% block title %}Support Bot Admin{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/admin/templates/bot_users/list.html b/admin/templates/bot_users/list.html new file mode 100644 index 0000000..67b2cb2 --- /dev/null +++ b/admin/templates/bot_users/list.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}Пользователи бота{% endblock %} +{% block content %} +

Пользователи бота

+
+
+
+ + + +
+
+ + + + + + + + + {% for u in users %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
#MAX IDИмяТелефонОрганизацияСтатусДатаРазблокировать
{{ u.id }}{{ u.max_user_id }}{{ u.first_name or u.username or '—' }}{{ u.phone or '—' }}{{ u.org_name or '—' }} + {% if u.is_blocked %} + Заблокирован + {% elif u.is_authorized %} + Авторизован + {% else %} + Ожидает + {% endif %} + {{ u.created_at[:16] }} + {% if u.is_blocked or not u.is_authorized %} +
+ + +
+ {% else %} + — + {% endif %} +
Нет пользователей
+
+{% endblock %} diff --git a/admin/templates/chat_log/history.html b/admin/templates/chat_log/history.html new file mode 100644 index 0000000..2b5b3cb --- /dev/null +++ b/admin/templates/chat_log/history.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}История чата {{ max_user_id }}{% endblock %} +{% block content %} +

История чата

+← К списку чатов + +
+
+ {% for msg in messages|reverse %} +
+
+ {% if msg.text %}
{{ msg.text }}
{% endif %} + {% if msg.attachment_json and msg.attachment_json != 'null' %} +
📎 Вложение
+ {% endif %} +
+ {{ msg.created_at[:16] }} + {% if msg.direction == 'in' %} · входящее{% else %} · исходящее{% endif %} +
+
+
+ {% else %} +

История пуста

+ {% endfor %} +
+
+{% endblock %} diff --git a/admin/templates/chat_log/list.html b/admin/templates/chat_log/list.html new file mode 100644 index 0000000..a723dcd --- /dev/null +++ b/admin/templates/chat_log/list.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Чаты{% endblock %} +{% block content %} +

Чаты с клиентами

+
+ + + + + + {% for c in chats %} + + + + + + + + {% else %} + + {% endfor %} + +
ПользовательMAX IDПоследнее сообщениеВремя
{{ c.first_name or c.username or '—' }}{{ c.max_user_id }} + {{ c.text or '[вложение]' }} + {{ c.created_at[:16] }}История
Сообщений нет
+
+{% endblock %} diff --git a/admin/templates/org_users/create.html b/admin/templates/org_users/create.html new file mode 100644 index 0000000..30d4334 --- /dev/null +++ b/admin/templates/org_users/create.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Добавить клиента{% endblock %} +{% block content %} +

Добавить клиента в белый список

+
+
+
+ + +
+
+ + + Будет нормализован автоматически +
+
+ + +
+
+ + Отмена +
+
+
+{% endblock %} diff --git a/admin/templates/org_users/list.html b/admin/templates/org_users/list.html new file mode 100644 index 0000000..33e869b --- /dev/null +++ b/admin/templates/org_users/list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Клиенты (белый список){% endblock %} +{% block content %} +

Клиенты (белый список телефонов)

+
+
+
+ +
+ + Добавить +
+ + + + {% for u in users %} + + + + + + + + + {% else %} + + {% endfor %} + +
#ОрганизацияТелефонИмяДобавленДействия
{{ u.id }}{{ u.org_name }}{{ u.phone }}{{ u.display_name or '—' }}{{ u.created_at[:16] }} +
+ +
+
Список пуст
+
+{% endblock %} diff --git a/admin/templates/orgs/create.html b/admin/templates/orgs/create.html new file mode 100644 index 0000000..777eab8 --- /dev/null +++ b/admin/templates/orgs/create.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Новая организация{% endblock %} +{% block content %} +

Новая организация

+
+
+
+ + +
+
+ + Отмена +
+
+
+{% endblock %} diff --git a/admin/templates/orgs/edit.html b/admin/templates/orgs/edit.html new file mode 100644 index 0000000..414b403 --- /dev/null +++ b/admin/templates/orgs/edit.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Редактировать организацию{% endblock %} +{% block content %} +

Редактировать организацию

+
+
+
+ + +
+
+ + Отмена +
+
+
+{% endblock %} diff --git a/admin/templates/orgs/list.html b/admin/templates/orgs/list.html new file mode 100644 index 0000000..98b3643 --- /dev/null +++ b/admin/templates/orgs/list.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Организации{% endblock %} +{% block content %} +

Организации

+
+
+ Всего: {{ orgs|length }} + + Добавить +
+ + + + {% for org in orgs %} + + + + + + + {% else %} + + {% endfor %} + +
#НазваниеСозданаДействия
{{ org.id }}{{ org.name }}{{ org.created_at[:16] }} + Изменить +
+ +
+
Организаций нет
+
+{% endblock %} diff --git a/admin/templates/tickets/detail.html b/admin/templates/tickets/detail.html new file mode 100644 index 0000000..b71d9a0 --- /dev/null +++ b/admin/templates/tickets/detail.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Заявка #{{ ticket.id }}{% endblock %} +{% block content %} +

Заявка #{{ ticket.id }}

+← К списку + +
+
+
+

Описание

+

{{ ticket.description }}

+
+ + {% if attachments %} +
+

Вложения

+
    + {% for a in attachments %} +
  • + {% 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 %} + — Открыть + {% endif %} + {{ a.created_at[:16] }} +
  • + {% endfor %} +
+
+ {% endif %} + + {% if ticket.last_comment %} +
+

Последний комментарий

+

{{ ticket.last_comment }}

+
+ {% endif %} +
+ +
+
+

Сведения

+ + + + + + + + + +
Организация{{ ticket.org_name }}
Автор{{ ticket.author_name or ticket.author_username or '—' }}
Создана{{ ticket.created_at[:16] }}
Обновлена{{ ticket.updated_at[:16] }}
Статус + + {% if ticket.status == 'open' %}Открыта + {% elif ticket.status == 'in_progress' %}В работе + {% else %}Закрыта{% endif %} + +
+
+ +
+

Обновить статус

+
+
+ + +
+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/admin/templates/tickets/list.html b/admin/templates/tickets/list.html new file mode 100644 index 0000000..413aad7 --- /dev/null +++ b/admin/templates/tickets/list.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Заявки{% endblock %} +{% block content %} +

Заявки

+
+
+
+ + +
+ Всего: {{ tickets|length }} +
+ + + + + + {% for t in tickets %} + + + + + + + + + + {% else %} + + {% endfor %} + +
#ОрганизацияАвторОписаниеСтатусСоздана
{{ t.id }}{{ t.org_name }}{{ t.author_name or '—' }}{{ (t.description or '')[:60] }}{% if (t.description or '')|length > 60 %}…{% endif %} + + {% if t.status == 'open' %}Открыта + {% elif t.status == 'in_progress' %}В работе + {% else %}Закрыта{% endif %} + + {{ t.created_at[:16] }}Открыть
Заявок нет
+
+{% endblock %} diff --git a/admin/templates_env.py b/admin/templates_env.py new file mode 100644 index 0000000..f9c21b8 --- /dev/null +++ b/admin/templates_env.py @@ -0,0 +1,5 @@ +from pathlib import Path +from fastapi.templating import Jinja2Templates + +# Один общий экземпляр с абсолютным путём — избегает конфликта кеша Jinja2 +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/filters.py b/bot/filters.py new file mode 100644 index 0000000..4a061c7 --- /dev/null +++ b/bot/filters.py @@ -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) diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py new file mode 100644 index 0000000..813f545 --- /dev/null +++ b/bot/handlers/create_ticket.py @@ -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) + ) diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py new file mode 100644 index 0000000..fc1e18d --- /dev/null +++ b/bot/handlers/menu.py @@ -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()], + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..5db8282 --- /dev/null +++ b/bot/handlers/start.py @@ -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()], + ) diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py new file mode 100644 index 0000000..6b7f6c3 --- /dev/null +++ b/bot/handlers/view_tickets.py @@ -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)], + ) diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..a9fdc8e --- /dev/null +++ b/bot/keyboards.py @@ -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() + ) diff --git a/bot/middleware.py b/bot/middleware.py new file mode 100644 index 0000000..d8cece6 --- /dev/null +++ b/bot/middleware.py @@ -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) diff --git a/bot/setup.py b/bot/setup.py new file mode 100644 index 0000000..a7dd21b --- /dev/null +++ b/bot/setup.py @@ -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 diff --git a/bot/states.py b/bot/states.py new file mode 100644 index 0000000..2927df6 --- /dev/null +++ b/bot/states.py @@ -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() # Накапливаем доп. файлы/текст diff --git a/config.py b/config.py new file mode 100644 index 0000000..37450e6 --- /dev/null +++ b/config.py @@ -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")) diff --git a/data/support.db b/data/support.db new file mode 100644 index 0000000000000000000000000000000000000000..0de02ecf623141161c863ee065d9f7dd83cbe849 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDWBCs(jw8(rcOMPk!Ya07zBOaGYbQ|UXUc1w-33IPHH2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+z^w{2QY%lE z=%qm#-Rc#bo4}Ogf()F}{?hJ-+!0x?@l2-GPM6Xk?JodPjKQ3%v25R*fNK%D~p zIsABP7&8(=cyuxC!UjxDOf)J54l*qrbe%wAXm0H>PTF4Ay9!%W z+z|h~;)T32IzlwQ@YO^Sga9v%iJDF{NMaO;ghYKoVo1bS{+{Rl^lt68vY>(Wd&%0n z=eg(UJ)diz+uiT``~4n2&@$ocxw6dXyVu8;Gf$SZKg0K3+IaHmZ-1D2CP_tpclqSe z(aZ0zSpChwwy0_-Y`0d($_9IsS~oQK)-gu=Hqc)qZ(}XIeUrUG@1j4~cD2<8-|DfE zB~;U}^cP=yFTG#^0T2KI5C8!X009sH0T2KI5CDPOPhe|9OUJ5J{+)(Alu`7N4BhJ{ zb!DWe`{eg~9$Um`N^r;nw<(xVu8>*Jm zJsS<4{ybGI9AvbpY5ppfzF0!qEXCQ@c;A*td?$NO+Q}k=iT=JA^|D2ZCAvimeiT-V zgtQ~UV*T_t7>$~>se&RKN;*k5@}63@QQ8z4j3!t(P4$MFRl=d1wlCDx73?D9{661# zUeLL=C*C{$)fB&Hw;m<%H7pN2cdDo6Y-ELzG&RJaAB*mrJ25EqKr0rof-NpK2 zBsq^tB&OdG8Q2inD4F{7{2nc5%h$aMb+bOL>iLX3niMK`u3)c_wKH$C*@f+9%fVRR zwm~T@K9{uay-h70(n`Ni&83z7mcU;w8k*QnIw+I9&MLocZROt~_z{qo!R)w&AAz{1 zxAg+oFT8y7;^fIo&*-;olclNB`O>-4nbO(m!=)3`6Vv0C+|PfJs*beeWPJ3T?oPV-k0$K0bWcaY4bDL2QX>PZr8@6ihCn38tdIihqs z&iUGI#^_jk*;9-@_Beg)IdMcM=}?YS$yqb}x}F@$XsNx_6D<{pt98Z){|Sxq9F6J} zb?NTT6@8t$t1$q(b(-=#FuMrcUVBs0v$xEpXLr7a$zEqQ06zkY@-3Yo_!0b{`3PG0 z<;KS%G} zJ_HN)BM7vf@c0qXCKeC?0T2KI5C8!XsGY#U7XpEf<;(qVwhN)Tp^`Y+(5<&tkI39A ztR^wHmCRe?eOn^&oop8ErbmNqw{fJ@^J}qfz4Op$HTATsy`w(nvB6Ejb6U|;1vPJ| zT5g6^+N{?N*(mDNFJG!lbwgTS$%RazH(zCIn3_xG3)*l&(RJENWi(w$3#E5PCbfG? z%Vw3F;ZnQXBan?*KPAkC*@l``!l9hDFVtlnf6*=2u6}$KErVU~BM|3PX!GGm06zlj z+hET<|Ji(nd;}3n#gK@sk4nr+d!Q>LMJ1O~!rVKko3^^fqy9igDCD2mZfBT~bLyDP zr)ZvKqP)@Ixej@%Dz8R0p4+mIl6ST@uLiDpy5_l-F1q|4ztz?YwBKm?G4s#*kKsp9 z`@bXD69hm21V8`;KmY_l00ck)1V8`;?qUKSy}+@*+JE2t-QlbA=mpwbA;r|JJgks$SuUnkjox>w8NT_F znpQMnYIb!}EM(lV)%dP1JI8ExX!@L*jU3PmM4=aOOnZ*bqUx`z#@`g;4wfyQWxWdG z4we-#h&w2*QJzWKQZ=c1J|mBsaW#2;h55SXx!r7uxP!B3*Id`pD(7K=dI5fq-)8Ft zu6#84*#{T)E`wg+&i#iJM+O2Q00JNY0w4eaAOHd&00JNY0=JQXM=vmbVMYGczn?fb zk6u88CA5x#=QCKaUZAa6uh0un5f%^t0T8Gs0tb8A0v(;5{x=>J_UA0Q`I0d`LPS}iCk(_OjB(|^-898z;3vIU23$l(g$S zrLr2QgjSTR>$u2m^?B(8CGR>_`a+Dt^)y}d=6T@G>?~PT+?}BpfL=hPR->8;&~tT z_4d~0E5sM5%lHEP9zSU71+KQf^2p?Y8@r$vsHa;Jj1>ex00ck)1V8`;KmY_l00cmw HUJ3jQyf*3Q literal 0 HcmV?d00001 diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/database/connection.py b/database/connection.py new file mode 100644 index 0000000..5ad1afe --- /dev/null +++ b/database/connection.py @@ -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 diff --git a/database/queries/__init__.py b/database/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/database/queries/bot_users.py b/database/queries/bot_users.py new file mode 100644 index 0000000..7311197 --- /dev/null +++ b/database/queries/bot_users.py @@ -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] diff --git a/database/queries/message_log.py b/database/queries/message_log.py new file mode 100644 index 0000000..13b5152 --- /dev/null +++ b/database/queries/message_log.py @@ -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] diff --git a/database/queries/org_users.py b/database/queries/org_users.py new file mode 100644 index 0000000..e8766f6 --- /dev/null +++ b/database/queries/org_users.py @@ -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() diff --git a/database/queries/organizations.py b/database/queries/organizations.py new file mode 100644 index 0000000..5467074 --- /dev/null +++ b/database/queries/organizations.py @@ -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() diff --git a/database/queries/ticket_attachments.py b/database/queries/ticket_attachments.py new file mode 100644 index 0000000..22bca18 --- /dev/null +++ b/database/queries/ticket_attachments.py @@ -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] diff --git a/database/queries/tickets.py b/database/queries/tickets.py new file mode 100644 index 0000000..88badcf --- /dev/null +++ b/database/queries/tickets.py @@ -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() diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..c80ace0 --- /dev/null +++ b/database/schema.sql @@ -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')) +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f00780 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/external/__init__.py b/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/external/client.py b/external/client.py new file mode 100644 index 0000000..1abfad7 --- /dev/null +++ b/external/client.py @@ -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) diff --git a/main.py b/main.py new file mode 100644 index 0000000..e4345ed --- /dev/null +++ b/main.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..61f7237 --- /dev/null +++ b/requirements.txt @@ -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