This commit is contained in:
dv 2026-03-19 16:47:49 +03:00
commit a29c45ee32
39 changed files with 2734 additions and 0 deletions

25
.env.example Normal file
View file

@ -0,0 +1,25 @@
APP_ENV=dev
APP_HOST=0.0.0.0
APP_PORT=8000
DB_PATH=data/app.db
SESSION_SECRET=change-me
WEB_ADMIN_LOGIN=admin
WEB_ADMIN_PASSWORD=admin
MAX_BOT_TOKEN=
MAX_BOT_API_URL=https://botapi.max.ru
MAX_USE_WEBHOOK=false
MAX_WEBHOOK_URL=
MAX_WEBHOOK_SECRET=
CONTEXT_BACKEND=memory
REDIS_URL=redis://redis:6379/0
CHANNEL_LOCAL_ENABLED=true
CHANNEL_EXTERNAL_ENABLED=true
EXTERNAL_API_CREATE_URL_WITH_KEY=
OPERATOR_CHAT_IDS=
REQUEST_CATEGORIES=Общая консультация,Техническая проблема,Оплата,Другое

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.tmp_pytest/
pytest-cache-files-*/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
build/
dist/
*.egg-info/
app.db
data/
data/testdb/
.env
*.log

52
.woodpecker.yml Normal file
View file

@ -0,0 +1,52 @@
when:
- event: [push]
steps:
test:
image: python:3.11-slim
commands:
- pip install --no-cache-dir -r requirements.txt
- pytest -q
docker_build_push:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: ${CI_REGISTRY}
repo: ${CI_REGISTRY_IMAGE}
username:
from_secret: registry_user
password:
from_secret: registry_password
dockerfile: Dockerfile
platforms: linux/amd64
tags:
- ${CI_COMMIT_SHA}
- stable
when:
- branch: [main, master]
deploy_pull_on_host:
image: appleboy/drone-ssh
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_user
key:
from_secret: deploy_ssh_key
port:
from_secret: deploy_port
script:
- docker login ${CI_REGISTRY} -u ${REGISTRY_USER} -p ${REGISTRY_PASSWORD}
- docker pull ${CI_REGISTRY_IMAGE}:stable
- docker rm -f max-support-bot || true
- docker run -d --name max-support-bot --restart unless-stopped -p 8000:8000 --env-file /opt/max-support/.env -v /opt/max-support/data:/app/data ${CI_REGISTRY_IMAGE}:stable
- curl -fsS http://127.0.0.1:8000/health
environment:
REGISTRY_USER:
from_secret: registry_user
REGISTRY_PASSWORD:
from_secret: registry_password
when:
- branch: [main, master]

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

60
README.md Normal file
View file

@ -0,0 +1,60 @@
# MAX Support Bot v1
Единый сервис на `FastAPI + Jinja2 + SQLite`, который включает:
- MAX-бота на `maxapi` для приёма и обработки заявок.
- Веб-панель оператора (`/applications`, `/applications/{id}`, `/chats`).
- Интеграцию с внешней системой заявок через REST API.
## Возможности
- Переключаемый режим бота: `Polling` или `Webhook`.
- Каналы создания заявок:
- локальный (SQLite),
- внешний REST.
- Каналы включаются/выключаются независимо через ENV.
- Если внешний REST падает: локальная заявка сохраняется, sync-статус ставится в `failed` (без ретраев).
- Операторские действия доступны:
- в MAX через callback-кнопки,
- в веб-панели в карточке заявки.
## Быстрый старт
```bash
python -m venv .venv
. .venv/Scripts/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```
По умолчанию веб будет на `http://127.0.0.1:8000`.
## Основные ENV
- `MAX_BOT_TOKEN`
- `MAX_USE_WEBHOOK=true|false`
- `MAX_WEBHOOK_URL`
- `MAX_WEBHOOK_SECRET`
- `DB_PATH=data/app.db`
- `CONTEXT_BACKEND=memory|redis`
- `REDIS_URL`
- `CHANNEL_LOCAL_ENABLED=true|false`
- `CHANNEL_EXTERNAL_ENABLED=true|false`
- `EXTERNAL_API_CREATE_URL_WITH_KEY`
- `WEB_ADMIN_LOGIN`
- `WEB_ADMIN_PASSWORD`
- `SESSION_SECRET`
- `OPERATOR_CHAT_IDS=123,456`
## Основные URL
- `GET /health`
- `GET/POST /login`
- `POST /logout`
- `GET /applications`
- `GET /applications/{id}`
- `POST /applications/{id}/status`
- `GET /chats`
- `POST /chats/sync`
- `POST /max/webhook`

2
app/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""MAX support bot application package."""

2
app/api/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""HTTP routes for web UI and internal REST."""

15
app/api/deps.py Normal file
View file

@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import HTTPException, Request, status
def get_current_username(request: Request) -> str:
username = request.session.get("username")
if not username:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
return username
def get_current_username_optional(request: Request) -> str | None:
return request.session.get("username")

196
app/api/routes.py Normal file
View file

@ -0,0 +1,196 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.api.deps import get_current_username, get_current_username_optional
from app.models import ApplicationStatus
from app.security import verify_password
def build_router(templates: Jinja2Templates) -> APIRouter:
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def root(request: Request, username: str | None = Depends(get_current_username_optional)):
if username:
return RedirectResponse("/applications", status_code=303)
return RedirectResponse("/login", status_code=303)
@router.get("/health")
async def health(request: Request):
return {
"ok": True,
"app": request.app.state.settings.app_name,
"env": request.app.state.settings.app_env,
"bot_enabled": bool(getattr(request.app.state.bot_runtime, "enabled", False)),
}
@router.get("/login", response_class=HTMLResponse)
async def login_get(request: Request):
if request.session.get("username"):
return RedirectResponse("/applications", status_code=303)
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": None},
)
@router.post("/login", response_class=HTMLResponse)
async def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
user = request.app.state.container.users.get_by_username(username.strip())
if not user or not verify_password(password, user["password_hash"]):
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": "Неверный логин или пароль"},
status_code=401,
)
request.session["username"] = user["username"]
request.session["role"] = user["role"]
return RedirectResponse("/applications", status_code=303)
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@router.get("/applications", response_class=HTMLResponse)
async def applications_page(
request: Request,
status_filter: str | None = None,
search: str | None = None,
username: str = Depends(get_current_username),
):
apps = request.app.state.container.app_service.list_applications(status=status_filter, search=search)
return templates.TemplateResponse(
request=request,
name="applications.html",
context={
"username": username,
"applications": apps,
"status_filter": status_filter or "",
"search": search or "",
"statuses": [s.value for s in ApplicationStatus],
},
)
@router.get("/applications/{application_id}", response_class=HTMLResponse)
async def application_page(
request: Request,
application_id: int,
username: str = Depends(get_current_username),
):
app = request.app.state.container.app_service.get_application(application_id)
if not app:
raise HTTPException(status_code=404, detail="Application not found")
events = request.app.state.container.app_service.get_events(application_id)
attachments = json.loads(app.attachments_json) if app.attachments_json else []
return templates.TemplateResponse(
request=request,
name="application_detail.html",
context={
"username": username,
"application": app,
"events": events,
"attachments": attachments,
"statuses": [s.value for s in ApplicationStatus],
},
)
@router.post("/applications/{application_id}/status")
async def application_status_change(
request: Request,
application_id: int,
status_value: str = Form(...),
reason: str = Form(""),
username: str = Depends(get_current_username),
):
if status_value not in {s.value for s in ApplicationStatus}:
raise HTTPException(status_code=400, detail="Invalid status")
updated = request.app.state.container.app_service.update_status(
application_id,
status=status_value,
actor=f"web:{username}",
reason=reason or None,
)
if not updated:
raise HTTPException(status_code=404, detail="Application not found")
return RedirectResponse(f"/applications/{application_id}", status_code=303)
@router.get("/chats", response_class=HTMLResponse)
async def chats_page(request: Request, username: str = Depends(get_current_username)):
chats = request.app.state.container.chats.list(limit=300)
return templates.TemplateResponse(
request=request,
name="chats.html",
context={"username": username, "chats": chats},
)
@router.post("/chats/sync")
async def chats_sync(request: Request, _: str = Depends(get_current_username)):
runtime = request.app.state.bot_runtime
counters = await runtime.sync_chats_from_api()
return JSONResponse(counters)
@router.post("/max/webhook")
async def max_webhook(request: Request):
payload = await request.json()
secret_expected = request.app.state.settings.max_webhook_secret
if secret_expected:
header_secret = request.headers.get("x-max-webhook-secret", "")
if header_secret != secret_expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid webhook secret")
await request.app.state.bot_runtime.handle_webhook_payload(payload)
return {"ok": True}
@router.get("/api/applications")
async def api_applications(
request: Request,
status_filter: str | None = None,
search: str | None = None,
_: str = Depends(get_current_username),
):
apps = request.app.state.container.app_service.list_applications(status=status_filter, search=search)
return [a.__dict__ for a in apps]
@router.get("/api/applications/{application_id}")
async def api_application(request: Request, application_id: int, _: str = Depends(get_current_username)):
app = request.app.state.container.app_service.get_application(application_id)
if not app:
raise HTTPException(status_code=404, detail="Application not found")
events = request.app.state.container.app_service.get_events(application_id)
return {"application": app.__dict__, "events": [e.__dict__ for e in events]}
@router.post("/api/applications/{application_id}/status")
async def api_application_status(
request: Request,
application_id: int,
payload: dict[str, Any],
username: str = Depends(get_current_username),
):
status_value = str(payload.get("status", ""))
reason = payload.get("reason")
if status_value not in {s.value for s in ApplicationStatus}:
raise HTTPException(status_code=400, detail="Invalid status")
updated = request.app.state.container.app_service.update_status(
application_id,
status=status_value,
actor=f"web:{username}",
reason=reason,
)
if not updated:
raise HTTPException(status_code=404, detail="Application not found")
return {"ok": True, "application": updated.__dict__}
@router.get("/api/chats")
async def api_chats(request: Request, _: str = Depends(get_current_username)):
chats = request.app.state.container.chats.list(limit=300)
return [c.__dict__ for c in chats]
return router

