init
This commit is contained in:
commit
a29c45ee32
39 changed files with 2734 additions and 0 deletions
25
.env.example
Normal file
25
.env.example
Normal 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
20
.gitignore
vendored
Normal 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
52
.woodpecker.yml
Normal 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
16
Dockerfile
Normal 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
60
README.md
Normal 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
2
app/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""MAX support bot application package."""
|
||||
|
||||
2
app/api/__init__.py
Normal file
2
app/api/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""HTTP routes for web UI and internal REST."""
|
||||
|
||||
15
app/api/deps.py
Normal file
15
app/api/deps.py
Normal 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
196
app/api/routes.py
Normal 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
2
app/bot/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""MAX bot runtime and handlers."""
|
||||
|
||||
438
app/bot/handlers.py
Normal file
438
app/bot/handlers.py
Normal 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
209
app/bot/runtime.py
Normal 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
85
app/config.py
Normal 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
61
app/container.py
Normal 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
117
app/db.py
Normal 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
54
app/main.py
Normal 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
82
app/models.py
Normal 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")
|
||||
2
app/repositories/__init__.py
Normal file
2
app/repositories/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Repository layer."""
|
||||
|
||||
178
app/repositories/applications.py
Normal file
178
app/repositories/applications.py
Normal 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
54
app/repositories/chats.py
Normal 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
37
app/repositories/users.py
Normal 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
33
app/security.py
Normal 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
2
app/services/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Service layer."""
|
||||
|
||||
166
app/services/application_service.py
Normal file
166
app/services/application_service.py
Normal 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)
|
||||
|
||||
58
app/services/external_client.py
Normal file
58
app/services/external_client.py
Normal 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))
|
||||
|
||||
71
app/services/request_state.py
Normal file
71
app/services/request_state.py
Normal 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
199
app/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
56
app/templates/application_detail.html
Normal file
56
app/templates/application_detail.html
Normal 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 %}
|
||||
|
||||
47
app/templates/applications.html
Normal file
47
app/templates/applications.html
Normal 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
27
app/templates/base.html
Normal 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
59
app/templates/chats.html
Normal 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
22
app/templates/login.html
Normal 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
34
docker-compose.yml
Normal 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
9
requirements.txt
Normal 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
41
tests/conftest.py
Normal 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)
|
||||
27
tests/test_application_repository.py
Normal file
27
tests/test_application_repository.py
Normal 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
|
||||
|
||||
95
tests/test_application_service.py
Normal file
95
tests/test_application_service.py
Normal 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
10
tests/test_security.py
Normal 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
71
tests/test_web.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue