commit a29c45ee32b9b31b0a7b3be8c9390d4f3dee1f63 Author: dv Date: Thu Mar 19 16:47:49 2026 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50367c8 --- /dev/null +++ b/.env.example @@ -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=Общая консультация,Техническая проблема,Оплата,Другое + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14e3934 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..1cc01d5 --- /dev/null +++ b/.woodpecker.yml @@ -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] + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3af8f8d --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f73cde4 --- /dev/null +++ b/README.md @@ -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` + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2ccc1b2 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""MAX support bot application package.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b33e063 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""HTTP routes for web UI and internal REST.""" + diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..f263083 --- /dev/null +++ b/app/api/deps.py @@ -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") + diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..ce82725 --- /dev/null +++ b/app/api/routes.py @@ -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 + diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..91a9ac9 --- /dev/null +++ b/app/bot/__init__.py @@ -0,0 +1,2 @@ +"""MAX bot runtime and handlers.""" + diff --git a/app/bot/handlers.py b/app/bot/handlers.py new file mode 100644 index 0000000..0873085 --- /dev/null +++ b/app/bot/handlers.py @@ -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") diff --git a/app/bot/runtime.py b/app/bot/runtime.py new file mode 100644 index 0000000..d3262a5 --- /dev/null +++ b/app/bot/runtime.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4d43585 --- /dev/null +++ b/app/config.py @@ -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() + diff --git a/app/container.py b/app/container.py new file mode 100644 index 0000000..b1d834a --- /dev/null +++ b/app/container.py @@ -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, + ) + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..e0f151b --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..960baec --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..868fbd2 --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..8c600d9 --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,2 @@ +"""Repository layer.""" + diff --git a/app/repositories/applications.py b/app/repositories/applications.py new file mode 100644 index 0000000..3efa534 --- /dev/null +++ b/app/repositories/applications.py @@ -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(), + ), + ) + diff --git a/app/repositories/chats.py b/app/repositories/chats.py new file mode 100644 index 0000000..49dfa4b --- /dev/null +++ b/app/repositories/chats.py @@ -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] + diff --git a/app/repositories/users.py b/app/repositories/users.py new file mode 100644 index 0000000..bf5abcc --- /dev/null +++ b/app/repositories/users.py @@ -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() + diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..c3b663a --- /dev/null +++ b/app/security.py @@ -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]}***" + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..f43b7b7 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service layer.""" + diff --git a/app/services/application_service.py b/app/services/application_service.py new file mode 100644 index 0000000..b23027c --- /dev/null +++ b/app/services/application_service.py @@ -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) + diff --git a/app/services/external_client.py b/app/services/external_client.py new file mode 100644 index 0000000..80b2cd9 --- /dev/null +++ b/app/services/external_client.py @@ -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)) + diff --git a/app/services/request_state.py b/app/services/request_state.py new file mode 100644 index 0000000..d763b0d --- /dev/null +++ b/app/services/request_state.py @@ -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)) + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..bfceac2 --- /dev/null +++ b/app/static/style.css @@ -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; + } +} + diff --git a/app/templates/application_detail.html b/app/templates/application_detail.html new file mode 100644 index 0000000..0166e0f --- /dev/null +++ b/app/templates/application_detail.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block content %} +
+
+

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

+

Клиент: {{ application.customer_name }} ({{ application.customer_id or "n/a" }})

+

Чат: {{ application.chat_id or "n/a" }}

+

Категория: {{ application.category }}

+

Статус: {{ application.status }}

+

External Sync: {{ application.external_sync_status }}

+

External ID: {{ application.external_id or "—" }}

+

External Error: {{ application.external_error or "—" }}

+

Описание

+
{{ application.description }}
+

Вложения

+ {% if attachments %} +
{{ attachments | tojson(indent=2) }}
+ {% else %} +

Нет вложений.

+ {% endif %} +
+ +
+

Действие оператора

+
+ + + +
+ +

История

+
    + {% for event in events %} +
  • +
    {{ event.event_type }} от {{ event.actor }}
    +
    {{ event.created_at }}
    +
    {{ event.details_json }}
    +
  • + {% else %} +
  • Событий пока нет.
  • + {% endfor %} +
+
+
+{% endblock %} + diff --git a/app/templates/applications.html b/app/templates/applications.html new file mode 100644 index 0000000..dd59025 --- /dev/null +++ b/app/templates/applications.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block content %} +
+

Заявки

+
+ + + +
+
+ + + + + + + + + + + + + {% for app in applications %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDКлиентКатегорияСтатусExternal SyncСоздано
#{{ app.id }}{{ app.customer_name }}{{ app.category }}{{ app.status }}{{ app.external_sync_status }}{{ app.created_at }}
Заявок пока нет.
+
+
+{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..0e51e8d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,27 @@ + + + + + + {{ title or "MAX Support" }} + + + +
+
MAX Support Bot
+ {% if username %} + + {% endif %} +
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/chats.html b/app/templates/chats.html new file mode 100644 index 0000000..4a5d4b4 --- /dev/null +++ b/app/templates/chats.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Чаты с ботом

+ +
+

Гибридный режим: локальный кеш + ручной sync через API.

+
+
+ + + + + + + + + + + + + {% for chat in chats %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
chat_idТипНазваниеUsernamelast_seen_atactive
{{ chat.chat_id }}{{ chat.chat_type }}{{ chat.title or "—" }}{{ chat.username or "—" }}{{ chat.last_seen_at }}{{ "yes" if chat.is_active else "no" }}
Чатов пока нет.
+
+
+ +{% endblock %} + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..96d3093 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +
+

Вход оператора

+

Авторизация для веб-панели заявок.

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + +
+
+{% endblock %} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..abb6237 --- /dev/null +++ b/docker-compose.yml @@ -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" + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7bdbdbb --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d8b56bb --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_application_repository.py b/tests/test_application_repository.py new file mode 100644 index 0000000..708e900 --- /dev/null +++ b/tests/test_application_repository.py @@ -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 + diff --git a/tests/test_application_service.py b/tests/test_application_service.py new file mode 100644 index 0000000..74f9903 --- /dev/null +++ b/tests/test_application_service.py @@ -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 diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..3eca5ea --- /dev/null +++ b/tests/test_security.py @@ -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 + diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..b054c09 --- /dev/null +++ b/tests/test_web.py @@ -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