2
app/bot/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""MAX bot runtime and handlers."""

438
app/bot/handlers.py Normal file
View file

@ -0,0 +1,438 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from app.models import ApplicationStatus
from app.repositories.chats import ChatRepository
from app.services.application_service import ApplicationService
from app.services.request_state import RequestDraft, RequestStateStore
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class BotHandlerDeps:
app_service: ApplicationService
chats: ChatRepository
request_state: RequestStateStore
request_categories: list[str]
operator_chat_ids: list[int]
safe_send_message: Callable[[int, str], Awaitable[None]]
safe_send_operator_card: Callable[[int, str, int], Awaitable[None]]
def register_handlers(dp: Any, deps: BotHandlerDeps) -> None:
@dp.message_created()
async def on_message_created(message: Any):
await _track_chat(deps.chats, message)
text = _extract_text(message).strip()
if not text:
# Возможно пользователь отправил вложения после описания.
await _maybe_collect_attachment(message, deps)
return
if text.startswith("/"):
await _handle_command(message, text, deps)
return
await _handle_request_flow_text(message, text, deps)
@dp.message_callback()
async def on_message_callback(callback: Any):
await _track_chat(deps.chats, callback)
payload = _extract_callback_payload(callback)
if payload.startswith("demo:"):
await _safe_answer(callback, "Демо callback обработан")
await _safe_reply(
callback,
"Callback обработан. Это демонстрация inline-кнопок и payload роутинга.",
)
return
if not payload.startswith("op:"):
await _safe_answer(callback, "Неизвестное callback-действие.")
return
_, action, app_id_raw = (payload.split(":", 2) + ["", ""])[:3]
if not app_id_raw.isdigit():
await _safe_answer(callback, "Некорректный ID заявки.")
return
app_id = int(app_id_raw)
status_map = {
"take": ApplicationStatus.IN_PROGRESS.value,
"need_info": ApplicationStatus.NEED_INFO.value,
"done": ApplicationStatus.DONE.value,
"reject": ApplicationStatus.REJECTED.value,
}
if action not in status_map:
await _safe_answer(callback, "Неизвестное действие оператора.")
return
actor = f"max_operator:{_extract_user_id(callback)}"
updated = deps.app_service.update_status(app_id, status_map[action], actor=actor)
if not updated:
await _safe_answer(callback, "Заявка не найдена.")
return
await _safe_answer(callback, f"Статус заявки #{app_id} -> {status_map[action]}")
await _safe_reply(
callback,
(
f"Оператор обновил заявку #{app_id}\n"
f"Новый статус: {status_map[action]}\n"
"Изменение сохранено в БД и видно в веб-панели."
),
)
@dp.bot_added()
async def on_bot_added(event: Any):
await _track_chat(deps.chats, event)
await _safe_reply(event, "Спасибо за добавление. Я готов принимать заявки: /request")
@dp.bot_removed()
async def on_bot_removed(event: Any):
chat_id = _extract_chat_id(event)
if chat_id is not None:
deps.chats.deactivate(chat_id)
@dp.user_added()
async def on_user_added(event: Any):
await _track_chat(deps.chats, event)
@dp.user_removed()
async def on_user_removed(event: Any):
await _track_chat(deps.chats, event)
@dp.message_edited()
async def on_message_edited(event: Any):
await _track_chat(deps.chats, event)
@dp.message_removed()
async def on_message_removed(event: Any):
await _track_chat(deps.chats, event)
async def _handle_command(message: Any, text: str, deps: BotHandlerDeps) -> None:
cmd = text.split()[0].lower()
user_id = _extract_user_id(message) or 0
if cmd in {"/start", "/help"}:
categories = "\n".join(f"- {c}" for c in deps.request_categories)
await _safe_reply(
message,
(
"Привет! Я MAX Support Bot.\n\n"
"Команды:\n"
"/request - создать заявку\n"
"/cancel - отменить создание\n"
"/demo - показать demo-возможности\n"
"/state - состояние каналов\n"
"/myid - ваш id\n\n"
f"Категории заявок:\n{categories}"
),
)
return
if cmd == "/myid":
await _safe_reply(message, f"Ваш user_id: {user_id}")
return
if cmd == "/state":
await _safe_reply(
message,
(
f"Локальный канал: {'ON' if deps.app_service.channel_local_enabled else 'OFF'}\n"
f"Внешний канал: {'ON' if deps.app_service.channel_external_enabled else 'OFF'}"
),
)
return
if cmd == "/cancel":
await deps.request_state.clear(user_id)
await _safe_reply(message, "Создание заявки отменено.")
return
if cmd == "/demo":
await _send_demo_bundle(message)
await _safe_reply(
message,
(
"Demo maxapi:\n"
"1) Команды и обработчики событий\n"
"2) Callback-действия операторов\n"
"3) Сервисные updates (bot/user/message)\n"
"4) Роутер + middleware + фильтры (в коде)\n"
"5) Polling/Webhook режимы\n"
"6) Веб-панель и REST эндпоинты\n"
"Нажмите inline-кнопку для callback demo."
),
)
return
if cmd == "/request":
if not deps.app_service.channel_local_enabled and not deps.app_service.channel_external_enabled:
await _safe_reply(message, "Создание заявок временно отключено: оба канала выключены.")
return
draft = RequestDraft(state="awaiting_category")
await deps.request_state.set(user_id, draft)
categories = "\n".join(f"- {c}" for c in deps.request_categories)
await _safe_reply(
message,
f"Начинаем создание заявки.\nВыберите категорию и отправьте текстом:\n{categories}",
)
return
await _safe_reply(message, "Неизвестная команда. Используйте /help")
async def _handle_request_flow_text(message: Any, text: str, deps: BotHandlerDeps) -> None:
user_id = _extract_user_id(message) or 0
draft = await deps.request_state.get(user_id)
if draft.state == "idle":
return
if draft.state == "awaiting_category":
if text not in deps.request_categories:
await _safe_reply(message, "Неизвестная категория. Выберите из списка или /cancel")
return
draft.category = text
draft.state = "awaiting_description"
await deps.request_state.set(user_id, draft)
await _safe_reply(
message,
"Опишите проблему одним сообщением. Можно приложить медиа следующим сообщением.",
)
return
if draft.state == "awaiting_description":
draft.description = text
draft.state = "awaiting_confirmation"
await deps.request_state.set(user_id, draft)
await _safe_reply(
message,
(
"Подтвердите создание заявки: отправьте `confirm`.\n"
"Или отправьте новое описание для замены.\n"
"Или /cancel для отмены."
),
)
return
if draft.state == "awaiting_confirmation":
if text.strip().lower() != "confirm":
draft.description = text
await deps.request_state.set(user_id, draft)
await _safe_reply(message, "Описание обновлено. Отправьте `confirm` для создания.")
return
customer_name = _extract_user_name(message)
outcome = await deps.app_service.create_application(
customer_id=_extract_user_id(message),
customer_name=customer_name,
chat_id=_extract_chat_id(message),
category=draft.category or "Другое",
description=draft.description or "",
attachments=draft.attachments,
source="bot",
)
await deps.request_state.clear(user_id)
app_id = outcome.application.id if outcome.application else "external-only"
sync_msg = "внешняя система: OK" if outcome.sent_external else f"внешняя система: ERROR ({outcome.external_error})"
await _safe_reply(
message,
f"Заявка создана: #{app_id}\nЛокально: {'да' if outcome.created_local else 'нет'}\n{sync_msg}",
)
if outcome.application and deps.operator_chat_ids:
await _notify_operators_new_application(outcome.application.id, outcome.application.description, deps)
return
async def _notify_operators_new_application(app_id: int, description: str, deps: BotHandlerDeps) -> None:
text = (
f"Новая заявка #{app_id}\n"
f"{description[:500]}\n"
"Используйте кнопки ниже для смены статуса."
)
for chat_id in deps.operator_chat_ids:
try:
await deps.safe_send_operator_card(chat_id, text, app_id)
except Exception:
LOGGER.exception("Failed to notify operator chat_id=%s about app_id=%s", chat_id, app_id)
async def _maybe_collect_attachment(message: Any, deps: BotHandlerDeps) -> None:
user_id = _extract_user_id(message) or 0
draft = await deps.request_state.get(user_id)
if draft.state not in {"awaiting_description", "awaiting_confirmation"}:
return
attachment = _extract_attachment(message)
if not attachment:
return
draft.attachments.append(attachment)
await deps.request_state.set(user_id, draft)
await _safe_reply(message, "Вложение добавлено к текущей заявке.")
async def _track_chat(chats: ChatRepository, event: Any) -> None:
chat_id = _extract_chat_id(event)
if chat_id is None:
return
chats.upsert(
chat_id=chat_id,
chat_type=_extract_chat_type(event) or "unknown",
title=_extract_chat_title(event),
username=None,
is_active=1,
)
def _extract_text(event: Any) -> str:
for candidate in (
getattr(event, "text", None),
getattr(getattr(event, "message", None), "text", None),
getattr(getattr(event, "body", None), "text", None),
getattr(getattr(getattr(event, "message", None), "body", None), "text", None),
):
if isinstance(candidate, str):
return candidate
body = getattr(event, "body", None)
if body and isinstance(getattr(body, "text", None), str):
return body.text
return ""
def _extract_user_id(event: Any) -> int | None:
for obj in (event, getattr(event, "message", None), getattr(event, "callback", None)):
user = getattr(obj, "from_user", None) or getattr(obj, "sender", None) or getattr(obj, "user", None)
if user is None:
continue
for attr in ("user_id", "id"):
value = getattr(user, attr, None)
if isinstance(value, int):
return value
return None
def _extract_user_name(event: Any) -> str:
for obj in (event, getattr(event, "message", None)):
user = getattr(obj, "from_user", None) or getattr(obj, "sender", None) or getattr(obj, "user", None)
if not user:
continue
for attr in ("username", "display_name", "name", "first_name"):
value = getattr(user, attr, None)
if isinstance(value, str) and value.strip():
return value.strip()
user_id = _extract_user_id(event)
return f"user_{user_id}" if user_id else "unknown_user"
def _extract_chat_id(event: Any) -> int | None:
for obj in (event, getattr(event, "message", None)):
chat = getattr(obj, "chat", None)
if chat is None:
message_obj = getattr(obj, "message", None) or obj
recipient = getattr(message_obj, "recipient", None)
if recipient is not None:
value = getattr(recipient, "chat_id", None)
if isinstance(value, int):
return value
continue
for attr in ("chat_id", "id"):
value = getattr(chat, attr, None)
if isinstance(value, int):
return value
return None
def _extract_chat_type(event: Any) -> str | None:
for obj in (event, getattr(event, "message", None)):
chat = getattr(obj, "chat", None)
if chat is None:
message_obj = getattr(obj, "message", None) or obj
recipient = getattr(message_obj, "recipient", None)
if recipient is not None:
value = getattr(recipient, "type", None)
if isinstance(value, str):
return value
continue
for attr in ("type", "chat_type"):
value = getattr(chat, attr, None)
if isinstance(value, str):
return value
return None
def _extract_chat_title(event: Any) -> str | None:
for obj in (event, getattr(event, "message", None)):
chat = getattr(obj, "chat", None)
if chat is None:
message_obj = getattr(obj, "message", None) or obj
recipient = getattr(message_obj, "recipient", None)
if recipient is not None:
for attr in ("title", "name"):
value = getattr(recipient, attr, None)
if isinstance(value, str):
return value
continue
for attr in ("title", "name"):
value = getattr(chat, attr, None)
if isinstance(value, str):
return value
return None
def _extract_callback_payload(callback: Any) -> str:
for attr in ("payload", "data", "callback_data"):
value = getattr(callback, attr, None)
if isinstance(value, str):
return value
inner = getattr(callback, "callback", None)
if inner:
for attr in ("payload", "data", "callback_data"):
value = getattr(inner, attr, None)
if isinstance(value, str):
return value
return ""
def _extract_attachment(event: Any) -> dict | None:
body = getattr(event, "body", None) or getattr(getattr(event, "message", None), "body", None)
attachments = getattr(body, "attachments", None) if body else None
if not attachments:
return None
first = attachments[0]
att_type = getattr(first, "type", None) or getattr(first, "media_type", None) or "unknown"
att_id = getattr(first, "id", None) or getattr(first, "token", None) or ""
return {"type": str(att_type), "id": str(att_id)}
async def _safe_reply(event: Any, text: str) -> None:
message = getattr(event, "message", None)
if message and hasattr(message, "answer"):
await message.answer(text)
return
if hasattr(event, "answer"):
await event.answer(text)
return
async def _safe_answer(callback: Any, text: str) -> None:
if hasattr(callback, "answer"):
await callback.answer(text)
return
inner = getattr(callback, "callback", None)
if inner and hasattr(inner, "answer"):
await inner.answer(text)
async def _send_demo_bundle(message_event: Any) -> None:
message = getattr(message_event, "message", None)
if message is None:
return
try:
from maxapi.enums.sender_action import SenderAction
from maxapi.types.attachments.buttons.callback_button import CallbackButton
from maxapi.types.attachments.buttons.link_button import LinkButton
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
bot = message_event._ensure_bot()
chat_id = _extract_chat_id(message_event)
if chat_id is not None:
await bot.send_action(chat_id=chat_id, action=SenderAction.TYPING_ON)
keyboard = InlineKeyboardBuilder()
keyboard.row(CallbackButton(text="Demo callback", payload="demo:ping"))
keyboard.row(LinkButton(text="Open MAX API Docs", url="https://love-apples.github.io/maxapi/"))
await message.answer(
text="Demo keyboard отправлена. Нажмите кнопку ниже.",
attachments=[keyboard.as_markup()],
)
except Exception:
LOGGER.exception("Failed to send demo bundle")

209
app/bot/runtime.py Normal file
View file

@ -0,0 +1,209 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.bot.handlers import BotHandlerDeps, register_handlers
from app.container import Container
LOGGER = logging.getLogger(__name__)
class BotRuntime:
def __init__(self, container: Container) -> None:
self._container = container
self._enabled = False
self._polling_task: asyncio.Task | None = None
self.bot: Any | None = None
self.dp: Any | None = None
self._process_webhook_update = None
self._init_maxapi_runtime()
@property
def enabled(self) -> bool:
return self._enabled
def _init_maxapi_runtime(self) -> None:
token = self._container.settings.max_bot_token
if not token:
LOGGER.warning("MAX_BOT_TOKEN is empty. Bot runtime is disabled.")
return
try:
from maxapi import Bot, Dispatcher, Router
from maxapi.filters.middleware import BaseMiddleware
from maxapi.methods.types.getted_updates import process_update_webhook
from maxapi.types.command import BotCommand
except Exception as exc:
LOGGER.exception("Failed to import maxapi: %s", exc)
return
self.bot = Bot(token=token)
if self._container.settings.max_bot_api_url:
self.bot.set_api_url(self._container.settings.max_bot_api_url)
self.dp = Dispatcher()
self._process_webhook_update = process_update_webhook
class TraceMiddleware(BaseMiddleware):
async def __call__(self, handler, event_object, data):
data["trace"] = f"upd:{getattr(event_object, 'update_type', 'unknown')}"
return await handler(event_object, data)
self.dp.outer_middleware(TraceMiddleware())
main_router = Router("main")
deps = BotHandlerDeps(
app_service=self._container.app_service,
chats=self._container.chats,
request_state=self._container.request_state,
request_categories=self._container.settings.request_categories or [],
operator_chat_ids=self._container.settings.operator_chat_ids or [],
safe_send_message=self.safe_send_message,
safe_send_operator_card=self.safe_send_operator_card,
)
register_handlers(main_router, deps)
self.dp.include_routers(main_router)
self._commands = [
BotCommand(name="start", description="Старт и справка"),
BotCommand(name="help", description="Команды"),
BotCommand(name="request", description="Создать заявку"),
BotCommand(name="cancel", description="Отменить создание заявки"),
BotCommand(name="demo", description="Demo возможностей"),
BotCommand(name="state", description="Состояние каналов"),
BotCommand(name="myid", description="Показать user_id"),
]
self._enabled = True
async def startup(self) -> None:
if not self._enabled:
return
assert self.bot is not None
try:
if hasattr(self.bot, "set_my_commands"):
await self.bot.set_my_commands(*self._commands)
except Exception:
LOGGER.exception("set_my_commands failed")
if self._container.settings.max_use_webhook:
LOGGER.info("Bot runtime started in webhook mode.")
return
LOGGER.info("Starting MAX polling task.")
self._polling_task = asyncio.create_task(self._run_polling())
async def shutdown(self) -> None:
if self._polling_task:
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
except Exception:
LOGGER.exception("Polling task failed while shutdown.")
if self.bot and hasattr(self.bot, "close_session"):
try:
await self.bot.close_session()
except Exception:
LOGGER.exception("Failed to close bot.")
async def _run_polling(self) -> None:
assert self.bot is not None
assert self.dp is not None
try:
if hasattr(self.dp, "start_polling"):
await self.dp.start_polling(self.bot)
else:
LOGGER.error("maxapi Dispatcher has no start_polling method.")
except asyncio.CancelledError:
raise
except Exception:
LOGGER.exception("Polling loop crashed.")
async def handle_webhook_payload(self, payload: dict) -> None:
if not self._enabled:
return
if not self._container.settings.max_use_webhook:
LOGGER.warning("Webhook payload received while MAX_USE_WEBHOOK=false.")
return
assert self.dp is not None
if self._process_webhook_update is None:
LOGGER.error("No webhook parser configured from maxapi.")
return
try:
update = await self._process_webhook_update(event_json=payload, bot=self.bot)
if update is None:
return
await self.dp.handle(update)
except Exception:
LOGGER.exception("Failed to process webhook payload.")
async def safe_send_message(self, chat_id: int, text: str) -> None:
if not self.bot:
return
try:
await self.bot.send_message(chat_id=chat_id, text=text)
return
except TypeError:
pass
except Exception:
LOGGER.exception("send_message(chat_id=..., text=...) failed")
try:
await self.bot.send_message(chat_id, text)
except Exception:
LOGGER.exception("send_message positional call failed")
async def safe_send_operator_card(self, chat_id: int, text: str, app_id: int) -> None:
if not self.bot:
return
try:
from maxapi.types.attachments.buttons.callback_button import CallbackButton
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
keyboard = InlineKeyboardBuilder()
keyboard.row(
CallbackButton(text="Взять", payload=f"op:take:{app_id}"),
CallbackButton(text="Уточнить", payload=f"op:need_info:{app_id}"),
)
keyboard.row(
CallbackButton(text="Готово", payload=f"op:done:{app_id}"),
CallbackButton(text="Отклонить", payload=f"op:reject:{app_id}"),
)
await self.bot.send_message(chat_id=chat_id, text=text, attachments=[keyboard.as_markup()])
return
except Exception:
LOGGER.exception("Failed to send operator card with keyboard, fallback to plain text.")
await self.safe_send_message(
chat_id,
f"{text}\nFallback payloads: op:take:{app_id}, op:need_info:{app_id}, op:done:{app_id}, op:reject:{app_id}",
)
async def sync_chats_from_api(self) -> dict[str, int]:
"""Best-effort sync for /chats/sync. Returns counters."""
counters = {"fetched": 0, "stored": 0}
if not self.bot:
return counters
if not hasattr(self.bot, "get_chats"):
return counters
try:
chats = await self.bot.get_chats()
except Exception:
LOGGER.exception("get_chats failed")
return counters
items = getattr(chats, "chats", []) or []
for chat in items:
counters["fetched"] += 1
chat_id = getattr(chat, "chat_id", None) or getattr(chat, "id", None)
if not isinstance(chat_id, int):
continue
self._container.chats.upsert(
chat_id=chat_id,
chat_type=str(getattr(chat, "type", "unknown")),
title=getattr(chat, "title", None) or getattr(chat, "name", None),
username=getattr(chat, "username", None),
is_active=1,
)
counters["stored"] += 1
return counters

85
app/config.py Normal file
View file

@ -0,0 +1,85 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from functools import lru_cache
def _to_bool(value: str | None, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _to_list(value: str | None) -> list[str]:
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
@dataclass(frozen=True)
class Settings:
app_name: str = "MAX Support Bot"
app_env: str = "dev"
app_host: str = "0.0.0.0"
app_port: int = 8000
db_path: str = "data/app.db"
session_secret: str = "change-me-session-secret"
web_admin_login: str = "admin"
web_admin_password: str = "admin"
max_bot_token: str = ""
max_bot_api_url: str = "https://botapi.max.ru"
max_use_webhook: bool = False
max_webhook_url: str = ""
max_webhook_secret: str = ""
context_backend: str = "memory"
redis_url: str = "redis://localhost:6379/0"
channel_local_enabled: bool = True
channel_external_enabled: bool = True
external_api_create_url_with_key: str = ""
operator_chat_ids: list[int] | None = None
request_categories: list[str] | None = None
@classmethod
def from_env(cls) -> "Settings":
operator_ids = [int(v) for v in _to_list(os.getenv("OPERATOR_CHAT_IDS")) if v.isdigit()]
categories = _to_list(os.getenv("REQUEST_CATEGORIES")) or [
"Общая консультация",
"Техническая проблема",
"Оплата",
"Другое",
]
return cls(
app_name=os.getenv("APP_NAME", "MAX Support Bot"),
app_env=os.getenv("APP_ENV", "dev"),
app_host=os.getenv("APP_HOST", "0.0.0.0"),
app_port=int(os.getenv("APP_PORT", "8000")),
db_path=os.getenv("DB_PATH", "data/app.db"),
session_secret=os.getenv("SESSION_SECRET", "change-me-session-secret"),
web_admin_login=os.getenv("WEB_ADMIN_LOGIN", "admin"),
web_admin_password=os.getenv("WEB_ADMIN_PASSWORD", "admin"),
max_bot_token=os.getenv("MAX_BOT_TOKEN", "").strip(),
max_bot_api_url=os.getenv("MAX_BOT_API_URL", "https://botapi.max.ru"),
max_use_webhook=_to_bool(os.getenv("MAX_USE_WEBHOOK"), False),
max_webhook_url=os.getenv("MAX_WEBHOOK_URL", "").strip(),
max_webhook_secret=os.getenv("MAX_WEBHOOK_SECRET", "").strip(),
context_backend=os.getenv("CONTEXT_BACKEND", "memory").strip().lower(),
redis_url=os.getenv("REDIS_URL", "redis://localhost:6379/0"),
channel_local_enabled=_to_bool(os.getenv("CHANNEL_LOCAL_ENABLED"), True),
channel_external_enabled=_to_bool(os.getenv("CHANNEL_EXTERNAL_ENABLED"), True),
external_api_create_url_with_key=os.getenv("EXTERNAL_API_CREATE_URL_WITH_KEY", "").strip(),
operator_chat_ids=operator_ids,
request_categories=categories,
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings.from_env()

61
app/container.py Normal file
View file

@ -0,0 +1,61 @@
from __future__ import annotations
from dataclasses import dataclass
from app.config import Settings
from app.db import Database
from app.repositories.applications import ApplicationRepository
from app.repositories.chats import ChatRepository
from app.repositories.users import UserRepository
from app.services.application_service import ApplicationService
from app.services.external_client import ExternalTicketClient
from app.services.request_state import MemoryRequestStateStore, RedisRequestStateStore, RequestStateStore
@dataclass(slots=True)
class Container:
settings: Settings
db: Database
applications: ApplicationRepository
chats: ChatRepository
users: UserRepository
app_service: ApplicationService
request_state: RequestStateStore
async def build_container(settings: Settings) -> Container:
db = Database(settings.db_path)
applications = ApplicationRepository(db)
chats = ChatRepository(db)
users = UserRepository(db)
external = ExternalTicketClient(settings.external_api_create_url_with_key)
app_service = ApplicationService(
repo=applications,
external_client=external,
channel_local_enabled=settings.channel_local_enabled,
channel_external_enabled=settings.channel_external_enabled,
)
request_state: RequestStateStore
if settings.context_backend == "redis":
try:
from redis.asyncio import Redis
redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
await redis_client.ping()
request_state = RedisRequestStateStore(redis_client)
except Exception:
request_state = MemoryRequestStateStore()
else:
request_state = MemoryRequestStateStore()
return Container(
settings=settings,
db=db,
applications=applications,
chats=chats,
users=users,
app_service=app_service,
request_state=request_state,
)

117
app/db.py Normal file
View file

@ -0,0 +1,117 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from app.config import Settings
from app.models import utc_now_iso
def ensure_db(settings: Settings) -> None:
db_path = Path(settings.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
conn.executescript(
"""
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
customer_name TEXT NOT NULL,
chat_id INTEGER,
category TEXT NOT NULL,
description TEXT NOT NULL,
attachments_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'new',
external_sync_status TEXT NOT NULL DEFAULT 'not_sent',
external_id TEXT,
external_error TEXT,
source TEXT NOT NULL DEFAULT 'bot',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_applications_status
ON applications(status);
CREATE INDEX IF NOT EXISTS idx_applications_created_at
ON applications(created_at);
CREATE TABLE IF NOT EXISTS application_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
details_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY(application_id) REFERENCES applications(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_events_application_id
ON application_events(application_id);
CREATE TABLE IF NOT EXISTS bot_chats (
chat_id INTEGER PRIMARY KEY,
chat_type TEXT NOT NULL,
title TEXT,
username TEXT,
last_seen_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
conn.commit()
class Database:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
def connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
return conn
@contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
conn = self.connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def ensure_admin_user_seed(db: Database, username: str, password_hash: str, role: str = "admin") -> None:
now = utc_now_iso()
with db.connect() as conn:
row = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
if row is None:
conn.execute(
"""
INSERT INTO users (username, password_hash, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
""",
(username, password_hash, role, now, now),
)
else:
conn.execute(
"UPDATE users SET password_hash = ?, role = ?, updated_at = ? WHERE username = ?",
(password_hash, role, now, username),
)
conn.commit()

54
app/main.py Normal file
View file

@ -0,0 +1,54 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from app.api.routes import build_router
from app.bot.runtime import BotRuntime
from app.config import get_settings
from app.container import build_container
from app.db import ensure_db
from app.security import hash_password
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
LOGGER = logging.getLogger(__name__)
def create_app(settings=None) -> FastAPI:
settings = settings or get_settings()
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_db(settings)
container = await build_container(settings)
container.users.upsert(settings.web_admin_login, hash_password(settings.web_admin_password), role="admin")
bot_runtime = BotRuntime(container)
await bot_runtime.startup()
app.state.settings = settings
app.state.container = container
app.state.bot_runtime = bot_runtime
LOGGER.info("Application startup complete.")
try:
yield
finally:
await bot_runtime.shutdown()
LOGGER.info("Application shutdown complete.")
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret, same_site="lax")
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
app.include_router(build_router(templates))
return app
app = create_app()

82
app/models.py Normal file
View file

@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import StrEnum
from typing import Any
class ApplicationStatus(StrEnum):
NEW = "new"
IN_PROGRESS = "in_progress"
NEED_INFO = "need_info"
DONE = "done"
REJECTED = "rejected"
class ExternalSyncStatus(StrEnum):
NOT_SENT = "not_sent"
SENT = "sent"
FAILED = "failed"
DISABLED = "disabled"
@dataclass(slots=True)
class ApplicationCreate:
customer_id: int | None
customer_name: str
chat_id: int | None
category: str
description: str
attachments_json: str = "[]"
source: str = "bot"
@dataclass(slots=True)
class ApplicationRecord:
id: int
customer_id: int | None
customer_name: str
chat_id: int | None
category: str
description: str
attachments_json: str
status: str
external_sync_status: str
external_id: str | None
external_error: str | None
source: str
created_at: str
updated_at: str
@dataclass(slots=True)
class ApplicationEventRecord:
id: int
application_id: int
event_type: str
actor: str
details_json: str
created_at: str
@dataclass(slots=True)
class ChatRecord:
chat_id: int
chat_type: str
title: str | None
username: str | None
last_seen_at: str
is_active: int
@dataclass(slots=True)
class ExternalCreateResult:
ok: bool
external_id: str | None = None
error: str | None = None
raw_response: dict[str, Any] | None = None
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

View file

@ -0,0 +1,2 @@
"""Repository layer."""

View file

@ -0,0 +1,178 @@
from __future__ import annotations
import json
from typing import Iterable
from app.db import Database
from app.models import (
ApplicationCreate,
ApplicationEventRecord,
ApplicationRecord,
ExternalSyncStatus,
utc_now_iso,
)
class ApplicationRepository:
def __init__(self, db: Database) -> None:
self._db = db
def create(self, payload: ApplicationCreate) -> ApplicationRecord:
now = utc_now_iso()
with self._db.connect() as conn:
cursor = conn.execute(
"""
INSERT INTO applications (
customer_id, customer_name, chat_id, category, description,
attachments_json, status, external_sync_status, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?)
""",
(
payload.customer_id,
payload.customer_name,
payload.chat_id,
payload.category,
payload.description,
payload.attachments_json,
ExternalSyncStatus.NOT_SENT.value,
payload.source,
now,
now,
),
)
app_id = cursor.lastrowid
self.add_event(
conn=conn,
application_id=app_id,
event_type="created",
actor=payload.source,
details={"category": payload.category},
)
row = conn.execute("SELECT * FROM applications WHERE id = ?", (app_id,)).fetchone()
conn.commit()
return ApplicationRecord(**dict(row))
def get(self, application_id: int) -> ApplicationRecord | None:
with self._db.connect() as conn:
row = conn.execute("SELECT * FROM applications WHERE id = ?", (application_id,)).fetchone()
return ApplicationRecord(**dict(row)) if row else None
def list(
self,
status: str | None = None,
search: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[ApplicationRecord]:
clauses: list[str] = []
params: list[object] = []
if status:
clauses.append("status = ?")
params.append(status)
if search:
clauses.append("(customer_name LIKE ? OR description LIKE ? OR CAST(id AS TEXT) LIKE ?)")
like = f"%{search}%"
params.extend([like, like, like])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset])
sql = f"""
SELECT * FROM applications
{where_sql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"""
with self._db.connect() as conn:
rows = conn.execute(sql, params).fetchall()
return [ApplicationRecord(**dict(row)) for row in rows]
def update_status(
self,
application_id: int,
status: str,
actor: str,
reason: str | None = None,
) -> ApplicationRecord | None:
now = utc_now_iso()
with self._db.connect() as conn:
exists = conn.execute("SELECT id FROM applications WHERE id = ?", (application_id,)).fetchone()
if not exists:
return None
conn.execute(
"UPDATE applications SET status = ?, updated_at = ? WHERE id = ?",
(status, now, application_id),
)
self.add_event(
conn=conn,
application_id=application_id,
event_type="status_changed",
actor=actor,
details={"status": status, "reason": reason},
)
row = conn.execute("SELECT * FROM applications WHERE id = ?", (application_id,)).fetchone()
conn.commit()
return ApplicationRecord(**dict(row))
def update_external_sync(
self,
application_id: int,
sync_status: str,
external_id: str | None,
external_error: str | None,
) -> None:
now = utc_now_iso()
with self._db.connect() as conn:
conn.execute(
"""
UPDATE applications
SET external_sync_status = ?, external_id = ?, external_error = ?, updated_at = ?
WHERE id = ?
""",
(sync_status, external_id, external_error, now, application_id),
)
self.add_event(
conn=conn,
application_id=application_id,
event_type="external_sync",
actor="external_api",
details={
"sync_status": sync_status,
"external_id": external_id,
"external_error": external_error,
},
)
conn.commit()
def get_events(self, application_id: int) -> list[ApplicationEventRecord]:
with self._db.connect() as conn:
rows = conn.execute(
"""
SELECT * FROM application_events
WHERE application_id = ?
ORDER BY created_at DESC
""",
(application_id,),
).fetchall()
return [ApplicationEventRecord(**dict(row)) for row in rows]
@staticmethod
def add_event(
conn,
application_id: int,
event_type: str,
actor: str,
details: dict[str, object] | None = None,
) -> None:
conn.execute(
"""
INSERT INTO application_events (application_id, event_type, actor, details_json, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
application_id,
event_type,
actor,
json.dumps(details or {}, ensure_ascii=False),
utc_now_iso(),
),
)

54
app/repositories/chats.py Normal file
View file

@ -0,0 +1,54 @@
from __future__ import annotations
from app.db import Database
from app.models import ChatRecord, utc_now_iso
class ChatRepository:
def __init__(self, db: Database) -> None:
self._db = db
def upsert(
self,
chat_id: int,
chat_type: str,
title: str | None = None,
username: str | None = None,
is_active: int = 1,
) -> None:
with self._db.connect() as conn:
conn.execute(
"""
INSERT INTO bot_chats (chat_id, chat_type, title, username, last_seen_at, is_active)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
chat_type = excluded.chat_type,
title = COALESCE(excluded.title, bot_chats.title),
username = COALESCE(excluded.username, bot_chats.username),
last_seen_at = excluded.last_seen_at,
is_active = excluded.is_active
""",
(chat_id, chat_type, title, username, utc_now_iso(), is_active),
)
conn.commit()
def deactivate(self, chat_id: int) -> None:
with self._db.connect() as conn:
conn.execute(
"UPDATE bot_chats SET is_active = 0, last_seen_at = ? WHERE chat_id = ?",
(utc_now_iso(), chat_id),
)
conn.commit()
def list(self, limit: int = 200) -> list[ChatRecord]:
with self._db.connect() as conn:
rows = conn.execute(
"""
SELECT * FROM bot_chats
ORDER BY last_seen_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [ChatRecord(**dict(row)) for row in rows]

37
app/repositories/users.py Normal file
View file

@ -0,0 +1,37 @@
from __future__ import annotations
from app.db import Database
from app.models import utc_now_iso
class UserRepository:
def __init__(self, db: Database) -> None:
self._db = db
def get_by_username(self, username: str) -> dict | None:
with self._db.connect() as conn:
row = conn.execute(
"SELECT id, username, password_hash, role FROM users WHERE username = ?",
(username,),
).fetchone()
return dict(row) if row else None
def upsert(self, username: str, password_hash: str, role: str = "admin") -> None:
now = utc_now_iso()
with self._db.connect() as conn:
existing = conn.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone()
if existing:
conn.execute(
"UPDATE users SET password_hash = ?, role = ?, updated_at = ? WHERE username = ?",
(password_hash, role, now, username),
)
else:
conn.execute(
"""
INSERT INTO users (username, password_hash, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
""",
(username, password_hash, role, now, now),
)
conn.commit()

33
app/security.py Normal file
View file

@ -0,0 +1,33 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return f"pbkdf2_sha256${salt}${digest.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algorithm, salt, digest_hex = password_hash.split("$", 2)
except ValueError:
return False
if algorithm != "pbkdf2_sha256":
return False
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return hmac.compare_digest(digest.hex(), digest_hex)
def mask_secret_url(url: str) -> str:
if "key=" not in url:
return url
prefix, suffix = url.split("key=", 1)
if "&" in suffix:
token, tail = suffix.split("&", 1)
return f"{prefix}key={token[:2]}***&{tail}"
return f"{prefix}key={suffix[:2]}***"

2
app/services/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Service layer."""

View file

@ -0,0 +1,166 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from app.models import (
ApplicationCreate,
ApplicationRecord,
ApplicationStatus,
ExternalSyncStatus,
)
from app.repositories.applications import ApplicationRepository
from app.services.external_client import ExternalTicketClient
@dataclass(slots=True)
class CreateOutcome:
application: ApplicationRecord | None
created_local: bool
sent_external: bool
external_error: str | None = None
class ApplicationService:
def __init__(
self,
repo: ApplicationRepository,
external_client: ExternalTicketClient,
channel_local_enabled: bool = True,
channel_external_enabled: bool = True,
) -> None:
self._repo = repo
self._external_client = external_client
self._channel_local_enabled = channel_local_enabled
self._channel_external_enabled = channel_external_enabled
@property
def channel_local_enabled(self) -> bool:
return self._channel_local_enabled
@property
def channel_external_enabled(self) -> bool:
return self._channel_external_enabled
async def create_application(
self,
customer_id: int | None,
customer_name: str,
chat_id: int | None,
category: str,
description: str,
attachments: list[dict],
source: str = "bot",
) -> CreateOutcome:
if not self._channel_local_enabled and not self._channel_external_enabled:
raise ValueError("Both creation channels are disabled.")
application: ApplicationRecord | None = None
if self._channel_local_enabled:
application = self._repo.create(
ApplicationCreate(
customer_id=customer_id,
customer_name=customer_name,
chat_id=chat_id,
category=category,
description=description,
attachments_json=json.dumps(attachments, ensure_ascii=False),
source=source,
)
)
elif self._channel_external_enabled:
# Локальный канал выключен: создаем "виртуальную" сущность без записи в БД.
# Для v1 это только транспорт в внешнюю систему.
application = ApplicationRecord(
id=0,
customer_id=customer_id,
customer_name=customer_name,
chat_id=chat_id,
category=category,
description=description,
attachments_json=json.dumps(attachments, ensure_ascii=False),
status=ApplicationStatus.NEW.value,
external_sync_status=ExternalSyncStatus.NOT_SENT.value,
external_id=None,
external_error=None,
source=source,
created_at="",
updated_at="",
)
if not self._channel_external_enabled:
if application and self._channel_local_enabled:
self._repo.update_external_sync(
application_id=application.id,
sync_status=ExternalSyncStatus.DISABLED.value,
external_id=None,
external_error="external_channel_disabled",
)
application = self._repo.get(application.id)
return CreateOutcome(
application=application,
created_local=self._channel_local_enabled,
sent_external=False,
)
if not self._external_client.enabled:
if application and self._channel_local_enabled:
self._repo.update_external_sync(
application_id=application.id,
sync_status=ExternalSyncStatus.FAILED.value,
external_id=None,
external_error="external_url_not_configured",
)
application = self._repo.get(application.id)
return CreateOutcome(
application=application,
created_local=self._channel_local_enabled,
sent_external=False,
external_error="external_url_not_configured",
)
assert application is not None
result = await self._external_client.create_ticket(application)
if result.ok:
if self._channel_local_enabled and application.id > 0:
self._repo.update_external_sync(
application_id=application.id,
sync_status=ExternalSyncStatus.SENT.value,
external_id=result.external_id,
external_error=None,
)
application = self._repo.get(application.id)
return CreateOutcome(
application=application,
created_local=self._channel_local_enabled,
sent_external=True,
)
if self._channel_local_enabled and application.id > 0:
self._repo.update_external_sync(
application_id=application.id,
sync_status=ExternalSyncStatus.FAILED.value,
external_id=None,
external_error=result.error,
)
application = self._repo.get(application.id)
return CreateOutcome(
application=application,
created_local=self._channel_local_enabled,
sent_external=False,
external_error=result.error,
)
def list_applications(self, status: str | None = None, search: str | None = None) -> list[ApplicationRecord]:
return self._repo.list(status=status, search=search)
def get_application(self, application_id: int) -> ApplicationRecord | None:
return self._repo.get(application_id)
def get_events(self, application_id: int):
return self._repo.get_events(application_id)
def update_status(self, application_id: int, status: str, actor: str, reason: str | None = None):
return self._repo.update_status(application_id, status, actor=actor, reason=reason)

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
import httpx
from app.models import ApplicationRecord, ExternalCreateResult
from app.security import mask_secret_url
class ExternalTicketClient:
def __init__(self, create_url_with_key: str, timeout: float = 10.0) -> None:
self._url = create_url_with_key.strip()
self._timeout = timeout
@property
def enabled(self) -> bool:
return bool(self._url)
@property
def safe_url(self) -> str:
return mask_secret_url(self._url)
async def create_ticket(self, application: ApplicationRecord) -> ExternalCreateResult:
if not self.enabled:
return ExternalCreateResult(ok=False, error="external_url_not_configured")
body: dict[str, Any] = {
"application_id": application.id,
"category": application.category,
"description": application.description,
"customer_name": application.customer_name,
"customer_id": application.customer_id,
"chat_id": application.chat_id,
"attachments_json": application.attachments_json,
"source": application.source,
"created_at": application.created_at,
}
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(self._url, json=body)
if 200 <= response.status_code < 300:
payload = response.json() if response.content else {}
external_id = str(
payload.get("id")
or payload.get("ticket_id")
or payload.get("result", {}).get("id")
or ""
).strip() or None
return ExternalCreateResult(ok=True, external_id=external_id, raw_response=payload)
return ExternalCreateResult(
ok=False,
error=f"http_{response.status_code}: {response.text[:600]}",
)
except Exception as exc:
return ExternalCreateResult(ok=False, error=str(exc))

View file

@ -0,0 +1,71 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
@dataclass(slots=True)
class RequestDraft:
category: str | None = None
description: str | None = None
attachments: list[dict] = field(default_factory=list)
state: str = "idle"
class RequestStateStore:
async def get(self, user_id: int) -> RequestDraft:
raise NotImplementedError
async def set(self, user_id: int, draft: RequestDraft) -> None:
raise NotImplementedError
async def clear(self, user_id: int) -> None:
raise NotImplementedError
class MemoryRequestStateStore(RequestStateStore):
def __init__(self) -> None:
self._storage: dict[int, RequestDraft] = {}
async def get(self, user_id: int) -> RequestDraft:
return self._storage.get(user_id, RequestDraft())
async def set(self, user_id: int, draft: RequestDraft) -> None:
self._storage[user_id] = draft
async def clear(self, user_id: int) -> None:
self._storage.pop(user_id, None)
class RedisRequestStateStore(RequestStateStore):
def __init__(self, redis_client, prefix: str = "maxbot:req:") -> None:
self._redis = redis_client
self._prefix = prefix
def _key(self, user_id: int) -> str:
return f"{self._prefix}{user_id}"
async def get(self, user_id: int) -> RequestDraft:
data = await self._redis.get(self._key(user_id))
if not data:
return RequestDraft()
payload = json.loads(data)
return RequestDraft(
category=payload.get("category"),
description=payload.get("description"),
attachments=payload.get("attachments") or [],
state=payload.get("state", "idle"),
)
async def set(self, user_id: int, draft: RequestDraft) -> None:
payload = {
"category": draft.category,
"description": draft.description,
"attachments": draft.attachments,
"state": draft.state,
}
await self._redis.set(self._key(user_id), json.dumps(payload, ensure_ascii=False), ex=60 * 60 * 6)
async def clear(self, user_id: int) -> None:
await self._redis.delete(self._key(user_id))

199
app/static/style.css Normal file
View file

@ -0,0 +1,199 @@
:root {
--bg: #f6f7fb;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--accent: #0f766e;
--border: #e5e7eb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color: var(--text);
background:
radial-gradient(circle at 0% 0%, #e2f8f3 0%, transparent 35%),
radial-gradient(circle at 100% 100%, #eaf1ff 0%, transparent 30%),
var(--bg);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: linear-gradient(90deg, #0f766e, #0e7490);
color: #fff;
}
.brand {
font-weight: 700;
letter-spacing: 0.3px;
}
.menu {
display: flex;
align-items: center;
gap: 12px;
}
.menu a {
color: #fff;
text-decoration: none;
}
.menu form {
margin: 0;
}
.container {
max-width: 1200px;
margin: 20px auto;
padding: 0 16px 40px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.login-card {
max-width: 420px;
margin: 8vh auto 0;
}
.muted {
color: var(--muted);
}
.error {
background: #fee2e2;
color: #991b1b;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
}
.form {
display: grid;
gap: 12px;
}
label {
display: grid;
gap: 6px;
font-size: 14px;
}
input,
select,
textarea,
button {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font: inherit;
}
button {
background: var(--accent);
color: #fff;
cursor: pointer;
}
button.ghost {
background: transparent;
border-color: rgba(255, 255, 255, 0.4);
}
.filters {
display: flex;
gap: 10px;
margin: 10px 0 16px;
flex-wrap: wrap;
}
.filters input {
min-width: 260px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 10px;
border-bottom: 1px solid var(--border);
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 99px;
background: #d1fae5;
color: #065f46;
font-size: 12px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: 1.2fr 1fr;
}
.events {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.events li {
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
background: #f9fafb;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block content %}
<section class="grid">
<article class="card">
<h1>Заявка #{{ application.id }}</h1>
<p><b>Клиент:</b> {{ application.customer_name }} ({{ application.customer_id or "n/a" }})</p>
<p><b>Чат:</b> {{ application.chat_id or "n/a" }}</p>
<p><b>Категория:</b> {{ application.category }}</p>
<p><b>Статус:</b> <span class="badge">{{ application.status }}</span></p>
<p><b>External Sync:</b> {{ application.external_sync_status }}</p>
<p><b>External ID:</b> {{ application.external_id or "—" }}</p>
<p><b>External Error:</b> {{ application.external_error or "—" }}</p>
<h2>Описание</h2>
<pre>{{ application.description }}</pre>
<h2>Вложения</h2>
{% if attachments %}
<pre>{{ attachments | tojson(indent=2) }}</pre>
{% else %}
<p class="muted">Нет вложений.</p>
{% endif %}
</article>
<article class="card">
<h2>Действие оператора</h2>
<form method="post" action="/applications/{{ application.id }}/status" class="form">
<label>
Новый статус
<select name="status_value">
{% for status in statuses %}
<option value="{{ status }}" {% if status == application.status %}selected{% endif %}>{{ status }}</option>
{% endfor %}
</select>
</label>
<label>
Комментарий
<textarea name="reason" rows="4" placeholder="Причина/комментарий"></textarea>
</label>
<button type="submit">Обновить статус</button>
</form>
<h2>История</h2>
<ul class="events">
{% for event in events %}
<li>
<div><b>{{ event.event_type }}</b> от {{ event.actor }}</div>
<div class="muted">{{ event.created_at }}</div>
<pre>{{ event.details_json }}</pre>
</li>
{% else %}
<li>Событий пока нет.</li>
{% endfor %}
</ul>
</article>
</section>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<h1>Заявки</h1>
<form method="get" action="/applications" class="filters">
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по ID / имени / описанию" />
<select name="status_filter">
<option value="">Все статусы</option>
{% for status in statuses %}
<option value="{{ status }}" {% if status == status_filter %}selected{% endif %}>{{ status }}</option>
{% endfor %}
</select>
<button type="submit">Фильтр</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Клиент</th>
<th>Категория</th>
<th>Статус</th>
<th>External Sync</th>
<th>Создано</th>
</tr>
</thead>
<tbody>
{% for app in applications %}
<tr>
<td><a href="/applications/{{ app.id }}">#{{ app.id }}</a></td>
<td>{{ app.customer_name }}</td>
<td>{{ app.category }}</td>
<td><span class="badge">{{ app.status }}</span></td>
<td>{{ app.external_sync_status }}</td>
<td>{{ app.created_at }}</td>
</tr>
{% else %}
<tr>
<td colspan="6">Заявок пока нет.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}

27
app/templates/base.html Normal file
View file

@ -0,0 +1,27 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "MAX Support" }}</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">MAX Support Bot</div>
{% if username %}
<nav class="menu">
<a href="/applications">Заявки</a>
<a href="/chats">Чаты</a>
<form action="/logout" method="post">
<button type="submit" class="ghost">Выйти ({{ username }})</button>
</form>
</nav>
{% endif %}
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

59
app/templates/chats.html Normal file
View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<section class="card">
<div class="row">
<h1>Чаты с ботом</h1>
<button id="sync-btn">Синхронизировать с MAX API</button>
</div>
<p class="muted">Гибридный режим: локальный кеш + ручной sync через API.</p>
<div id="sync-result" class="muted"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>chat_id</th>
<th>Тип</th>
<th>Название</th>
<th>Username</th>
<th>last_seen_at</th>
<th>active</th>
</tr>
</thead>
<tbody>
{% for chat in chats %}
<tr>
<td>{{ chat.chat_id }}</td>
<td>{{ chat.chat_type }}</td>
<td>{{ chat.title or "—" }}</td>
<td>{{ chat.username or "—" }}</td>
<td>{{ chat.last_seen_at }}</td>
<td>{{ "yes" if chat.is_active else "no" }}</td>
</tr>
{% else %}
<tr>
<td colspan="6">Чатов пока нет.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<script>
const btn = document.getElementById("sync-btn");
const result = document.getElementById("sync-result");
btn?.addEventListener("click", async () => {
btn.disabled = true;
result.textContent = "Синхронизация...";
try {
const response = await fetch("/chats/sync", { method: "POST" });
const data = await response.json();
result.textContent = `Готово. fetched=${data.fetched}, stored=${data.stored}. Обновите страницу.`;
} catch (err) {
result.textContent = "Ошибка синхронизации.";
} finally {
btn.disabled = false;
}
});
</script>
{% endblock %}

22
app/templates/login.html Normal file
View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<section class="card login-card">
<h1>Вход оператора</h1>
<p class="muted">Авторизация для веб-панели заявок.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form action="/login" method="post" class="form">
<label>
Логин
<input type="text" name="username" required />
</label>
<label>
Пароль
<input type="password" name="password" required />
</label>
<button type="submit">Войти</button>
</form>
</section>
{% endblock %}

34
docker-compose.yml Normal file
View file

@ -0,0 +1,34 @@
services:
max-support:
build: .
container_name: max-support-bot
ports:
- "8000:8000"
environment:
APP_ENV: prod
DB_PATH: /app/data/app.db
MAX_BOT_TOKEN: ${MAX_BOT_TOKEN:-}
MAX_USE_WEBHOOK: ${MAX_USE_WEBHOOK:-false}
MAX_WEBHOOK_SECRET: ${MAX_WEBHOOK_SECRET:-}
CONTEXT_BACKEND: ${CONTEXT_BACKEND:-memory}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
CHANNEL_LOCAL_ENABLED: ${CHANNEL_LOCAL_ENABLED:-true}
CHANNEL_EXTERNAL_ENABLED: ${CHANNEL_EXTERNAL_ENABLED:-true}
EXTERNAL_API_CREATE_URL_WITH_KEY: ${EXTERNAL_API_CREATE_URL_WITH_KEY:-}
WEB_ADMIN_LOGIN: ${WEB_ADMIN_LOGIN:-admin}
WEB_ADMIN_PASSWORD: ${WEB_ADMIN_PASSWORD:-admin}
SESSION_SECRET: ${SESSION_SECRET:-change-me}
OPERATOR_CHAT_IDS: ${OPERATOR_CHAT_IDS:-}
volumes:
- ./data:/app/data
restart: unless-stopped
depends_on:
- redis
redis:
image: redis:7-alpine
container_name: max-support-redis
restart: unless-stopped
ports:
- "6379:6379"

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi==0.118.0
uvicorn[standard]==0.37.0
jinja2==3.1.6
httpx==0.28.1
pydantic==2.11.9
maxapi>=0.9.17,<1.0
redis==6.2.0
python-multipart==0.0.20
pytest==8.4.2

41
tests/conftest.py Normal file
View file

@ -0,0 +1,41 @@
from __future__ import annotations
import uuid
from pathlib import Path
import pytest
from app.config import Settings
from app.db import Database, ensure_db
@pytest.fixture()
def test_settings() -> Settings:
data_dir = Path("data/testdb")
data_dir.mkdir(parents=True, exist_ok=True)
db_path = data_dir / f"test_{uuid.uuid4().hex}.db"
settings = Settings(
db_path=str(db_path),
session_secret="test-secret",
web_admin_login="admin",
web_admin_password="admin",
max_bot_token="",
channel_local_enabled=True,
channel_external_enabled=True,
external_api_create_url_with_key="https://example.com/create?key=abc",
context_backend="memory",
max_use_webhook=False,
operator_chat_ids=[],
request_categories=["A", "B", "C"],
)
ensure_db(settings)
yield settings
try:
db_path.unlink(missing_ok=True)
except Exception:
pass
@pytest.fixture()
def database(test_settings: Settings) -> Database:
return Database(test_settings.db_path)

View file

@ -0,0 +1,27 @@
from app.models import ApplicationCreate
from app.repositories.applications import ApplicationRepository
def test_repository_create_and_status_update(database):
repo = ApplicationRepository(database)
created = repo.create(
ApplicationCreate(
customer_id=1,
customer_name="Alice",
chat_id=42,
category="A",
description="Need help",
attachments_json="[]",
source="test",
)
)
assert created.id > 0
assert created.status == "new"
updated = repo.update_status(created.id, "in_progress", actor="tester")
assert updated is not None
assert updated.status == "in_progress"
events = repo.get_events(created.id)
assert len(events) >= 2

View file

@ -0,0 +1,95 @@
import asyncio
from app.models import ExternalCreateResult
from app.repositories.applications import ApplicationRepository
from app.services.application_service import ApplicationService
class ExternalClientOk:
enabled = True
async def create_ticket(self, application):
return ExternalCreateResult(ok=True, external_id="EXT-1")
class ExternalClientFail:
enabled = True
async def create_ticket(self, application):
return ExternalCreateResult(ok=False, error="boom")
def test_service_external_success(database):
repo = ApplicationRepository(database)
service = ApplicationService(
repo=repo,
external_client=ExternalClientOk(),
channel_local_enabled=True,
channel_external_enabled=True,
)
outcome = asyncio.run(
service.create_application(
customer_id=1,
customer_name="Bob",
chat_id=2,
category="A",
description="text",
attachments=[],
source="test",
)
)
assert outcome.created_local is True
assert outcome.sent_external is True
assert outcome.application is not None
assert outcome.application.external_sync_status == "sent"
def test_service_external_failure_keeps_local(database):
repo = ApplicationRepository(database)
service = ApplicationService(
repo=repo,
external_client=ExternalClientFail(),
channel_local_enabled=True,
channel_external_enabled=True,
)
outcome = asyncio.run(
service.create_application(
customer_id=1,
customer_name="Bob",
chat_id=2,
category="A",
description="text",
attachments=[],
source="test",
)
)
assert outcome.created_local is True
assert outcome.sent_external is False
assert outcome.external_error == "boom"
assert outcome.application is not None
assert outcome.application.external_sync_status == "failed"
def test_service_both_channels_disabled(database):
repo = ApplicationRepository(database)
service = ApplicationService(
repo=repo,
external_client=ExternalClientOk(),
channel_local_enabled=False,
channel_external_enabled=False,
)
try:
asyncio.run(
service.create_application(
customer_id=1,
customer_name="Bob",
chat_id=2,
category="A",
description="text",
attachments=[],
source="test",
)
)
assert False, "Expected ValueError"
except ValueError:
pass

10
tests/test_security.py Normal file
View file

@ -0,0 +1,10 @@
from app.security import hash_password, verify_password
def test_password_hash_roundtrip():
password = "s3cret"
hashed = hash_password(password)
assert hashed != password
assert verify_password(password, hashed) is True
assert verify_password("wrong", hashed) is False

71
tests/test_web.py Normal file
View file

@ -0,0 +1,71 @@
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
def _build_settings() -> Settings:
data_dir = Path("data/testdb")
data_dir.mkdir(parents=True, exist_ok=True)
db_path = data_dir / f"web_{uuid.uuid4().hex}.db"
return Settings(
db_path=str(db_path),
session_secret="test-secret",
web_admin_login="admin",
web_admin_password="admin",
max_bot_token="",
max_use_webhook=True,
max_webhook_secret="s3cr3t",
channel_local_enabled=True,
channel_external_enabled=False,
context_backend="memory",
operator_chat_ids=[],
request_categories=["A", "B"],
)
def test_login_and_applications_page():
app = create_app(_build_settings())
with TestClient(app) as client:
# Protected endpoint redirects to login.
response = client.get("/applications", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
response = client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/applications"
# Create one request via service and check list page.
outcome = asyncio.run(
client.app.state.container.app_service.create_application(
customer_id=1,
customer_name="Client",
chat_id=100,
category="A",
description="Need support",
attachments=[],
source="test",
)
)
assert outcome.application is not None
page = client.get("/applications")
assert page.status_code == 200
assert "Заявки" in page.text
assert f"#{outcome.application.id}" in page.text
def test_webhook_secret_validation():
app = create_app(_build_settings())
with TestClient(app) as client:
bad = client.post("/max/webhook", json={"foo": "bar"})
assert bad.status_code == 401
ok = client.post("/max/webhook", json={"foo": "bar"}, headers={"x-max-webhook-secret": "s3cr3t"})
assert ok.status_code == 200