Разработана первая версия приложений
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,33 @@
|
|||||||
|
# Local deployment configuration and secrets
|
||||||
|
backend/.env
|
||||||
|
backend/secrets/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# JavaScript / Expo / Playwright
|
||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Java
|
||||||
|
target/
|
||||||
|
|
||||||
|
# IDE and OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# HAN Chat MVP
|
||||||
|
|
||||||
|
Production-like MVP implementation described by `../architectory` and `../modules`.
|
||||||
|
|
||||||
|
The deployment entry point is `backend/docker-compose.yml`. Copy
|
||||||
|
`backend/.env.example` to `backend/.env`, provide external managed PostgreSQL,
|
||||||
|
Selectel S3 and Bitrix24 credentials, then follow `backend/deployment/RUNBOOK.md`.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Copy to .env, replace every placeholder, then run: ./scripts/validate-env .env
|
||||||
|
APP_ENV=production-like
|
||||||
|
RELEASE_VERSION=change-me-release
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
COMPOSE_PROJECT_NAME=han-chat
|
||||||
|
|
||||||
|
# Immutable service images built by their owning modules.
|
||||||
|
API_BACKEND_IMAGE=han-chat-api-backend:local
|
||||||
|
MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
|
||||||
|
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
|
||||||
|
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
|
||||||
|
KEYCLOAK_IMAGE=han-chat-keycloak:local
|
||||||
|
|
||||||
|
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
|
||||||
|
HAN_PG_HOST=managed-pg.private.example
|
||||||
|
HAN_PG_PORT=6432
|
||||||
|
HAN_PG_DATABASE=han_chat
|
||||||
|
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
|
||||||
|
DATABASE_URL=postgresql+asyncpg://han_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app
|
||||||
|
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local
|
||||||
|
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync%2Chan_app
|
||||||
|
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync
|
||||||
|
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety
|
||||||
|
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
|
KEYCLOAK_DB_USERNAME=keycloak_user
|
||||||
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
|
|
||||||
|
PUBLIC_HOST=chat.example.ru
|
||||||
|
PUBLIC_WEB_URL=https://chat.example.ru
|
||||||
|
PUBLIC_API_URL=https://chat.example.ru/api
|
||||||
|
PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||||
|
|
||||||
|
NGINX_HTTP_PORT=80
|
||||||
|
NGINX_HTTPS_PORT=443
|
||||||
|
NGINX_TLS_ENABLED=true
|
||||||
|
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||||
|
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||||
|
NGINX_HSTS_MAX_AGE=0
|
||||||
|
NGINX_CLIENT_MAX_BODY_SIZE=8m
|
||||||
|
NGINX_RATE_LIMIT_API=60r/m
|
||||||
|
NGINX_RATE_LIMIT_AUTH=10r/m
|
||||||
|
NGINX_RATE_LIMIT_PUBLIC=60r/m
|
||||||
|
NGINX_RATE_LIMIT_POLLING=60r/m
|
||||||
|
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
|
||||||
|
NGINX_RATE_LIMIT_BITRIX=120r/m
|
||||||
|
NGINX_RATE_LIMIT_WS=30r/m
|
||||||
|
NGINX_MESSAGE_READ_TIMEOUT_SEC=330
|
||||||
|
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
|
||||||
|
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||||
|
BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru
|
||||||
|
S3_CONNECT_SRC=https://s3.storage.selcloud.ru
|
||||||
|
|
||||||
|
FRONTEND_DEV_PROXY_ENABLED=false
|
||||||
|
EXPO_DEV_SERVER_URL=http://host.docker.internal:8081
|
||||||
|
ACME_EMAIL=ops@example.ru
|
||||||
|
|
||||||
|
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
|
||||||
|
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
|
||||||
|
KEYCLOAK_REALM=han-chat
|
||||||
|
KEYCLOAK_AUDIENCE=han-chat-api
|
||||||
|
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||||
|
KEYCLOAK_OTP_MOCK_CODE=change-me
|
||||||
|
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
|
||||||
|
KEYCLOAK_OTP_HMAC_KEY=change-me
|
||||||
|
KEYCLOAK_OTP_TTL_SEC=300
|
||||||
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||||
|
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||||
|
KEYCLOAK_ADMIN=bootstrap-admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
|
CURSOR_HMAC_SECRET=change-me
|
||||||
|
|
||||||
|
REDIS_API_PASSWORD=change-me
|
||||||
|
REDIS_SAFETY_PASSWORD=change-me
|
||||||
|
REDIS_HEALTH_PASSWORD=change-me
|
||||||
|
REDIS_URL=redis://api_backend:change-me@redis:6379/0
|
||||||
|
REDIS_REALTIME_URL=redis://api_backend:change-me@redis:6379/1
|
||||||
|
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:change-me@redis:6379/2
|
||||||
|
REDIS_MAXMEMORY=384mb
|
||||||
|
REDIS_EVICTION_POLICY=volatile-lru
|
||||||
|
|
||||||
|
MESSAGE_SAFETY_SERVICE_TOKEN=change-me
|
||||||
|
BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me
|
||||||
|
BITRIX_INTERNAL_API_TOKEN=change-me
|
||||||
|
BITRIX_API_FORWARD_TOKEN=change-me
|
||||||
|
BITRIX_API_INBOX_TOKEN=change-me
|
||||||
|
BITRIX_SYNC_SERVICE_TOKEN=change-me
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
|
||||||
|
|
||||||
|
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||||
|
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||||
|
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||||
|
MESSAGE_SAFETY_URL=http://message-safety:8080
|
||||||
|
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
|
||||||
|
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
|
||||||
|
MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300
|
||||||
|
MESSAGE_SAFETY_TASK_TTL_SEC=900
|
||||||
|
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
|
||||||
|
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
||||||
|
BITRIX_SYNC_ENABLED=false
|
||||||
|
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
|
||||||
|
BITRIX_SYNC_CRM_WEBHOOK_URL=change-me
|
||||||
|
BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60
|
||||||
|
BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30
|
||||||
|
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
|
||||||
|
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
|
||||||
|
BITRIX_SYNC_WEBHOOK_TOKEN=change-me
|
||||||
|
BITRIX_CLIENT_ID=change-me
|
||||||
|
BITRIX_CLIENT_SECRET=change-me
|
||||||
|
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||||
|
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||||
|
BITRIX_OPEN_LINE_ID=8
|
||||||
|
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
||||||
|
BITRIX_APPLICATION_TOKEN=change-me
|
||||||
|
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||||
|
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
||||||
|
BITRIX_HTTP_TIMEOUT_SEC=10
|
||||||
|
|
||||||
|
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
||||||
|
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
||||||
|
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
||||||
|
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||||
|
SELECTEL_S3_ACCESS_KEY=change-me
|
||||||
|
SELECTEL_S3_SECRET_KEY=change-me
|
||||||
|
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me
|
||||||
|
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
||||||
|
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||||
|
OTEL_REMOTE_AUTH_HEADER=change-me
|
||||||
|
OTEL_TRACES_SAMPLER_ARG=0.10
|
||||||
|
OTEL_QUEUE_SIZE=10000
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
scripts/validate-env text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
*.template text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
backups/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
tests
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip build
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN python -m build --wheel
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/dist/*.whl /tmp/
|
||||||
|
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY alembic ./alembic
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["uvicorn"]
|
||||||
|
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# HAN Chat API backend
|
||||||
|
|
||||||
|
FastAPI-сервис публичного API, internal Open Lines/settings API, WebSocket realtime,
|
||||||
|
Message Safety orchestration, S3 lifecycle и PostgreSQL workers.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Python 3.12+:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/Scripts/pip install -e ".[dev]"
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Миграции выполняются отдельным deployment step. Приложение не применяет DDL при старте.
|
||||||
|
|
||||||
|
Workers запускаются независимо:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
han-delivery-worker
|
||||||
|
han-safety-worker
|
||||||
|
han-cleanup-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
Сервис читает только инфраструктурные параметры и секреты из корневого `backend/.env`.
|
||||||
|
Локальный `.env` не коммитится. Business settings создаются миграцией в
|
||||||
|
`han_app.app_settings`.
|
||||||
|
|
||||||
|
Обязательны:
|
||||||
|
|
||||||
|
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`, `DATABASE_URL`;
|
||||||
|
- `REDIS_URL`, `REDIS_REALTIME_URL`;
|
||||||
|
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`,
|
||||||
|
`KEYCLOAK_AUDIENCE`;
|
||||||
|
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`,
|
||||||
|
`MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`,
|
||||||
|
`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`,
|
||||||
|
`MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
|
||||||
|
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`,
|
||||||
|
`BITRIX_API_INBOX_TOKEN`, `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`,
|
||||||
|
`BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`,
|
||||||
|
`BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
|
||||||
|
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
|
||||||
|
- `SELECTEL_S3_ENDPOINT_URL`, `SELECTEL_S3_BUCKET_DOCUMENTS`,
|
||||||
|
`SELECTEL_S3_BUCKET_ATTACHMENTS`, `SELECTEL_S3_BUCKET_QUARANTINE`,
|
||||||
|
`SELECTEL_S3_ACCESS_KEY`, `SELECTEL_S3_SECRET_KEY`;
|
||||||
|
- `CURSOR_HMAC_SECRET` — случайный секрет не короче 32 байт;
|
||||||
|
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
|
||||||
|
|
||||||
|
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
|
||||||
|
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
|
||||||
|
TLS, а internal endpoints — быть доступны только из backend-сети.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check .
|
||||||
|
ruff format --check .
|
||||||
|
mypy app
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
`/health/live` проверяет процесс. `/health/ready` проверяет критические зависимости и
|
||||||
|
возвращает `503`, если сервис не может безопасно обслуживать protected API.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from app.db import Base
|
||||||
|
from app.settings import get_settings
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
include_schemas=True,
|
||||||
|
version_table_schema="han_app",
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
context.configure(
|
||||||
|
url=config.get_main_option("sqlalchemy.url"),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
include_schemas=True,
|
||||||
|
version_table_schema="han_app",
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Initial han_app schema, seed and CRM sync triggers.
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-10
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
revision: str = "0001_initial"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
SEED = {
|
||||||
|
"auth.phone.enabled": ("true", "boolean", True),
|
||||||
|
"auth.password.enabled": ("false", "boolean", True),
|
||||||
|
"otp.phone.max_send_attempts_per_24h": ("3", "integer", False),
|
||||||
|
"otp.phone.min_seconds_between_attempts": ("30", "integer", False),
|
||||||
|
"operator.call.phone": ("+74999591007", "string", True),
|
||||||
|
"consent.personal_data.required": ("true", "boolean", True),
|
||||||
|
"consent.personal_data.document_url": (
|
||||||
|
"https://www.han0107.ru/privacy/persdata-agree-mobile",
|
||||||
|
"string",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
"consent.personal_data.version": ("2026-06-10", "string", True),
|
||||||
|
"consent.user_agreement.required": ("true", "boolean", True),
|
||||||
|
"consent.user_agreement.document_url": (
|
||||||
|
"https://www.han0107.ru/user-agreement",
|
||||||
|
"string",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
"consent.user_agreement.version": ("2026-06-10", "string", True),
|
||||||
|
"consent.marketing.required": ("false", "boolean", True),
|
||||||
|
"consent.marketing.version": ("2026-06-10", "string", True),
|
||||||
|
"chat.attachments.allowed_extensions": (
|
||||||
|
"jpg,jpeg,png,webp,heic,heif,pdf",
|
||||||
|
"string_list",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
"chat.attachments.allowed_mime_types": (
|
||||||
|
"image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf",
|
||||||
|
"string_list",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
"chat.attachments.disallowed_extensions": ("svg,doc,docx,xls,xlsx,csv", "string_list", False),
|
||||||
|
"chat.attachments.max_size_mb": ("5", "integer", True),
|
||||||
|
"chat.attachments.storage": ("selectel_s3", "string", False),
|
||||||
|
"chat.attachments.upload_mode": ("presigned_put", "string", False),
|
||||||
|
"chat.attachments.safety_scan_required": ("true", "boolean", False),
|
||||||
|
"chat.attachments.presigned_upload_ttl_seconds": ("600", "integer", False),
|
||||||
|
"rate_limit.message_send.per_user": ("30/minute", "string", False),
|
||||||
|
"rate_limit.message_send.per_dialog": ("20/minute", "string", False),
|
||||||
|
"rate_limit.download_url.per_user": ("60/hour", "string", False),
|
||||||
|
"rate_limit.public_endpoints.per_ip": ("60/minute", "string", False),
|
||||||
|
"rate_limit.login.per_ip": ("10/minute", "string", False),
|
||||||
|
"ux.session.idle_timeout_minutes": ("30", "integer", True),
|
||||||
|
"security.cors.allowed_origins": ("https://tohin.ru", "string_list", False),
|
||||||
|
"security.public_cache.max_age_seconds": ("3600", "integer", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||||
|
op.execute("CREATE SCHEMA IF NOT EXISTS han_app")
|
||||||
|
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
|
||||||
|
ON han_app.dialogs(user_id)
|
||||||
|
WHERE record_status='A'
|
||||||
|
AND status IN ('open','waiting_for_company','waiting_for_client');
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
|
||||||
|
ON han_app.client_profiles(bitrix_contact_id)
|
||||||
|
WHERE bitrix_contact_id IS NOT NULL AND record_status='A';
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = han_app, pg_temp
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_entity_id uuid;
|
||||||
|
v_task_type text;
|
||||||
|
v_dedup text;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||||
|
v_entity_id := NEW.id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
ELSE
|
||||||
|
v_entity_id := NEW.user_id;
|
||||||
|
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||||
|
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||||
|
END IF;
|
||||||
|
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||||
|
encode(digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||||
|
INSERT INTO han_app.sync_queue
|
||||||
|
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||||
|
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||||
|
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||||
|
now(), now(), now())
|
||||||
|
ON CONFLICT (dedup_key) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities;
|
||||||
|
CREATE TRIGGER trg_identity_contact_sync
|
||||||
|
AFTER INSERT OR UPDATE OF phone_number, record_status
|
||||||
|
ON han_app.user_identities
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||||
|
CREATE TRIGGER trg_profile_contact_sync
|
||||||
|
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
|
||||||
|
foreign_phone, email, record_status
|
||||||
|
ON han_app.client_profiles
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for key, (value, value_type, public) in SEED.items():
|
||||||
|
bind.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO han_app.app_settings
|
||||||
|
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, 'A', now())
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE SET
|
||||||
|
setting_value = EXCLUDED.setting_value,
|
||||||
|
value_type = EXCLUDED.value_type,
|
||||||
|
is_public = EXCLUDED.is_public,
|
||||||
|
record_status = 'A',
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
(key, value, value_type, public),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
raise RuntimeError("Initial data migration is forward-only")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HAN Chat API backend."""
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
import phonenumbers
|
||||||
|
from jwt import ExpiredSignatureError, InvalidTokenError, PyJWK
|
||||||
|
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
def __init__(self, code: str = "unauthorized") -> None:
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Principal:
|
||||||
|
subject: str
|
||||||
|
phone_number: str | None
|
||||||
|
claims: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class JWKSValidator:
|
||||||
|
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.http = http
|
||||||
|
self._keys: dict[str, PyJWK] = {}
|
||||||
|
self._loaded_at = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def refresh(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
discovery_url = (
|
||||||
|
f"{str(self.settings.keycloak_internal_url).rstrip('/')}"
|
||||||
|
f"/realms/{self.settings.keycloak_realm}/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
discovery = (await self.http.get(discovery_url, timeout=3)).raise_for_status().json()
|
||||||
|
jwks_uri = discovery["jwks_uri"]
|
||||||
|
public_prefix = str(self.settings.keycloak_public_url).rstrip("/")
|
||||||
|
internal_prefix = str(self.settings.keycloak_internal_url).rstrip("/")
|
||||||
|
if jwks_uri.startswith(public_prefix):
|
||||||
|
jwks_uri = internal_prefix + jwks_uri[len(public_prefix) :]
|
||||||
|
payload = (await self.http.get(jwks_uri, timeout=3)).raise_for_status().json()
|
||||||
|
self._keys = {
|
||||||
|
key["kid"]: PyJWK.from_dict(key)
|
||||||
|
for key in payload.get("keys", [])
|
||||||
|
if key.get("kid") and key.get("kty") == "RSA"
|
||||||
|
}
|
||||||
|
self._loaded_at = time.monotonic()
|
||||||
|
|
||||||
|
async def validate(self, token: str) -> Principal:
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
raise AuthError() from exc
|
||||||
|
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||||
|
raise AuthError()
|
||||||
|
kid = str(header["kid"])
|
||||||
|
stale = time.monotonic() - self._loaded_at > self.settings.jwks_cache_ttl_seconds
|
||||||
|
if stale or kid not in self._keys:
|
||||||
|
try:
|
||||||
|
await self.refresh()
|
||||||
|
except (httpx.HTTPError, KeyError, ValueError):
|
||||||
|
if (
|
||||||
|
kid not in self._keys
|
||||||
|
or time.monotonic() - self._loaded_at > self.settings.jwks_stale_grace_seconds
|
||||||
|
):
|
||||||
|
raise AuthError() from None
|
||||||
|
key = self._keys.get(kid)
|
||||||
|
if key is None:
|
||||||
|
raise AuthError()
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=self.settings.keycloak_audience,
|
||||||
|
issuer=self.settings.issuer,
|
||||||
|
options={"require": ["exp", "sub"]},
|
||||||
|
leeway=30,
|
||||||
|
)
|
||||||
|
except ExpiredSignatureError as exc:
|
||||||
|
raise AuthError("token_expired") from exc
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
raise AuthError() from exc
|
||||||
|
subject = claims.get("sub")
|
||||||
|
if not isinstance(subject, str) or not subject:
|
||||||
|
raise AuthError()
|
||||||
|
return Principal(subject, canonical_phone(claims), claims)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_phone(claims: dict[str, Any]) -> str | None:
|
||||||
|
for name in ("phone_number", "preferred_username"):
|
||||||
|
value = claims.get(name)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = phonenumbers.parse(value, None)
|
||||||
|
except phonenumbers.NumberParseException:
|
||||||
|
continue
|
||||||
|
if phonenumbers.is_valid_number(number) and value.startswith("+"):
|
||||||
|
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
|
||||||
|
return None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Operational command-line entry points."""
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
|
from app.db import AppSetting, Database
|
||||||
|
from app.settings import get_settings
|
||||||
|
|
||||||
|
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_seed(path: Path) -> list[dict[str, Any]]:
|
||||||
|
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
||||||
|
raise ValueError("settings file must have schema_version: 1")
|
||||||
|
settings = document.get("settings")
|
||||||
|
if not isinstance(settings, dict) or not settings:
|
||||||
|
raise ValueError("settings file must contain a non-empty settings mapping")
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for key, raw in settings.items():
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
raise ValueError("setting keys must be non-empty strings")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"{key}: setting must be a mapping")
|
||||||
|
value_type = raw.get("type")
|
||||||
|
if value_type not in VALUE_TYPES:
|
||||||
|
raise ValueError(f"{key}: unsupported type {value_type!r}")
|
||||||
|
if not isinstance(raw.get("public"), bool):
|
||||||
|
raise ValueError(f"{key}: public must be a boolean")
|
||||||
|
description = raw.get("description")
|
||||||
|
if description is not None and not isinstance(description, str):
|
||||||
|
raise ValueError(f"{key}: description must be a string")
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"setting_key": key,
|
||||||
|
"setting_value": serialize_value(key, value_type, raw.get("value")),
|
||||||
|
"value_type": value_type,
|
||||||
|
"is_public": raw["public"],
|
||||||
|
"description": description,
|
||||||
|
"record_status": "A",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_value(key: str, value_type: str, value: Any) -> str:
|
||||||
|
if value_type == "boolean":
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValueError(f"{key}: boolean value expected")
|
||||||
|
return str(value).lower()
|
||||||
|
if value_type == "integer":
|
||||||
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
|
raise ValueError(f"{key}: integer value expected")
|
||||||
|
return str(value)
|
||||||
|
if value_type == "string_list":
|
||||||
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||||
|
return ",".join(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
raise ValueError(f"{key}: string or list of strings expected")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{key}: string value expected")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(path: Path) -> int:
|
||||||
|
rows = load_seed(path)
|
||||||
|
database = Database(get_settings().database_url)
|
||||||
|
try:
|
||||||
|
async with database.sessions() as session:
|
||||||
|
for row in rows:
|
||||||
|
statement = insert(AppSetting).values(**row)
|
||||||
|
excluded = statement.excluded
|
||||||
|
statement = statement.on_conflict_do_update(
|
||||||
|
index_elements=[AppSetting.setting_key],
|
||||||
|
set_={
|
||||||
|
"setting_value": excluded.setting_value,
|
||||||
|
"value_type": excluded.value_type,
|
||||||
|
"is_public": excluded.is_public,
|
||||||
|
"description": excluded.description,
|
||||||
|
"record_status": "A",
|
||||||
|
"updated_at": func.now(),
|
||||||
|
},
|
||||||
|
where=or_(
|
||||||
|
AppSetting.setting_value.is_distinct_from(excluded.setting_value),
|
||||||
|
AppSetting.value_type.is_distinct_from(excluded.value_type),
|
||||||
|
AppSetting.is_public.is_distinct_from(excluded.is_public),
|
||||||
|
AppSetting.description.is_distinct_from(excluded.description),
|
||||||
|
AppSetting.record_status != "A",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await session.execute(statement)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Idempotently seed application settings")
|
||||||
|
parser.add_argument("--file", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
count = asyncio.run(seed(args.file))
|
||||||
|
print(f"Application settings seeded: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import AppSetting, Database
|
||||||
|
from app.services import REQUIRED_SETTINGS
|
||||||
|
from app.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def validate() -> int:
|
||||||
|
database = Database(get_settings().database_url)
|
||||||
|
try:
|
||||||
|
async with database.sessions() as session:
|
||||||
|
active_keys = set(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(AppSetting.setting_key).where(AppSetting.record_status == "A")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
missing = sorted(REQUIRED_SETTINGS - active_keys)
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"Mandatory application settings are missing: {', '.join(missing)}")
|
||||||
|
return len(REQUIRED_SETTINGS)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
count = asyncio.run(validate())
|
||||||
|
print(f"Mandatory application settings validated: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
SCHEMA = "han_app"
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
type_annotation_map = {dict[str, Any]: JSON}
|
||||||
|
|
||||||
|
|
||||||
|
class Common:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
|
||||||
|
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdentity(Common, Base):
|
||||||
|
__tablename__ = "user_identities"
|
||||||
|
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
|
||||||
|
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
|
||||||
|
phone_number: Mapped[str] = mapped_column(String(32))
|
||||||
|
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class ClientProfile(Common, Base):
|
||||||
|
__tablename__ = "client_profiles"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
|
||||||
|
)
|
||||||
|
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
citizenship: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
russian_phone: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
foreign_phone: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
email: Mapped[str | None] = mapped_column(String(320))
|
||||||
|
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class UserConsent(Common, Base):
|
||||||
|
__tablename__ = "user_consents"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "consent_type", "document_version"),
|
||||||
|
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||||
|
)
|
||||||
|
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
consent_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
document_version: Mapped[str] = mapped_column(String(64))
|
||||||
|
accepted: Mapped[bool] = mapped_column(Boolean)
|
||||||
|
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
client_ip: Mapped[str | None] = mapped_column(INET)
|
||||||
|
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class UxSession(Common, Base):
|
||||||
|
__tablename__ = "ux_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
|
||||||
|
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||||
|
)
|
||||||
|
start_reason: Mapped[str] = mapped_column(String(32))
|
||||||
|
platform: Mapped[str] = mapped_column(String(32))
|
||||||
|
app_version: Mapped[str] = mapped_column(String(64))
|
||||||
|
device_id_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class Dialog(Common, Base):
|
||||||
|
__tablename__ = "dialogs"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
|
||||||
|
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||||
|
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Common, Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("sender_type IN ('client','company')"),
|
||||||
|
CheckConstraint("content_kind IN ('text','file')"),
|
||||||
|
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
|
||||||
|
CheckConstraint(
|
||||||
|
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
|
||||||
|
),
|
||||||
|
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
|
||||||
|
UniqueConstraint("dialog_id", "client_idempotency_key"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
dialog_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
|
||||||
|
)
|
||||||
|
sender_type: Mapped[str] = mapped_column(String(16))
|
||||||
|
content_kind: Mapped[str] = mapped_column(String(16))
|
||||||
|
text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
safety_status: Mapped[str] = mapped_column(String(16))
|
||||||
|
delivery_status: Mapped[str] = mapped_column(String(16))
|
||||||
|
external_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class MessageAttachment(Common, Base):
|
||||||
|
__tablename__ = "message_attachments"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("direction IN ('client_upload','company_inbound')"),
|
||||||
|
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
|
||||||
|
CheckConstraint("size_bytes > 0"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
|
||||||
|
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
|
||||||
|
)
|
||||||
|
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||||
|
direction: Mapped[str] = mapped_column(String(32))
|
||||||
|
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||||
|
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
|
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||||
|
object_key: Mapped[str] = mapped_column(String(1024))
|
||||||
|
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Common, Base):
|
||||||
|
__tablename__ = "documents"
|
||||||
|
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64))
|
||||||
|
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||||
|
object_key: Mapped[str] = mapped_column(String(1024))
|
||||||
|
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyTask(Base):
|
||||||
|
__tablename__ = "safety_tasks"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
task_id: Mapped[str] = mapped_column(String(255), unique=True)
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||||
|
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
status: Mapped[str] = mapped_column(String(16))
|
||||||
|
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryOutbox(Base):
|
||||||
|
__tablename__ = "delivery_outbox"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||||
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyRecord(Base):
|
||||||
|
__tablename__ = "idempotency_records"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("scope", "user_id", "idempotency_key"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
scope: Mapped[str] = mapped_column(String(128))
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(128))
|
||||||
|
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
|
status: Mapped[str] = mapped_column(String(16))
|
||||||
|
response_status: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLinesInboxReceipt(Base):
|
||||||
|
__tablename__ = "openlines_inbox_receipts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("event_id"),
|
||||||
|
UniqueConstraint("external_chat_id", "bitrix_message_id"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(255))
|
||||||
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
event_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
|
status: Mapped[str] = mapped_column(String(16))
|
||||||
|
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(Base):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
request_id: Mapped[str] = mapped_column(String(64))
|
||||||
|
trace_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
ip: Mapped[str | None] = mapped_column(INET)
|
||||||
|
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
outcome: Mapped[str] = mapped_column(String(32))
|
||||||
|
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AppSetting(Base):
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
|
setting_value: Mapped[str] = mapped_column(Text)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TextResource(Common, Base):
|
||||||
|
__tablename__ = "text_resources"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("mnemonic", "locale"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||||
|
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||||
|
text_value: Mapped[str] = mapped_column(Text)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PopularQuestion(Common, Base):
|
||||||
|
__tablename__ = "popular_questions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("mnemonic", "locale"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||||
|
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||||
|
question_text: Mapped[str] = mapped_column(Text)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncQueue(Base):
|
||||||
|
__tablename__ = "sync_queue"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
task_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
dedup_key: Mapped[str] = mapped_column(String(255), unique=True)
|
||||||
|
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class EntityExternalMapping(Base):
|
||||||
|
__tablename__ = "entity_external_mapping"
|
||||||
|
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
external_id: Mapped[str] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
||||||
|
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||||
|
async with self.sessions() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.engine.dispose()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import httpx
|
||||||
|
from botocore.config import Config
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
RATE_LIMIT_LUA = """
|
||||||
|
local current = redis.call('INCR', KEYS[1])
|
||||||
|
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {current, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyFailure(Exception):
|
||||||
|
def __init__(self, code: str = "dependency_unavailable", timeout: bool = False) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CircuitBreaker:
|
||||||
|
threshold: int
|
||||||
|
open_seconds: float
|
||||||
|
failures: int = 0
|
||||||
|
opened_at: float | None = None
|
||||||
|
|
||||||
|
def allow(self) -> bool:
|
||||||
|
if self.opened_at is None:
|
||||||
|
return True
|
||||||
|
if time.monotonic() - self.opened_at >= self.open_seconds:
|
||||||
|
self.opened_at = None
|
||||||
|
self.failures = max(0, self.threshold - 1)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def success(self) -> None:
|
||||||
|
self.failures = 0
|
||||||
|
self.opened_at = None
|
||||||
|
|
||||||
|
def failure(self) -> None:
|
||||||
|
self.failures += 1
|
||||||
|
if self.failures >= self.threshold:
|
||||||
|
self.opened_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self, redis: Redis) -> None:
|
||||||
|
self.redis = redis
|
||||||
|
|
||||||
|
async def consume(self, key: str, limit: int, window: int) -> int:
|
||||||
|
try:
|
||||||
|
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
|
||||||
|
except Exception as exc:
|
||||||
|
raise DependencyFailure() from exc
|
||||||
|
if int(count) > limit:
|
||||||
|
return max(1, int(ttl))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def key(identity_type: str, identity: str, route: str, window: int) -> str:
|
||||||
|
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
|
||||||
|
bucket = int(time.time()) // window
|
||||||
|
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
|
||||||
|
|
||||||
|
|
||||||
|
class RedisIdempotency:
|
||||||
|
def __init__(self, redis: Redis) -> None:
|
||||||
|
self.redis = redis
|
||||||
|
|
||||||
|
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
|
||||||
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
|
||||||
|
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
|
||||||
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
await self.redis.set(
|
||||||
|
f"han:api:idem:{scope}:{user_id}:{key_hash}",
|
||||||
|
json.dumps(value, separators=(",", ":"), default=str),
|
||||||
|
ex=86400,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyClient:
|
||||||
|
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.http = http
|
||||||
|
self.breaker = CircuitBreaker(
|
||||||
|
settings.message_safety_circuit_failure_threshold,
|
||||||
|
settings.message_safety_circuit_open_sec,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"POST",
|
||||||
|
"/internal/safety/v1/messages/check",
|
||||||
|
request_id,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.settings.message_safety_post_timeout_sec,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def poll(self, task_id: str, request_id: str) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"GET",
|
||||||
|
f"/internal/safety/v1/messages/tasks/{task_id}",
|
||||||
|
request_id,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
if not self.breaker.allow():
|
||||||
|
raise DependencyFailure()
|
||||||
|
headers = {
|
||||||
|
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
|
||||||
|
"X-Request-ID": request_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await self.http.request(
|
||||||
|
method,
|
||||||
|
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
|
||||||
|
headers=headers,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure(timeout=True) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure() from exc
|
||||||
|
if response.status_code == 401 or response.status_code >= 500:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure()
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure() from exc
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure()
|
||||||
|
self.breaker.success()
|
||||||
|
body["_status"] = response.status_code
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLinesClient:
|
||||||
|
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.http = http
|
||||||
|
self.breaker = CircuitBreaker(
|
||||||
|
settings.bitrix_local_app_circuit_failure_threshold,
|
||||||
|
settings.bitrix_local_app_circuit_open_sec,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.breaker.allow():
|
||||||
|
raise DependencyFailure()
|
||||||
|
try:
|
||||||
|
response = await self.http.post(
|
||||||
|
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||||
|
"/internal/openlines/v1/messages",
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer "
|
||||||
|
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
|
||||||
|
"Idempotency-Key": str(message_id),
|
||||||
|
"X-Request-ID": request_id,
|
||||||
|
},
|
||||||
|
timeout=self.settings.bitrix_local_app_http_timeout_sec,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure(timeout=True) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
self.breaker.failure()
|
||||||
|
raise DependencyFailure() from exc
|
||||||
|
self.breaker.success()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def ready(self) -> bool:
|
||||||
|
try:
|
||||||
|
response = await self.http.get(
|
||||||
|
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||||
|
"/internal/openlines/v1/status",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer "
|
||||||
|
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
|
||||||
|
},
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
return response.is_success
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def fresh_openlines_payload(
|
||||||
|
payload: dict[str, Any], s3: "S3Client"
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = json.loads(json.dumps(payload, default=str))
|
||||||
|
for file in result.get("message", {}).get("files", []):
|
||||||
|
bucket = file.pop("_storage_bucket")
|
||||||
|
key = file.pop("_object_key")
|
||||||
|
file["download_url"] = await s3.presign_get(bucket, key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class S3Client:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||||
|
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||||
|
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||||
|
config=Config(connect_timeout=3, read_timeout=10, retries={"max_attempts": 2}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ready(self) -> bool:
|
||||||
|
try:
|
||||||
|
for bucket in (
|
||||||
|
self.settings.selectel_s3_bucket_quarantine,
|
||||||
|
self.settings.selectel_s3_bucket_attachments,
|
||||||
|
self.settings.selectel_s3_bucket_documents,
|
||||||
|
):
|
||||||
|
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.client.generate_presigned_url,
|
||||||
|
"put_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||||
|
"Key": key,
|
||||||
|
"ContentType": mime,
|
||||||
|
},
|
||||||
|
ExpiresIn=ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.client.generate_presigned_url,
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": bucket, "Key": key},
|
||||||
|
ExpiresIn=ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def head(self, bucket: str, key: str) -> dict[str, Any]:
|
||||||
|
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
|
||||||
|
|
||||||
|
async def promote(self, source_key: str, destination_key: str) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.client.copy_object,
|
||||||
|
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||||
|
Key=destination_key,
|
||||||
|
CopySource={
|
||||||
|
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||||
|
"Key": source_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.client.delete_object,
|
||||||
|
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||||
|
Key=source_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_quarantine(self, key: str) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.client.delete_object,
|
||||||
|
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_inbound(
|
||||||
|
self,
|
||||||
|
http: httpx.AsyncClient,
|
||||||
|
download_url: str,
|
||||||
|
destination_key: str,
|
||||||
|
mime_type: str,
|
||||||
|
max_bytes: int,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
parsed = urlparse(download_url)
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname:
|
||||||
|
raise DependencyFailure("unsafe_inbound_url")
|
||||||
|
try:
|
||||||
|
addresses = await asyncio.to_thread(
|
||||||
|
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise DependencyFailure("unsafe_inbound_url") from exc
|
||||||
|
if any(
|
||||||
|
ipaddress.ip_address(address[4][0]).is_private
|
||||||
|
or ipaddress.ip_address(address[4][0]).is_loopback
|
||||||
|
or ipaddress.ip_address(address[4][0]).is_link_local
|
||||||
|
or ipaddress.ip_address(address[4][0]).is_reserved
|
||||||
|
for address in addresses
|
||||||
|
):
|
||||||
|
raise DependencyFailure("unsafe_inbound_url")
|
||||||
|
data = bytearray()
|
||||||
|
try:
|
||||||
|
async with http.stream(
|
||||||
|
"GET", download_url, timeout=10, follow_redirects=False
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get("content-type", "").split(";")[0] != mime_type:
|
||||||
|
raise DependencyFailure("inbound_mime_mismatch")
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
data.extend(chunk)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise DependencyFailure("inbound_file_too_large")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise DependencyFailure() from exc
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.client.put_object,
|
||||||
|
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||||
|
Key=destination_key,
|
||||||
|
Body=bytes(data),
|
||||||
|
ContentType=mime_type,
|
||||||
|
)
|
||||||
|
return len(data), hashlib.sha256(data).hexdigest()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
CHANNEL_PREFIX = "han:realtime:dialog:"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFanout:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queues: set[asyncio.Queue[dict[str, Any]]] = set()
|
||||||
|
|
||||||
|
async def publish(self, event: dict[str, Any]) -> None:
|
||||||
|
for queue in tuple(self._queues):
|
||||||
|
with suppress(asyncio.QueueFull):
|
||||||
|
queue.put_nowait(event)
|
||||||
|
|
||||||
|
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
||||||
|
self._queues.add(queue)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
yield await queue.get()
|
||||||
|
finally:
|
||||||
|
self._queues.discard(queue)
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeFanout:
|
||||||
|
def __init__(self, redis: Redis, local: LocalFanout | None = None) -> None:
|
||||||
|
self.redis = redis
|
||||||
|
self.local = local or LocalFanout()
|
||||||
|
|
||||||
|
async def publish(self, event: dict[str, Any]) -> None:
|
||||||
|
event = {"event_id": str(uuid.uuid4()), **event}
|
||||||
|
channel = CHANNEL_PREFIX + str(event["dialog_id"])
|
||||||
|
try:
|
||||||
|
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||||
|
except Exception:
|
||||||
|
await self.local.publish(event)
|
||||||
|
|
||||||
|
async def events(self, dialog_ids: set[uuid.UUID]) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
channels = [CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
|
||||||
|
pubsub = self.redis.pubsub()
|
||||||
|
try:
|
||||||
|
await pubsub.subscribe(*channels)
|
||||||
|
except Exception:
|
||||||
|
await pubsub.aclose()
|
||||||
|
async for event in self.local.subscribe():
|
||||||
|
if uuid.UUID(str(event["dialog_id"])) in dialog_ids:
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
|
||||||
|
if message:
|
||||||
|
yield json.loads(message["data"])
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
finally:
|
||||||
|
await pubsub.unsubscribe(*channels)
|
||||||
|
await pubsub.aclose()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Device(StrictModel):
|
||||||
|
platform: Literal["ios", "android", "web"]
|
||||||
|
app_version: str = Field(min_length=1, max_length=64)
|
||||||
|
device_id: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentChoice(StrictModel):
|
||||||
|
accepted: bool
|
||||||
|
version: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentSet(StrictModel):
|
||||||
|
personal_data: ConsentChoice
|
||||||
|
user_agreement: ConsentChoice
|
||||||
|
marketing: ConsentChoice
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapRequest(StrictModel):
|
||||||
|
consents: ConsentSet
|
||||||
|
device: Device
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentsRequest(StrictModel):
|
||||||
|
consents: ConsentSet
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStartRequest(StrictModel):
|
||||||
|
start_reason: Literal["first_launch", "cold_start", "idle_timeout"]
|
||||||
|
device: Device
|
||||||
|
|
||||||
|
|
||||||
|
class TextMessageRequest(StrictModel):
|
||||||
|
content_kind: Literal["text"]
|
||||||
|
text: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class FileMessageRequest(StrictModel):
|
||||||
|
content_kind: Literal["file"]
|
||||||
|
attachment_id: uuid.UUID
|
||||||
|
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
MessageRequest = Annotated[
|
||||||
|
TextMessageRequest | FileMessageRequest, Field(discriminator="content_kind")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentInitRequest(StrictModel):
|
||||||
|
file_name: str = Field(min_length=1, max_length=255)
|
||||||
|
mime_type: str = Field(min_length=1, max_length=128)
|
||||||
|
size_bytes: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentCompleteRequest(StrictModel):
|
||||||
|
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLinesFile(StrictModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
mime_type: str = Field(min_length=1, max_length=128)
|
||||||
|
size_bytes: int = Field(gt=0)
|
||||||
|
download_url: HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLinesMessage(StrictModel):
|
||||||
|
text: str = Field(default="", max_length=4000)
|
||||||
|
files: list[OpenLinesFile] = Field(default_factory=list, max_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def non_empty(self) -> "OpenLinesMessage":
|
||||||
|
if not self.text.strip() and not self.files:
|
||||||
|
raise ValueError("message must contain text or file")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class OpenLinesInbox(StrictModel):
|
||||||
|
event_id: str = Field(min_length=1, max_length=255)
|
||||||
|
event_type: Literal["message.new", "dialog.closed"]
|
||||||
|
external_chat_id: uuid.UUID
|
||||||
|
bitrix_message_id: str | None = Field(default=None, max_length=255)
|
||||||
|
occurred_at: datetime
|
||||||
|
message: OpenLinesMessage | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def event_shape(self) -> "OpenLinesInbox":
|
||||||
|
if self.event_type == "message.new" and (
|
||||||
|
not self.bitrix_message_id or self.message is None
|
||||||
|
):
|
||||||
|
raise ValueError("message.new requires bitrix_message_id and message")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DialogStatus(StrEnum):
|
||||||
|
OPEN = "open"
|
||||||
|
WAITING_COMPANY = "waiting_for_company"
|
||||||
|
WAITING_CLIENT = "waiting_for_client"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_fingerprint(
|
||||||
|
method: str, route: str, path_params: dict[str, str], body: object, user_id: uuid.UUID
|
||||||
|
) -> str:
|
||||||
|
value = {
|
||||||
|
"method": method.upper(),
|
||||||
|
"route": route,
|
||||||
|
"path": dict(sorted(path_params.items())),
|
||||||
|
"body": body,
|
||||||
|
"user_id": str(user_id),
|
||||||
|
}
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(data: dict[str, str], secret: bytes) -> str:
|
||||||
|
payload = json.dumps({"v": 1, **data}, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
signature = hmac.digest(secret, payload, "sha256")
|
||||||
|
return urlsafe_b64encode(payload + signature).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(value: str, secret: bytes) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
raw = urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||||
|
payload, signature = raw[:-32], raw[-32:]
|
||||||
|
if not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")):
|
||||||
|
raise ValueError("invalid cursor")
|
||||||
|
decoded = json.loads(payload)
|
||||||
|
if decoded.pop("v") != 1:
|
||||||
|
raise ValueError("unsupported cursor")
|
||||||
|
return decoded
|
||||||
|
except (ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError("invalid cursor") from exc
|
||||||
@@ -0,0 +1,930 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import Principal
|
||||||
|
from app.db import (
|
||||||
|
AppSetting,
|
||||||
|
AuditEvent,
|
||||||
|
ClientProfile,
|
||||||
|
DeliveryOutbox,
|
||||||
|
Dialog,
|
||||||
|
Document,
|
||||||
|
IdempotencyRecord,
|
||||||
|
Message,
|
||||||
|
MessageAttachment,
|
||||||
|
OpenLinesInboxReceipt,
|
||||||
|
SafetyTask,
|
||||||
|
UserConsent,
|
||||||
|
UserIdentity,
|
||||||
|
UxSession,
|
||||||
|
)
|
||||||
|
from app.integrations import (
|
||||||
|
DependencyFailure,
|
||||||
|
OpenLinesClient,
|
||||||
|
S3Client,
|
||||||
|
SafetyClient,
|
||||||
|
fresh_openlines_payload,
|
||||||
|
)
|
||||||
|
from app.realtime import RealtimeFanout
|
||||||
|
from app.schemas import (
|
||||||
|
AttachmentCompleteRequest,
|
||||||
|
AttachmentInitRequest,
|
||||||
|
BootstrapRequest,
|
||||||
|
ConsentsRequest,
|
||||||
|
FileMessageRequest,
|
||||||
|
MessageRequest,
|
||||||
|
OpenLinesInbox,
|
||||||
|
SessionStartRequest,
|
||||||
|
encode_cursor,
|
||||||
|
)
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Exception):
|
||||||
|
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||||
|
self.code, self.status, self.message = code, status, message
|
||||||
|
self.details = details or {}
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_SETTINGS = {
|
||||||
|
"auth.phone.enabled",
|
||||||
|
"auth.password.enabled",
|
||||||
|
"otp.phone.max_send_attempts_per_24h",
|
||||||
|
"otp.phone.min_seconds_between_attempts",
|
||||||
|
"operator.call.phone",
|
||||||
|
"consent.personal_data.required",
|
||||||
|
"consent.personal_data.document_url",
|
||||||
|
"consent.personal_data.version",
|
||||||
|
"consent.user_agreement.required",
|
||||||
|
"consent.user_agreement.document_url",
|
||||||
|
"consent.user_agreement.version",
|
||||||
|
"consent.marketing.required",
|
||||||
|
"consent.marketing.version",
|
||||||
|
"chat.attachments.allowed_extensions",
|
||||||
|
"chat.attachments.allowed_mime_types",
|
||||||
|
"chat.attachments.max_size_mb",
|
||||||
|
"chat.attachments.presigned_upload_ttl_seconds",
|
||||||
|
"rate_limit.message_send.per_user",
|
||||||
|
"rate_limit.message_send.per_dialog",
|
||||||
|
"rate_limit.download_url.per_user",
|
||||||
|
"rate_limit.public_endpoints.per_ip",
|
||||||
|
"ux.session.idle_timeout_minutes",
|
||||||
|
"security.cors.allowed_origins",
|
||||||
|
"security.public_cache.max_age_seconds",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SettingsSnapshot:
|
||||||
|
values: dict[str, str]
|
||||||
|
version: str
|
||||||
|
|
||||||
|
def boolean(self, key: str) -> bool:
|
||||||
|
return self.values[key].lower() == "true"
|
||||||
|
|
||||||
|
def integer(self, key: str) -> int:
|
||||||
|
return int(self.values[key])
|
||||||
|
|
||||||
|
def strings(self, key: str) -> list[str]:
|
||||||
|
return [item.strip() for item in self.values[key].split(",") if item.strip()]
|
||||||
|
|
||||||
|
def limit(self, key: str) -> tuple[int, int]:
|
||||||
|
amount, period = self.values[key].split("/", 1)
|
||||||
|
windows = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
|
||||||
|
return int(amount), windows[period]
|
||||||
|
|
||||||
|
|
||||||
|
async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||||
|
rows = (
|
||||||
|
await session.execute(select(AppSetting).where(AppSetting.record_status == "A"))
|
||||||
|
).scalars()
|
||||||
|
values = {row.setting_key: row.setting_value for row in rows}
|
||||||
|
missing = REQUIRED_SETTINGS - values.keys()
|
||||||
|
if missing:
|
||||||
|
raise DomainError(
|
||||||
|
"dependency_unavailable",
|
||||||
|
503,
|
||||||
|
"Required settings are unavailable",
|
||||||
|
{"missing": sorted(missing)},
|
||||||
|
)
|
||||||
|
version = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:24]
|
||||||
|
return SettingsSnapshot(values, version)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_user(session: AsyncSession, principal: Principal) -> UserIdentity:
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(UserIdentity).where(
|
||||||
|
UserIdentity.keycloak_sub == principal.subject, UserIdentity.record_status == "A"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise DomainError("resource_state_conflict", 409, "Bootstrap required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def audit(
|
||||||
|
event_type: str,
|
||||||
|
request_id: str,
|
||||||
|
user_id: uuid.UUID | None,
|
||||||
|
resource_type: str | None = None,
|
||||||
|
resource_id: uuid.UUID | None = None,
|
||||||
|
ux_session_id: uuid.UUID | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
return AuditEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
actor_type="user" if user_id else "service",
|
||||||
|
user_id=user_id,
|
||||||
|
ux_session_id=ux_session_id,
|
||||||
|
request_id=request_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
outcome="success",
|
||||||
|
metadata_json=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_consents(consents: Any, snapshot: SettingsSnapshot) -> None:
|
||||||
|
for name in ("personal_data", "user_agreement", "marketing"):
|
||||||
|
choice = getattr(consents, name)
|
||||||
|
if choice.version != snapshot.values[f"consent.{name}.version"]:
|
||||||
|
raise DomainError(
|
||||||
|
"validation_error", 400, "Consent version is not current", {"field": name}
|
||||||
|
)
|
||||||
|
if snapshot.boolean(f"consent.{name}.required") and not choice.accepted:
|
||||||
|
raise DomainError("consents_required", 403, "Required consents must be accepted")
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap(
|
||||||
|
session: AsyncSession,
|
||||||
|
principal: Principal,
|
||||||
|
body: BootstrapRequest,
|
||||||
|
snapshot: SettingsSnapshot,
|
||||||
|
request_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not principal.phone_number:
|
||||||
|
raise DomainError("phone_claim_missing", 400, "Verified phone claim is missing")
|
||||||
|
validate_consents(body.consents, snapshot)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
statement = (
|
||||||
|
insert(UserIdentity)
|
||||||
|
.values(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
keycloak_sub=principal.subject,
|
||||||
|
phone_number=principal.phone_number,
|
||||||
|
last_login_at=now,
|
||||||
|
)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[UserIdentity.keycloak_sub],
|
||||||
|
set_={"phone_number": principal.phone_number, "last_login_at": now, "updated_at": now},
|
||||||
|
)
|
||||||
|
.returning(UserIdentity.id)
|
||||||
|
)
|
||||||
|
user_id = (await session.execute(statement)).scalar_one()
|
||||||
|
await session.execute(
|
||||||
|
insert(ClientProfile)
|
||||||
|
.values(id=uuid.uuid4(), user_id=user_id)
|
||||||
|
.on_conflict_do_nothing(index_elements=[ClientProfile.user_id])
|
||||||
|
)
|
||||||
|
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||||
|
choice = getattr(body.consents, consent_type)
|
||||||
|
await session.execute(
|
||||||
|
insert(UserConsent)
|
||||||
|
.values(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
consent_type=consent_type,
|
||||||
|
document_version=choice.version,
|
||||||
|
accepted=choice.accepted,
|
||||||
|
accepted_at=now,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(
|
||||||
|
index_elements=[
|
||||||
|
UserConsent.user_id,
|
||||||
|
UserConsent.consent_type,
|
||||||
|
UserConsent.document_version,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(audit("auth.bootstrap", request_id, user_id))
|
||||||
|
await session.commit()
|
||||||
|
return {"user_id": user_id, "profile_ready": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def record_consents(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: UserIdentity,
|
||||||
|
body: ConsentsRequest,
|
||||||
|
snapshot: SettingsSnapshot,
|
||||||
|
request_id: str,
|
||||||
|
ux_session_id: uuid.UUID | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
validate_consents(body.consents, snapshot)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
versions: dict[str, str] = {}
|
||||||
|
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||||
|
choice = getattr(body.consents, consent_type)
|
||||||
|
versions[consent_type] = choice.version
|
||||||
|
await session.execute(
|
||||||
|
insert(UserConsent)
|
||||||
|
.values(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user.id,
|
||||||
|
ux_session_id=ux_session_id,
|
||||||
|
consent_type=consent_type,
|
||||||
|
document_version=choice.version,
|
||||||
|
accepted=choice.accepted,
|
||||||
|
accepted_at=now,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing()
|
||||||
|
)
|
||||||
|
session.add(audit("consent.recorded", request_id, user.id, ux_session_id=ux_session_id))
|
||||||
|
await session.commit()
|
||||||
|
return {"recorded_at": now, "versions": versions}
|
||||||
|
|
||||||
|
|
||||||
|
async def start_session(
|
||||||
|
session: AsyncSession, user: UserIdentity, body: SessionStartRequest, request_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now, session_id = datetime.now(UTC), uuid.uuid4()
|
||||||
|
device_hash = (
|
||||||
|
hashlib.sha256(body.device.device_id.encode()).hexdigest()
|
||||||
|
if body.device.device_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
UxSession(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
start_reason=body.start_reason,
|
||||||
|
platform=body.device.platform,
|
||||||
|
app_version=body.device.app_version,
|
||||||
|
device_id_hash=device_hash,
|
||||||
|
started_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(audit("session_start", request_id, user.id, ux_session_id=session_id))
|
||||||
|
await session.commit()
|
||||||
|
return {"ux_session_id": session_id, "started_at": now}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_profile(session: AsyncSession, user: UserIdentity) -> dict[str, Any]:
|
||||||
|
profile = (
|
||||||
|
await session.execute(
|
||||||
|
select(ClientProfile).where(
|
||||||
|
ClientProfile.user_id == user.id, ClientProfile.record_status == "A"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
document_count = (
|
||||||
|
await session.scalar(
|
||||||
|
select(func.count(Document.id)).where(
|
||||||
|
Document.user_id == user.id, Document.record_status == "A"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"profile": {
|
||||||
|
"personal_data": {
|
||||||
|
"full_name": profile.full_name,
|
||||||
|
"citizenship": profile.citizenship,
|
||||||
|
"russian_phone": profile.russian_phone,
|
||||||
|
"foreign_phone": profile.foreign_phone,
|
||||||
|
"email": profile.email,
|
||||||
|
},
|
||||||
|
"documents": {"count": document_count},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_dto(dialog: Dialog) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"dialog_id": dialog.id,
|
||||||
|
"status": dialog.status,
|
||||||
|
"last_message_preview": None,
|
||||||
|
"unread_count": 0,
|
||||||
|
"created_at": dialog.created_at,
|
||||||
|
"updated_at": dialog.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message_dto(
|
||||||
|
message: Message, attachments: list[MessageAttachment] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"message_id": message.id,
|
||||||
|
"dialog_id": message.dialog_id,
|
||||||
|
"sender_type": message.sender_type,
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"text": message.text,
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"attachment_id": item.id,
|
||||||
|
"file_name": item.safe_file_name,
|
||||||
|
"mime_type": item.mime_type,
|
||||||
|
"size_bytes": item.size_bytes,
|
||||||
|
"scan_status": item.scan_status,
|
||||||
|
}
|
||||||
|
for item in (attachments or [])
|
||||||
|
],
|
||||||
|
"safety_status": message.safety_status,
|
||||||
|
"delivery_status": message.delivery_status,
|
||||||
|
"created_at": message.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def message_cursor(message: Message, settings: Settings) -> str:
|
||||||
|
return encode_cursor(
|
||||||
|
{"created_at": message.created_at.isoformat(), "id": str(message.id)},
|
||||||
|
settings.cursor_hmac_secret.get_secret_value().encode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_message(
|
||||||
|
fanout: RealtimeFanout,
|
||||||
|
message: Message,
|
||||||
|
settings: Settings,
|
||||||
|
attachments: list[MessageAttachment] | None = None,
|
||||||
|
) -> None:
|
||||||
|
await fanout.publish(
|
||||||
|
{
|
||||||
|
"type": "message.new",
|
||||||
|
"dialog_id": str(message.dialog_id),
|
||||||
|
"message": message_dto(message, attachments),
|
||||||
|
"cursor": message_cursor(message, settings),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_message_status(
|
||||||
|
fanout: RealtimeFanout, message: Message, settings: Settings
|
||||||
|
) -> None:
|
||||||
|
await fanout.publish(
|
||||||
|
{
|
||||||
|
"type": "message.status",
|
||||||
|
"dialog_id": str(message.dialog_id),
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"safety_status": message.safety_status,
|
||||||
|
"delivery_status": message.delivery_status,
|
||||||
|
"cursor": message_cursor(message, settings),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_dialog_status(fanout: RealtimeFanout, dialog: Dialog) -> None:
|
||||||
|
await fanout.publish(
|
||||||
|
{"type": "dialog.status", "dialog_id": str(dialog.id), "status": dialog.status}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def owned_dialog(session: AsyncSession, user_id: uuid.UUID, dialog_id: uuid.UUID) -> Dialog:
|
||||||
|
dialog = (
|
||||||
|
await session.execute(
|
||||||
|
select(Dialog).where(
|
||||||
|
Dialog.id == dialog_id, Dialog.user_id == user_id, Dialog.record_status == "A"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if dialog is None:
|
||||||
|
raise DomainError("not_found", 404, "Resource was not found")
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
|
||||||
|
async def create_dialog(
|
||||||
|
session: AsyncSession, user: UserIdentity, request_id: str, idempotency_key: str
|
||||||
|
) -> tuple[dict[str, Any], int]:
|
||||||
|
scope = "dialogs.create"
|
||||||
|
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
|
||||||
|
record = (
|
||||||
|
await session.execute(
|
||||||
|
select(IdempotencyRecord).where(
|
||||||
|
IdempotencyRecord.scope == scope,
|
||||||
|
IdempotencyRecord.user_id == user.id,
|
||||||
|
IdempotencyRecord.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record:
|
||||||
|
if record.request_fingerprint != fingerprint:
|
||||||
|
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||||
|
if record.status == "completed" and record.response_body_json:
|
||||||
|
return record.response_body_json, record.response_status or 200
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(Dialog).where(
|
||||||
|
Dialog.user_id == user.id,
|
||||||
|
Dialog.record_status == "A",
|
||||||
|
Dialog.status.in_(["open", "waiting_for_company", "waiting_for_client"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
result = dialog_dto(existing)
|
||||||
|
session.add(
|
||||||
|
IdempotencyRecord(
|
||||||
|
scope=scope,
|
||||||
|
user_id=user.id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_fingerprint=fingerprint,
|
||||||
|
status="completed",
|
||||||
|
response_status=200,
|
||||||
|
response_body_json=json.loads(json.dumps(result, default=str)),
|
||||||
|
resource_type="dialog",
|
||||||
|
resource_id=existing.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result, 200
|
||||||
|
dialog = Dialog(id=uuid.uuid4(), user_id=user.id, status="open")
|
||||||
|
session.add(dialog)
|
||||||
|
session.add(audit("dialog.created", request_id, user.id, "dialog", dialog.id))
|
||||||
|
result = dialog_dto(dialog)
|
||||||
|
session.add(
|
||||||
|
IdempotencyRecord(
|
||||||
|
scope=scope,
|
||||||
|
user_id=user.id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_fingerprint=fingerprint,
|
||||||
|
status="completed",
|
||||||
|
response_status=201,
|
||||||
|
response_body_json=json.loads(json.dumps(result, default=str)),
|
||||||
|
resource_type="dialog",
|
||||||
|
resource_id=dialog.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(dialog)
|
||||||
|
return dialog_dto(dialog), 201
|
||||||
|
|
||||||
|
|
||||||
|
async def init_attachment(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: UserIdentity,
|
||||||
|
dialog_id: uuid.UUID,
|
||||||
|
body: AttachmentInitRequest,
|
||||||
|
snapshot: SettingsSnapshot,
|
||||||
|
s3: S3Client,
|
||||||
|
request_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
await owned_dialog(session, user.id, dialog_id)
|
||||||
|
extension = PurePath(body.file_name).suffix.lower().lstrip(".")
|
||||||
|
if (
|
||||||
|
extension not in snapshot.strings("chat.attachments.allowed_extensions")
|
||||||
|
or body.mime_type not in snapshot.strings("chat.attachments.allowed_mime_types")
|
||||||
|
or body.size_bytes > snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
|
||||||
|
):
|
||||||
|
raise DomainError("validation_error", 400, "File type or size is not allowed")
|
||||||
|
attachment_id = uuid.uuid4()
|
||||||
|
key = f"quarantine/users/{user.id}/dialogs/{dialog_id}/{attachment_id}"
|
||||||
|
ttl = snapshot.integer("chat.attachments.presigned_upload_ttl_seconds")
|
||||||
|
expires = datetime.now(UTC) + timedelta(seconds=ttl)
|
||||||
|
safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKC", body.file_name))
|
||||||
|
item = MessageAttachment(
|
||||||
|
id=attachment_id,
|
||||||
|
dialog_id=dialog_id,
|
||||||
|
owner_user_id=user.id,
|
||||||
|
direction="client_upload",
|
||||||
|
original_file_name=body.file_name,
|
||||||
|
safe_file_name=safe_name,
|
||||||
|
mime_type=body.mime_type,
|
||||||
|
size_bytes=body.size_bytes,
|
||||||
|
scan_status="pending",
|
||||||
|
storage_bucket=s3.settings.selectel_s3_bucket_quarantine,
|
||||||
|
object_key=key,
|
||||||
|
quarantine_object_key=key,
|
||||||
|
upload_expires_at=expires,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.add(
|
||||||
|
audit("attachment.upload_initialized", request_id, user.id, "attachment", attachment_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
url = await s3.presign_put(key, body.mime_type, ttl)
|
||||||
|
return {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"upload_url": url,
|
||||||
|
"upload_headers": {"Content-Type": body.mime_type},
|
||||||
|
"expires_at": expires,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_attachment(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: UserIdentity,
|
||||||
|
dialog_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
body: AttachmentCompleteRequest,
|
||||||
|
s3: S3Client,
|
||||||
|
request_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
item = (
|
||||||
|
await session.execute(
|
||||||
|
select(MessageAttachment).where(
|
||||||
|
MessageAttachment.id == attachment_id,
|
||||||
|
MessageAttachment.dialog_id == dialog_id,
|
||||||
|
MessageAttachment.owner_user_id == user.id,
|
||||||
|
MessageAttachment.record_status == "A",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if item is None:
|
||||||
|
raise DomainError("not_found", 404, "Resource was not found")
|
||||||
|
checksum = body.checksum.removeprefix("sha256:")
|
||||||
|
if item.completed_at:
|
||||||
|
if item.checksum_sha256 != checksum:
|
||||||
|
raise DomainError("resource_state_conflict", 409, "Attachment checksum changed")
|
||||||
|
return attachment_dto(item)
|
||||||
|
try:
|
||||||
|
head = await s3.head(item.storage_bucket, item.object_key)
|
||||||
|
except Exception as exc:
|
||||||
|
raise DomainError("dependency_unavailable", 503, "Object storage is unavailable") from exc
|
||||||
|
if int(head["ContentLength"]) != item.size_bytes or head.get("ContentType") != item.mime_type:
|
||||||
|
raise DomainError("attachment_checksum_mismatch", 400, "Uploaded metadata does not match")
|
||||||
|
item.checksum_sha256 = checksum
|
||||||
|
item.completed_at = datetime.now(UTC)
|
||||||
|
session.add(audit("attachment.upload_completed", request_id, user.id, "attachment", item.id))
|
||||||
|
await session.commit()
|
||||||
|
return attachment_dto(item)
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_dto(item: MessageAttachment) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"attachment_id": item.id,
|
||||||
|
"file_name": item.safe_file_name,
|
||||||
|
"mime_type": item.mime_type,
|
||||||
|
"size_bytes": item.size_bytes,
|
||||||
|
"checksum": f"sha256:{item.checksum_sha256}" if item.checksum_sha256 else None,
|
||||||
|
"scan_status": item.scan_status,
|
||||||
|
"completed_at": item.completed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: UserIdentity,
|
||||||
|
dialog_id: uuid.UUID,
|
||||||
|
body: MessageRequest,
|
||||||
|
idem_key: str,
|
||||||
|
request_id: str,
|
||||||
|
settings: Settings,
|
||||||
|
safety: SafetyClient,
|
||||||
|
openlines: OpenLinesClient,
|
||||||
|
s3: S3Client,
|
||||||
|
fanout: RealtimeFanout,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
scope = f"dialogs.{dialog_id}.messages.create"
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
).hexdigest()
|
||||||
|
idem = (
|
||||||
|
await session.execute(
|
||||||
|
select(IdempotencyRecord).where(
|
||||||
|
IdempotencyRecord.scope == scope,
|
||||||
|
IdempotencyRecord.user_id == user.id,
|
||||||
|
IdempotencyRecord.idempotency_key == idem_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if idem:
|
||||||
|
if idem.request_fingerprint != fingerprint:
|
||||||
|
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
||||||
|
if idem.status == "completed" and idem.response_body_json:
|
||||||
|
if idem.response_status == 422:
|
||||||
|
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
|
||||||
|
return idem.response_body_json
|
||||||
|
prior = (
|
||||||
|
await session.execute(
|
||||||
|
select(Message).where(
|
||||||
|
Message.dialog_id == dialog_id,
|
||||||
|
Message.client_idempotency_key == idem_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if prior and prior.delivery_status == "delivered":
|
||||||
|
return message_dto(prior)
|
||||||
|
raise DomainError(
|
||||||
|
"dependency_unavailable",
|
||||||
|
503,
|
||||||
|
"Previous request is still being recovered",
|
||||||
|
{"retry_after": 2},
|
||||||
|
)
|
||||||
|
dialog = await owned_dialog(session, user.id, dialog_id)
|
||||||
|
now, message_id = datetime.now(UTC), uuid.uuid4()
|
||||||
|
idem = IdempotencyRecord(
|
||||||
|
scope=scope,
|
||||||
|
user_id=user.id,
|
||||||
|
idempotency_key=idem_key,
|
||||||
|
request_fingerprint=fingerprint,
|
||||||
|
status="in_progress",
|
||||||
|
resource_type="message",
|
||||||
|
resource_id=message_id,
|
||||||
|
expires_at=now + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
session.add(idem)
|
||||||
|
attachment: MessageAttachment | None = None
|
||||||
|
if isinstance(body, FileMessageRequest):
|
||||||
|
attachment = (
|
||||||
|
await session.execute(
|
||||||
|
select(MessageAttachment).where(
|
||||||
|
MessageAttachment.id == body.attachment_id,
|
||||||
|
MessageAttachment.owner_user_id == user.id,
|
||||||
|
MessageAttachment.dialog_id == dialog_id,
|
||||||
|
MessageAttachment.record_status == "A",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not attachment or not attachment.completed_at:
|
||||||
|
raise DomainError("attachment_not_completed", 400, "Attachment upload is incomplete")
|
||||||
|
if attachment.checksum_sha256 != body.checksum.removeprefix("sha256:"):
|
||||||
|
raise DomainError("attachment_checksum_mismatch", 400, "Attachment checksum differs")
|
||||||
|
text, kind = "", "file"
|
||||||
|
else:
|
||||||
|
text, kind = unicodedata.normalize("NFKC", body.text).strip(), "text"
|
||||||
|
message = Message(
|
||||||
|
id=message_id,
|
||||||
|
dialog_id=dialog_id,
|
||||||
|
sender_type="client",
|
||||||
|
content_kind=kind,
|
||||||
|
text=text,
|
||||||
|
safety_status="pending",
|
||||||
|
delivery_status="accepted",
|
||||||
|
client_idempotency_key=idem_key,
|
||||||
|
occurred_at=now,
|
||||||
|
)
|
||||||
|
session.add(message)
|
||||||
|
if attachment:
|
||||||
|
attachment.message_id = message_id
|
||||||
|
session.add(audit("message.submitted", request_id, user.id, "message", message_id))
|
||||||
|
await session.commit()
|
||||||
|
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||||
|
payload: dict[str, Any] = {"message_id": str(message_id), "content_kind": kind, "text": text}
|
||||||
|
if attachment:
|
||||||
|
payload["attachment"] = {
|
||||||
|
"attachment_id": str(attachment.id),
|
||||||
|
"quarantine_object_key": attachment.quarantine_object_key,
|
||||||
|
"checksum": f"sha256:{attachment.checksum_sha256}",
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"size_bytes": attachment.size_bytes,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
verdict = await safety.check(payload, request_id)
|
||||||
|
if verdict["_status"] == 203:
|
||||||
|
task_id = verdict["task_id"]
|
||||||
|
task = SafetyTask(
|
||||||
|
task_id=task_id,
|
||||||
|
message_id=message.id,
|
||||||
|
attachment_id=attachment.id if attachment else None,
|
||||||
|
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
||||||
|
status="polling",
|
||||||
|
deadline_at=now
|
||||||
|
+ timedelta(seconds=settings.message_safety_task_poll_max_sec + 900),
|
||||||
|
next_poll_at=now,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.commit()
|
||||||
|
deadline = time_monotonic() + settings.message_safety_task_poll_max_sec
|
||||||
|
while time_monotonic() < deadline:
|
||||||
|
await sleep(settings.message_safety_task_poll_interval_sec)
|
||||||
|
verdict = await safety.poll(task_id, request_id)
|
||||||
|
if verdict["_status"] != 203:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise DependencyFailure(timeout=True)
|
||||||
|
if verdict["_status"] == 403 or (
|
||||||
|
verdict["_status"] == 400
|
||||||
|
and verdict.get("error", {}).get("code") == "stub_final_error"
|
||||||
|
and verdict.get("verdict") == "deny"
|
||||||
|
):
|
||||||
|
message.text = ""
|
||||||
|
message.safety_status = "blocked"
|
||||||
|
message.delivery_status = "rejected"
|
||||||
|
if attachment and attachment.quarantine_object_key:
|
||||||
|
attachment.scan_status = "infected"
|
||||||
|
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||||
|
session.add(audit("message.blocked", request_id, user.id, "message", message.id))
|
||||||
|
idem.status = "completed"
|
||||||
|
idem.response_status = 422
|
||||||
|
idem.response_body_json = {
|
||||||
|
"error": {"code": "message_blocked", "message_id": str(message.id)}
|
||||||
|
}
|
||||||
|
await session.commit()
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
raise DomainError("message_blocked", 422, "Message was blocked by safety policy")
|
||||||
|
if verdict["_status"] != 200:
|
||||||
|
raise DependencyFailure()
|
||||||
|
if attachment and attachment.quarantine_object_key:
|
||||||
|
destination = f"attachments/dialogs/{dialog_id}/{attachment.id}"
|
||||||
|
await s3.promote(attachment.quarantine_object_key, destination)
|
||||||
|
attachment.storage_bucket = settings.selectel_s3_bucket_attachments
|
||||||
|
attachment.object_key = destination
|
||||||
|
attachment.quarantine_object_key = None
|
||||||
|
attachment.scan_status = "clean"
|
||||||
|
message.safety_status = "allowed"
|
||||||
|
outbox = DeliveryOutbox(
|
||||||
|
message_id=message.id,
|
||||||
|
external_chat_id=dialog_id,
|
||||||
|
payload_json={
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"external_chat_id": str(dialog_id),
|
||||||
|
"occurred_at": message.occurred_at.isoformat(),
|
||||||
|
"user": {"id": str(user.id), "display_name": user.phone_number},
|
||||||
|
"message": {
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"text": message.text,
|
||||||
|
"files": (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"attachment_id": str(attachment.id),
|
||||||
|
"name": attachment.safe_file_name,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"size_bytes": attachment.size_bytes,
|
||||||
|
"_storage_bucket": attachment.storage_bucket,
|
||||||
|
"_object_key": attachment.object_key,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if attachment
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
next_attempt_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(outbox)
|
||||||
|
await session.commit()
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
delivery_payload = await fresh_openlines_payload(outbox.payload_json, s3)
|
||||||
|
await openlines.send(message.id, delivery_payload, request_id)
|
||||||
|
message.delivery_status = "delivered"
|
||||||
|
dialog.status = "waiting_for_company"
|
||||||
|
dialog.last_message_at = datetime.now(UTC)
|
||||||
|
outbox.status = "delivered"
|
||||||
|
session.add(audit("message.delivered", request_id, user.id, "message", message.id))
|
||||||
|
result = message_dto(message, [attachment] if attachment else [])
|
||||||
|
idem.status = "completed"
|
||||||
|
idem.response_status = 201
|
||||||
|
idem.response_body_json = json.loads(json.dumps(result, default=str))
|
||||||
|
await session.commit()
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
await publish_dialog_status(fanout, dialog)
|
||||||
|
return result
|
||||||
|
except DomainError:
|
||||||
|
raise
|
||||||
|
except DependencyFailure as exc:
|
||||||
|
message.delivery_status = "failed"
|
||||||
|
session.add(audit("message.failed", request_id, user.id, "message", message.id))
|
||||||
|
await session.commit()
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
raise DomainError(
|
||||||
|
"dependency_timeout" if exc.timeout else "dependency_unavailable",
|
||||||
|
504 if exc.timeout else 503,
|
||||||
|
"A required dependency did not complete the request",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_inbox(
|
||||||
|
session: AsyncSession,
|
||||||
|
event: OpenLinesInbox,
|
||||||
|
request_id: str,
|
||||||
|
snapshot: SettingsSnapshot,
|
||||||
|
s3: S3Client,
|
||||||
|
http: httpx.AsyncClient,
|
||||||
|
settings: Settings,
|
||||||
|
fanout: RealtimeFanout,
|
||||||
|
) -> tuple[dict[str, Any], int]:
|
||||||
|
fingerprint = hashlib.sha256(event.model_dump_json().encode()).hexdigest()
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(OpenLinesInboxReceipt).where(OpenLinesInboxReceipt.event_id == event.event_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
if existing.payload_fingerprint != fingerprint:
|
||||||
|
raise DomainError("idempotency_key_reused", 409, "Event id was reused")
|
||||||
|
return {"status": "duplicate"}, 200
|
||||||
|
dialog = (
|
||||||
|
await session.execute(
|
||||||
|
select(Dialog).where(Dialog.id == event.external_chat_id, Dialog.record_status == "A")
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not dialog:
|
||||||
|
raise DomainError("not_found", 404, "Resource was not found")
|
||||||
|
receipt = OpenLinesInboxReceipt(
|
||||||
|
event_id=event.event_id,
|
||||||
|
external_chat_id=event.external_chat_id,
|
||||||
|
bitrix_message_id=event.bitrix_message_id,
|
||||||
|
event_type=event.event_type,
|
||||||
|
payload_fingerprint=fingerprint,
|
||||||
|
status="processing",
|
||||||
|
)
|
||||||
|
session.add(receipt)
|
||||||
|
message: Message | None = None
|
||||||
|
attachment: MessageAttachment | None = None
|
||||||
|
if event.event_type == "dialog.closed":
|
||||||
|
dialog.status = "closed"
|
||||||
|
dialog.closed_at = event.occurred_at
|
||||||
|
else:
|
||||||
|
assert event.message is not None
|
||||||
|
inbound_file = event.message.files[0] if event.message.files else None
|
||||||
|
attachment_data: tuple[str, int, str] | None = None
|
||||||
|
if inbound_file:
|
||||||
|
extension = PurePath(inbound_file.name).suffix.lower().lstrip(".")
|
||||||
|
max_bytes = snapshot.integer("chat.attachments.max_size_mb") * 1024 * 1024
|
||||||
|
if (
|
||||||
|
extension not in snapshot.strings("chat.attachments.allowed_extensions")
|
||||||
|
or inbound_file.mime_type
|
||||||
|
not in snapshot.strings("chat.attachments.allowed_mime_types")
|
||||||
|
or inbound_file.size_bytes > max_bytes
|
||||||
|
):
|
||||||
|
raise DomainError("validation_error", 400, "Inbound file is not allowed")
|
||||||
|
attachment_id = uuid.uuid4()
|
||||||
|
object_key = f"attachments/dialogs/{dialog.id}/{attachment_id}"
|
||||||
|
try:
|
||||||
|
actual_size, checksum = await s3.upload_inbound(
|
||||||
|
http,
|
||||||
|
str(inbound_file.download_url),
|
||||||
|
object_key,
|
||||||
|
inbound_file.mime_type,
|
||||||
|
max_bytes,
|
||||||
|
)
|
||||||
|
except DependencyFailure as exc:
|
||||||
|
raise DomainError(
|
||||||
|
"dependency_unavailable", 503, "Inbound file transfer failed"
|
||||||
|
) from exc
|
||||||
|
if actual_size != inbound_file.size_bytes:
|
||||||
|
raise DomainError("validation_error", 400, "Inbound file size differs")
|
||||||
|
attachment_data = object_key, actual_size, checksum
|
||||||
|
message = Message(
|
||||||
|
dialog_id=dialog.id,
|
||||||
|
sender_type="company",
|
||||||
|
content_kind="file" if inbound_file else "text",
|
||||||
|
text=event.message.text,
|
||||||
|
safety_status="allowed",
|
||||||
|
delivery_status="delivered",
|
||||||
|
external_message_id=event.bitrix_message_id,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
)
|
||||||
|
session.add(message)
|
||||||
|
await session.flush()
|
||||||
|
if inbound_file and attachment_data:
|
||||||
|
object_key, actual_size, checksum = attachment_data
|
||||||
|
safe_name = re.sub(
|
||||||
|
r"[^A-Za-z0-9._-]",
|
||||||
|
"_",
|
||||||
|
unicodedata.normalize("NFKC", inbound_file.name),
|
||||||
|
)
|
||||||
|
attachment = MessageAttachment(
|
||||||
|
dialog_id=dialog.id,
|
||||||
|
message_id=message.id,
|
||||||
|
owner_user_id=dialog.user_id,
|
||||||
|
direction="company_inbound",
|
||||||
|
original_file_name=inbound_file.name,
|
||||||
|
safe_file_name=safe_name,
|
||||||
|
mime_type=inbound_file.mime_type,
|
||||||
|
size_bytes=actual_size,
|
||||||
|
checksum_sha256=checksum,
|
||||||
|
scan_status="clean",
|
||||||
|
storage_bucket=s3.settings.selectel_s3_bucket_attachments,
|
||||||
|
object_key=object_key,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(attachment)
|
||||||
|
receipt.message_id = message.id
|
||||||
|
dialog.status = "waiting_for_client"
|
||||||
|
dialog.last_message_at = event.occurred_at
|
||||||
|
receipt.status = "applied"
|
||||||
|
session.add(audit("openlines.inbox_applied", request_id, dialog.user_id, "dialog", dialog.id))
|
||||||
|
await session.commit()
|
||||||
|
if message is not None:
|
||||||
|
await publish_message(fanout, message, settings, [attachment] if attachment else [])
|
||||||
|
await publish_dialog_status(fanout, dialog)
|
||||||
|
return {"status": "applied"}, 201
|
||||||
|
|
||||||
|
|
||||||
|
async def sleep(seconds: float) -> None:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def time_monotonic() -> float:
|
||||||
|
import time
|
||||||
|
|
||||||
|
return time.monotonic()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl, Field, SecretStr
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
app_env: str = Field(alias="APP_ENV")
|
||||||
|
api_port: int = Field(default=8000, alias="API_PORT")
|
||||||
|
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||||
|
database_url: str = Field(alias="DATABASE_URL")
|
||||||
|
redis_url: str = Field(alias="REDIS_URL")
|
||||||
|
redis_realtime_url: str = Field(alias="REDIS_REALTIME_URL")
|
||||||
|
|
||||||
|
keycloak_public_url: AnyHttpUrl = Field(alias="KEYCLOAK_PUBLIC_URL")
|
||||||
|
keycloak_internal_url: AnyHttpUrl = Field(alias="KEYCLOAK_INTERNAL_URL")
|
||||||
|
keycloak_realm: str = Field(alias="KEYCLOAK_REALM")
|
||||||
|
keycloak_audience: str = Field(alias="KEYCLOAK_AUDIENCE")
|
||||||
|
jwks_cache_ttl_seconds: int = 300
|
||||||
|
jwks_stale_grace_seconds: int = 900
|
||||||
|
|
||||||
|
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
|
||||||
|
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
|
||||||
|
message_safety_post_timeout_sec: float = Field(
|
||||||
|
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
|
||||||
|
)
|
||||||
|
message_safety_task_poll_interval_sec: float = Field(
|
||||||
|
default=2, alias="MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC"
|
||||||
|
)
|
||||||
|
message_safety_task_poll_max_sec: float = Field(
|
||||||
|
default=300, alias="MESSAGE_SAFETY_TASK_POLL_MAX_SEC"
|
||||||
|
)
|
||||||
|
message_safety_circuit_failure_threshold: int = Field(
|
||||||
|
default=5, alias="MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD"
|
||||||
|
)
|
||||||
|
message_safety_circuit_open_sec: int = Field(
|
||||||
|
default=30, alias="MESSAGE_SAFETY_CIRCUIT_OPEN_SEC"
|
||||||
|
)
|
||||||
|
|
||||||
|
bitrix_local_app_base_url: AnyHttpUrl = Field(alias="BITRIX_LOCAL_APP_BASE_URL")
|
||||||
|
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
|
||||||
|
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
|
||||||
|
bitrix_local_app_http_timeout_sec: float = Field(
|
||||||
|
default=10, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
|
||||||
|
)
|
||||||
|
bitrix_local_app_circuit_failure_threshold: int = Field(
|
||||||
|
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
|
||||||
|
)
|
||||||
|
bitrix_local_app_circuit_open_sec: int = Field(
|
||||||
|
default=30, alias="BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC"
|
||||||
|
)
|
||||||
|
keycloak_settings_bridge_token: SecretStr = Field(alias="KEYCLOAK_SETTINGS_BRIDGE_TOKEN")
|
||||||
|
|
||||||
|
selectel_s3_endpoint_url: AnyHttpUrl = Field(alias="SELECTEL_S3_ENDPOINT_URL")
|
||||||
|
selectel_s3_bucket_documents: str = Field(alias="SELECTEL_S3_BUCKET_DOCUMENTS")
|
||||||
|
selectel_s3_bucket_attachments: str = Field(alias="SELECTEL_S3_BUCKET_ATTACHMENTS")
|
||||||
|
selectel_s3_bucket_quarantine: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
|
||||||
|
selectel_s3_access_key: SecretStr = Field(alias="SELECTEL_S3_ACCESS_KEY")
|
||||||
|
selectel_s3_secret_key: SecretStr = Field(alias="SELECTEL_S3_SECRET_KEY")
|
||||||
|
otel_exporter_otlp_endpoint: str | None = Field(
|
||||||
|
default=None, alias="OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||||
|
)
|
||||||
|
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
|
||||||
|
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
|
||||||
|
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def issuer(self) -> str:
|
||||||
|
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis.asyncio as redis
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
|
||||||
|
from app.integrations import (
|
||||||
|
DependencyFailure,
|
||||||
|
OpenLinesClient,
|
||||||
|
S3Client,
|
||||||
|
SafetyClient,
|
||||||
|
fresh_openlines_payload,
|
||||||
|
)
|
||||||
|
from app.realtime import RealtimeFanout
|
||||||
|
from app.services import publish_dialog_status, publish_message_status
|
||||||
|
from app.settings import Settings, get_settings
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
async def delivery_once(
|
||||||
|
db: Database,
|
||||||
|
client: OpenLinesClient,
|
||||||
|
s3: S3Client,
|
||||||
|
fanout: RealtimeFanout,
|
||||||
|
settings: Settings,
|
||||||
|
worker_id: str,
|
||||||
|
batch_size: int = 20,
|
||||||
|
) -> int:
|
||||||
|
async with db.sessions() as session:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(DeliveryOutbox)
|
||||||
|
.where(
|
||||||
|
DeliveryOutbox.status.in_(["pending", "retry"]),
|
||||||
|
DeliveryOutbox.next_attempt_at <= datetime.now(UTC),
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids = [row.id for row in rows]
|
||||||
|
for row in rows:
|
||||||
|
row.status = "processing"
|
||||||
|
row.locked_at = datetime.now(UTC)
|
||||||
|
row.locked_by = worker_id
|
||||||
|
await session.commit()
|
||||||
|
for row_id in ids:
|
||||||
|
async with db.sessions() as session:
|
||||||
|
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
message = None
|
||||||
|
dialog = None
|
||||||
|
try:
|
||||||
|
payload = await fresh_openlines_payload(row.payload_json, s3)
|
||||||
|
await client.send(row.message_id, payload, f"worker-{worker_id}")
|
||||||
|
row.status = "delivered"
|
||||||
|
message = await session.get(Message, row.message_id)
|
||||||
|
if message:
|
||||||
|
message.delivery_status = "delivered"
|
||||||
|
dialog = await session.get(Dialog, message.dialog_id)
|
||||||
|
if dialog:
|
||||||
|
dialog.status = "waiting_for_company"
|
||||||
|
dialog.last_message_at = datetime.now(UTC)
|
||||||
|
except DependencyFailure:
|
||||||
|
row.attempt_count += 1
|
||||||
|
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
|
||||||
|
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=min(3600, 2**row.attempt_count)
|
||||||
|
)
|
||||||
|
row.last_error_code = "dependency_unavailable"
|
||||||
|
row.locked_at = None
|
||||||
|
row.locked_by = None
|
||||||
|
await session.commit()
|
||||||
|
if message:
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
if dialog:
|
||||||
|
await publish_dialog_status(fanout, dialog)
|
||||||
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def safety_once(
|
||||||
|
db: Database,
|
||||||
|
safety: SafetyClient,
|
||||||
|
s3: S3Client,
|
||||||
|
fanout: RealtimeFanout,
|
||||||
|
settings: Settings,
|
||||||
|
worker_id: str,
|
||||||
|
batch_size: int = 20,
|
||||||
|
) -> int:
|
||||||
|
async with db.sessions() as session:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(SafetyTask)
|
||||||
|
.where(
|
||||||
|
SafetyTask.status.in_(["polling", "failed"]),
|
||||||
|
SafetyTask.next_poll_at <= datetime.now(UTC),
|
||||||
|
SafetyTask.deadline_at > datetime.now(UTC),
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids = [row.id for row in rows]
|
||||||
|
for row in rows:
|
||||||
|
row.locked_at, row.locked_by = datetime.now(UTC), worker_id
|
||||||
|
await session.commit()
|
||||||
|
for task_id in ids:
|
||||||
|
async with db.sessions() as session:
|
||||||
|
task = await session.get(SafetyTask, task_id, with_for_update=True)
|
||||||
|
if task is None:
|
||||||
|
continue
|
||||||
|
message = None
|
||||||
|
try:
|
||||||
|
verdict = await safety.poll(task.task_id, f"worker-{worker_id}")
|
||||||
|
message = await session.get(Message, task.message_id)
|
||||||
|
attachment = (
|
||||||
|
await session.get(MessageAttachment, task.attachment_id)
|
||||||
|
if task.attachment_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if verdict["_status"] == 200 and message:
|
||||||
|
if attachment and attachment.quarantine_object_key:
|
||||||
|
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
|
||||||
|
await s3.promote(attachment.quarantine_object_key, destination)
|
||||||
|
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
|
||||||
|
attachment.object_key = destination
|
||||||
|
attachment.quarantine_object_key = None
|
||||||
|
attachment.scan_status = "clean"
|
||||||
|
message.safety_status = "allowed"
|
||||||
|
task.status = "completed"
|
||||||
|
elif verdict["_status"] == 403 and message:
|
||||||
|
message.text = ""
|
||||||
|
message.safety_status = "blocked"
|
||||||
|
message.delivery_status = "rejected"
|
||||||
|
if attachment and attachment.quarantine_object_key:
|
||||||
|
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||||
|
attachment.scan_status = "infected"
|
||||||
|
task.status = "completed"
|
||||||
|
else:
|
||||||
|
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
|
||||||
|
except DependencyFailure:
|
||||||
|
task.attempt_count += 1
|
||||||
|
task.status = "failed"
|
||||||
|
task.next_poll_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=min(300, 2**task.attempt_count)
|
||||||
|
)
|
||||||
|
task.locked_at = None
|
||||||
|
task.locked_by = None
|
||||||
|
await session.commit()
|
||||||
|
if message:
|
||||||
|
await publish_message_status(fanout, message, settings)
|
||||||
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int:
|
||||||
|
async with db.sessions() as session:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(MessageAttachment)
|
||||||
|
.where(
|
||||||
|
MessageAttachment.record_status == "A",
|
||||||
|
MessageAttachment.quarantine_object_key.is_not(None),
|
||||||
|
MessageAttachment.upload_expires_at < datetime.now(UTC),
|
||||||
|
MessageAttachment.message_id.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if row.quarantine_object_key:
|
||||||
|
await s3.delete_quarantine(row.quarantine_object_key)
|
||||||
|
row.record_status = "D"
|
||||||
|
row.status_changed_at = datetime.now(UTC)
|
||||||
|
row.status_change_reason = "expired_quarantine_cleanup"
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def loop(kind: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
db = Database(settings.database_url)
|
||||||
|
http = httpx.AsyncClient()
|
||||||
|
safety = SafetyClient(settings, http)
|
||||||
|
openlines = OpenLinesClient(settings, http)
|
||||||
|
s3 = S3Client(settings)
|
||||||
|
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||||
|
fanout = RealtimeFanout(redis_rt)
|
||||||
|
worker_id = f"{kind}-{uuid.uuid4()}"
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
count = 0
|
||||||
|
if kind == "delivery":
|
||||||
|
count = await delivery_once(db, openlines, s3, fanout, settings, worker_id)
|
||||||
|
elif kind == "safety":
|
||||||
|
count = await safety_once(db, safety, s3, fanout, settings, worker_id)
|
||||||
|
else:
|
||||||
|
count = await cleanup_once(db, s3)
|
||||||
|
if not count:
|
||||||
|
await asyncio.sleep(settings.worker_poll_interval_sec)
|
||||||
|
finally:
|
||||||
|
await http.aclose()
|
||||||
|
await redis_rt.aclose()
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_main() -> None:
|
||||||
|
asyncio.run(loop("delivery"))
|
||||||
|
|
||||||
|
|
||||||
|
def safety_main() -> None:
|
||||||
|
asyncio.run(loop("safety"))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_main() -> None:
|
||||||
|
asyncio.run(loop("cleanup"))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
services:
|
||||||
|
api-backend:
|
||||||
|
build: .
|
||||||
|
image: han-chat/api-backend:local
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV}
|
||||||
|
API_PORT: ${API_PORT:-8000}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
|
||||||
|
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
|
||||||
|
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
|
||||||
|
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||||
|
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||||
|
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||||
|
MESSAGE_SAFETY_SERVICE_TOKEN: ${MESSAGE_SAFETY_SERVICE_TOKEN}
|
||||||
|
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||||
|
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||||
|
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||||
|
MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD: ${MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||||
|
MESSAGE_SAFETY_CIRCUIT_OPEN_SEC: ${MESSAGE_SAFETY_CIRCUIT_OPEN_SEC:-30}
|
||||||
|
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
||||||
|
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
|
||||||
|
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
|
||||||
|
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-10}
|
||||||
|
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||||
|
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||||
|
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
|
||||||
|
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
|
||||||
|
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
|
||||||
|
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||||
|
SELECTEL_S3_ACCESS_KEY: ${SELECTEL_S3_ACCESS_KEY}
|
||||||
|
SELECTEL_S3_SECRET_KEY: ${SELECTEL_S3_SECRET_KEY}
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
|
||||||
|
CURSOR_HMAC_SECRET: ${CURSOR_HMAC_SECRET}
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=2)"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
- observability
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
observability:
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: HAN Chat API
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
paths:
|
||||||
|
/health/live:
|
||||||
|
get:
|
||||||
|
operationId: healthLive
|
||||||
|
responses:
|
||||||
|
"200": {description: Process is live}
|
||||||
|
/health/ready:
|
||||||
|
get:
|
||||||
|
operationId: healthReady
|
||||||
|
responses:
|
||||||
|
"200": {description: Ready or partially degraded}
|
||||||
|
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||||
|
/api/v1/public/app-config:
|
||||||
|
get:
|
||||||
|
operationId: getPublicAppConfig
|
||||||
|
responses:
|
||||||
|
"200": {description: Public application configuration}
|
||||||
|
/api/v1/public/content:
|
||||||
|
get:
|
||||||
|
operationId: getPublicContent
|
||||||
|
parameters:
|
||||||
|
- {name: locale, in: query, schema: {type: string, default: ru}}
|
||||||
|
responses:
|
||||||
|
"200": {description: Active UI content}
|
||||||
|
/api/v1/auth/bootstrap:
|
||||||
|
post:
|
||||||
|
operationId: bootstrap
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/BootstrapRequest"}
|
||||||
|
responses:
|
||||||
|
"200": {description: Local user resolved}
|
||||||
|
"401": {$ref: "#/components/responses/Unauthorized"}
|
||||||
|
/api/v1/consents:
|
||||||
|
post:
|
||||||
|
operationId: recordConsents
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/ConsentsRequest"}
|
||||||
|
responses:
|
||||||
|
"201": {description: Immutable consent records saved}
|
||||||
|
/api/v1/analytics/session-start:
|
||||||
|
post:
|
||||||
|
operationId: startUxSession
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/SessionStartRequest"}
|
||||||
|
responses:
|
||||||
|
"201": {description: UX session created}
|
||||||
|
/api/v1/me:
|
||||||
|
get:
|
||||||
|
operationId: getCurrentProfile
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Block profile}
|
||||||
|
/api/v1/me/documents:
|
||||||
|
get:
|
||||||
|
operationId: listDocuments
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Cursor-paginated documents}
|
||||||
|
/api/v1/documents/{document_id}:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DocumentId"}
|
||||||
|
get:
|
||||||
|
operationId: getDocument
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Document metadata}
|
||||||
|
"404": {$ref: "#/components/responses/NotFound"}
|
||||||
|
/api/v1/documents/{document_id}/download-url:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DocumentId"}
|
||||||
|
get:
|
||||||
|
operationId: getDocumentDownloadUrl
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Short-lived presigned GET}
|
||||||
|
/api/v1/dialogs:
|
||||||
|
post:
|
||||||
|
operationId: createDialog
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||||
|
responses:
|
||||||
|
"200": {description: Existing active dialog}
|
||||||
|
"201": {description: New active dialog}
|
||||||
|
get:
|
||||||
|
operationId: listDialogs
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Cursor-paginated dialogs}
|
||||||
|
/api/v1/dialogs/{dialog_id}:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DialogId"}
|
||||||
|
get:
|
||||||
|
operationId: getDialog
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Dialog summary}
|
||||||
|
"404": {$ref: "#/components/responses/NotFound"}
|
||||||
|
/api/v1/dialogs/{dialog_id}/messages:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DialogId"}
|
||||||
|
get:
|
||||||
|
operationId: listMessages
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {name: after, in: query, schema: {type: string}}
|
||||||
|
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
|
||||||
|
responses:
|
||||||
|
"200": {description: Message history or polling delta}
|
||||||
|
post:
|
||||||
|
operationId: sendMessage
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- {$ref: "#/components/schemas/TextMessageRequest"}
|
||||||
|
- {$ref: "#/components/schemas/FileMessageRequest"}
|
||||||
|
discriminator: {propertyName: content_kind}
|
||||||
|
responses:
|
||||||
|
"201": {description: Safety-allowed and delivered message}
|
||||||
|
"422": {description: Message blocked, content redacted}
|
||||||
|
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||||
|
/api/v1/dialogs/{dialog_id}/attachments/init:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DialogId"}
|
||||||
|
post:
|
||||||
|
operationId: initAttachment
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/AttachmentInitRequest"}
|
||||||
|
responses:
|
||||||
|
"201": {description: Upload metadata and presigned PUT}
|
||||||
|
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DialogId"}
|
||||||
|
- {$ref: "#/components/parameters/AttachmentId"}
|
||||||
|
post:
|
||||||
|
operationId: completeAttachment
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/ChecksumRequest"}
|
||||||
|
responses:
|
||||||
|
"200": {description: Upload metadata verified}
|
||||||
|
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url:
|
||||||
|
parameters:
|
||||||
|
- {$ref: "#/components/parameters/DialogId"}
|
||||||
|
- {$ref: "#/components/parameters/AttachmentId"}
|
||||||
|
get:
|
||||||
|
operationId: getAttachmentDownloadUrl
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Audited short-lived presigned GET}
|
||||||
|
/internal/openlines/v1/inbox:
|
||||||
|
post:
|
||||||
|
operationId: applyOpenLinesInbox
|
||||||
|
security: [{serviceBearer: []}]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: "#/components/schemas/OpenLinesInbox"}
|
||||||
|
responses:
|
||||||
|
"200": {description: Duplicate acknowledged}
|
||||||
|
"201": {description: Event applied}
|
||||||
|
/internal/settings/v1/otp:
|
||||||
|
get:
|
||||||
|
operationId: getOtpSettings
|
||||||
|
security: [{serviceBearer: []}]
|
||||||
|
responses:
|
||||||
|
"200": {description: Product OTP limits and cache metadata}
|
||||||
|
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||||
|
serviceBearer: {type: http, scheme: bearer}
|
||||||
|
parameters:
|
||||||
|
DialogId: {name: dialog_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||||
|
AttachmentId: {name: attachment_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||||
|
DocumentId: {name: document_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||||
|
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
|
responses:
|
||||||
|
Unauthorized:
|
||||||
|
description: JWT is absent, invalid or expired
|
||||||
|
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||||
|
NotFound:
|
||||||
|
description: Resource absent or owned by another user
|
||||||
|
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||||
|
DependencyUnavailable:
|
||||||
|
description: Required dependency is unavailable
|
||||||
|
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||||
|
schemas:
|
||||||
|
ErrorEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [error]
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
required: [code, message, request_id, details]
|
||||||
|
properties:
|
||||||
|
code: {type: string}
|
||||||
|
message: {type: string}
|
||||||
|
request_id: {type: string}
|
||||||
|
details: {type: object}
|
||||||
|
ConsentChoice:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [accepted, version]
|
||||||
|
properties:
|
||||||
|
accepted: {type: boolean}
|
||||||
|
version: {type: string, maxLength: 64}
|
||||||
|
ConsentSet:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [personal_data, user_agreement, marketing]
|
||||||
|
properties:
|
||||||
|
personal_data: {$ref: "#/components/schemas/ConsentChoice"}
|
||||||
|
user_agreement: {$ref: "#/components/schemas/ConsentChoice"}
|
||||||
|
marketing: {$ref: "#/components/schemas/ConsentChoice"}
|
||||||
|
Device:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [platform, app_version]
|
||||||
|
properties:
|
||||||
|
platform: {type: string, enum: [ios, android, web]}
|
||||||
|
app_version: {type: string, maxLength: 64}
|
||||||
|
device_id: {type: [string, "null"], maxLength: 255}
|
||||||
|
BootstrapRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [consents, device]
|
||||||
|
properties:
|
||||||
|
consents: {$ref: "#/components/schemas/ConsentSet"}
|
||||||
|
device: {$ref: "#/components/schemas/Device"}
|
||||||
|
ConsentsRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [consents]
|
||||||
|
properties: {consents: {$ref: "#/components/schemas/ConsentSet"}}
|
||||||
|
SessionStartRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [start_reason, device]
|
||||||
|
properties:
|
||||||
|
start_reason: {type: string, enum: [first_launch, cold_start, idle_timeout]}
|
||||||
|
device: {$ref: "#/components/schemas/Device"}
|
||||||
|
TextMessageRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [content_kind, text]
|
||||||
|
properties:
|
||||||
|
content_kind: {const: text}
|
||||||
|
text: {type: string, minLength: 1, maxLength: 4000}
|
||||||
|
FileMessageRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [content_kind, attachment_id, checksum]
|
||||||
|
properties:
|
||||||
|
content_kind: {const: file}
|
||||||
|
attachment_id: {type: string, format: uuid}
|
||||||
|
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||||
|
AttachmentInitRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [file_name, mime_type, size_bytes]
|
||||||
|
properties:
|
||||||
|
file_name: {type: string, minLength: 1, maxLength: 255}
|
||||||
|
mime_type: {type: string, minLength: 1, maxLength: 128}
|
||||||
|
size_bytes: {type: integer, minimum: 1}
|
||||||
|
ChecksumRequest:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [checksum]
|
||||||
|
properties:
|
||||||
|
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||||
|
OpenLinesInbox:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [event_id, event_type, external_chat_id, occurred_at]
|
||||||
|
properties:
|
||||||
|
event_id: {type: string, minLength: 1, maxLength: 255}
|
||||||
|
event_type: {type: string, enum: [message.new, dialog.closed]}
|
||||||
|
external_chat_id: {type: string, format: uuid}
|
||||||
|
bitrix_message_id: {type: [string, "null"], maxLength: 255}
|
||||||
|
occurred_at: {type: string, format: date-time}
|
||||||
|
message: {type: [object, "null"]}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
[project]
|
||||||
|
name = "han-api-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "HAN Chat FastAPI backend"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.16,<2",
|
||||||
|
"asyncpg>=0.30,<1",
|
||||||
|
"boto3>=1.39,<2",
|
||||||
|
"fastapi>=0.116,<1",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
|
"phonenumbers>=9,<10",
|
||||||
|
"prometheus-client>=0.22,<1",
|
||||||
|
"pydantic-settings>=2.10,<3",
|
||||||
|
"pyyaml>=6,<7",
|
||||||
|
"pyjwt[crypto]>=2.10,<3",
|
||||||
|
"redis>=6,<7",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||||
|
"structlog>=25,<26",
|
||||||
|
"uvicorn[standard]>=0.35,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"aiosqlite>=0.21,<1",
|
||||||
|
"mypy>=1.16,<2",
|
||||||
|
"pytest>=8.4,<9",
|
||||||
|
"pytest-asyncio>=1.0,<2",
|
||||||
|
"ruff>=0.12,<1",
|
||||||
|
"types-boto3-s3>=1.39,<2",
|
||||||
|
"types-PyYAML>=6,<7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
han-api = "app.main:run"
|
||||||
|
han-delivery-worker = "app.workers:delivery_main"
|
||||||
|
han-safety-worker = "app.workers:safety_main"
|
||||||
|
han-cleanup-worker = "app.workers:cleanup_main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
|
||||||
|
ignore = ["S101"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
disable_error_code = ["no-untyped-def", "misc", "arg-type", "assignment"]
|
||||||
|
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
|
||||||
|
exclude = ["alembic/"]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.integrations import (
|
||||||
|
DependencyFailure,
|
||||||
|
OpenLinesClient,
|
||||||
|
SafetyClient,
|
||||||
|
fresh_openlines_payload,
|
||||||
|
)
|
||||||
|
from app.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def settings() -> Settings:
|
||||||
|
common = {
|
||||||
|
"APP_ENV": "test",
|
||||||
|
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||||
|
"REDIS_URL": "redis://localhost/0",
|
||||||
|
"REDIS_REALTIME_URL": "redis://localhost/1",
|
||||||
|
"KEYCLOAK_PUBLIC_URL": "https://auth.example",
|
||||||
|
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
|
||||||
|
"KEYCLOAK_REALM": "han",
|
||||||
|
"KEYCLOAK_AUDIENCE": "api",
|
||||||
|
"MESSAGE_SAFETY_URL": "http://safety:8080",
|
||||||
|
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||||
|
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
|
||||||
|
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
|
||||||
|
"BITRIX_API_INBOX_TOKEN": "inbox-token",
|
||||||
|
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": "settings-token",
|
||||||
|
"SELECTEL_S3_ENDPOINT_URL": "https://s3.example",
|
||||||
|
"SELECTEL_S3_BUCKET_DOCUMENTS": "documents",
|
||||||
|
"SELECTEL_S3_BUCKET_ATTACHMENTS": "attachments",
|
||||||
|
"SELECTEL_S3_BUCKET_QUARANTINE": "quarantine",
|
||||||
|
"SELECTEL_S3_ACCESS_KEY": "access",
|
||||||
|
"SELECTEL_S3_SECRET_KEY": "secret",
|
||||||
|
"CURSOR_HMAC_SECRET": "x" * 32,
|
||||||
|
}
|
||||||
|
return Settings.model_validate(common)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_safety_contract_status_and_service_token() -> None:
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.headers["X-Service-Token"] == "safety-token"
|
||||||
|
assert request.url.path == "/internal/safety/v1/messages/check"
|
||||||
|
return httpx.Response(203, json={"verdict": "pending", "task_id": "task-1"})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||||
|
result = await SafetyClient(settings(), http).check(
|
||||||
|
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||||
|
"request-1",
|
||||||
|
)
|
||||||
|
assert result == {"verdict": "pending", "task_id": "task-1", "_status": 203}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_safety_file_body_uses_exact_attachment_schema() -> None:
|
||||||
|
attachment_id = uuid.uuid4()
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
body = __import__("json").loads(request.content)
|
||||||
|
assert body["attachment"] == {
|
||||||
|
"attachment_id": str(attachment_id),
|
||||||
|
"quarantine_object_key": "quarantine/users/u/file",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 42,
|
||||||
|
"checksum": "sha256:" + "a" * 64,
|
||||||
|
}
|
||||||
|
assert "file" not in body
|
||||||
|
return httpx.Response(200, json={"verdict": "allow"})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"message_id": str(uuid.uuid4()),
|
||||||
|
"content_kind": "file",
|
||||||
|
"text": "",
|
||||||
|
"attachment": {
|
||||||
|
"attachment_id": str(attachment_id),
|
||||||
|
"quarantine_object_key": "quarantine/users/u/file",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 42,
|
||||||
|
"checksum": "sha256:" + "a" * 64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||||
|
await SafetyClient(settings(), http).check(payload, "request-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_safety_auth_failure_is_dependency_failure() -> None:
|
||||||
|
async def handler(_: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(401, json={"error": {"code": "service_unauthorized"}})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||||
|
with pytest.raises(DependencyFailure):
|
||||||
|
await SafetyClient(settings(), http).check(
|
||||||
|
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||||
|
"request-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openlines_contract_uses_bearer_and_idempotency() -> None:
|
||||||
|
message_id = uuid.uuid4()
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.headers["Authorization"] == "Bearer bitrix-token"
|
||||||
|
assert request.headers["Idempotency-Key"] == str(message_id)
|
||||||
|
assert request.url.path == "/internal/openlines/v1/messages"
|
||||||
|
return httpx.Response(201, json={"status": "accepted"})
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||||
|
result = await OpenLinesClient(settings(), http).send(
|
||||||
|
message_id, {"message_id": str(message_id)}, "request-1"
|
||||||
|
)
|
||||||
|
assert result["status"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_openlines_payload_gets_fresh_download_url_without_storage_fields() -> None:
|
||||||
|
class FakeS3:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||||
|
self.calls += 1
|
||||||
|
return f"https://download.example/{bucket}/{key}?generation={self.calls}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"message_id": str(uuid.uuid4()),
|
||||||
|
"external_chat_id": str(uuid.uuid4()),
|
||||||
|
"occurred_at": "2026-07-10T12:00:00+00:00",
|
||||||
|
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
|
||||||
|
"message": {
|
||||||
|
"content_kind": "file",
|
||||||
|
"text": "",
|
||||||
|
"files": [{
|
||||||
|
"attachment_id": str(uuid.uuid4()),
|
||||||
|
"name": "file.pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 42,
|
||||||
|
"_storage_bucket": "attachments",
|
||||||
|
"_object_key": "dialogs/d/file",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s3 = FakeS3()
|
||||||
|
first = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||||
|
second = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||||
|
assert first["occurred_at"] and first["user"]["id"]
|
||||||
|
assert first["message"]["content_kind"] == "file"
|
||||||
|
assert (
|
||||||
|
first["message"]["files"][0]["download_url"]
|
||||||
|
!= second["message"]["files"][0]["download_url"]
|
||||||
|
)
|
||||||
|
assert all(not key.startswith("_") for key in first["message"]["files"][0])
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.main import app, websocket_token
|
||||||
|
|
||||||
|
EXPECTED_PATHS = {
|
||||||
|
"/health/live",
|
||||||
|
"/health/ready",
|
||||||
|
"/api/v1/public/app-config",
|
||||||
|
"/api/v1/public/content",
|
||||||
|
"/api/v1/auth/bootstrap",
|
||||||
|
"/api/v1/consents",
|
||||||
|
"/api/v1/analytics/session-start",
|
||||||
|
"/api/v1/me",
|
||||||
|
"/api/v1/me/documents",
|
||||||
|
"/api/v1/documents/{document_id}",
|
||||||
|
"/api/v1/documents/{document_id}/download-url",
|
||||||
|
"/api/v1/dialogs",
|
||||||
|
"/api/v1/dialogs/{dialog_id}",
|
||||||
|
"/api/v1/dialogs/{dialog_id}/messages",
|
||||||
|
"/api/v1/dialogs/{dialog_id}/attachments/init",
|
||||||
|
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
||||||
|
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
||||||
|
"/internal/openlines/v1/inbox",
|
||||||
|
"/internal/settings/v1/otp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_31_contains_all_http_contracts() -> None:
|
||||||
|
schema = app.openapi()
|
||||||
|
assert schema["openapi"].startswith("3.1.")
|
||||||
|
assert EXPECTED_PATHS <= schema["paths"].keys()
|
||||||
|
assert all(not path.startswith("/internal/safety") for path in schema["paths"])
|
||||||
|
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||||
|
assert committed["openapi"] == "3.1.0"
|
||||||
|
assert committed["paths"].keys() == schema["paths"].keys()
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_route_is_registered() -> None:
|
||||||
|
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
|
||||||
|
jwt = "header.payload.signature"
|
||||||
|
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
|
||||||
|
websocket = SimpleNamespace(
|
||||||
|
headers={"sec-websocket-protocol": f"han-chat-v1, han.jwt.{encoded}"},
|
||||||
|
query_params={},
|
||||||
|
)
|
||||||
|
assert websocket_token(websocket) == (jwt, f"han.jwt.{encoded}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||||
|
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||||
|
assert committed["servers"] == [{"url": "/"}]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.cli.seed_settings import load_seed
|
||||||
|
from app.services import REQUIRED_SETTINGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
||||||
|
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
|
||||||
|
rows = load_seed(path)
|
||||||
|
|
||||||
|
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
|
||||||
|
assert all(row["record_status"] == "A" for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
"schema_version: 1\nsettings:\n"
|
||||||
|
" bad.integer: {type: integer, value: nope, public: false}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="integer value expected"):
|
||||||
|
load_seed(path)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
|
from app.auth import canonical_phone
|
||||||
|
from app.integrations import CircuitBreaker, RateLimiter
|
||||||
|
from app.schemas import (
|
||||||
|
FileMessageRequest,
|
||||||
|
MessageRequest,
|
||||||
|
TextMessageRequest,
|
||||||
|
canonical_fingerprint,
|
||||||
|
decode_cursor,
|
||||||
|
encode_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_claim_priority_and_e164_validation() -> None:
|
||||||
|
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
||||||
|
assert canonical_phone(claims) == "+74999591007"
|
||||||
|
assert canonical_phone({"phone_number": "89999999999"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_discriminated_union() -> None:
|
||||||
|
adapter = TypeAdapter(MessageRequest)
|
||||||
|
assert isinstance(
|
||||||
|
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
|
||||||
|
TextMessageRequest,
|
||||||
|
)
|
||||||
|
assert isinstance(
|
||||||
|
adapter.validate_python(
|
||||||
|
{
|
||||||
|
"content_kind": "file",
|
||||||
|
"attachment_id": str(uuid.uuid4()),
|
||||||
|
"checksum": "sha256:" + "a" * 64,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
FileMessageRequest,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
adapter.validate_python(
|
||||||
|
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||||
|
user = uuid.uuid4()
|
||||||
|
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||||
|
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
|
||||||
|
assert first == second
|
||||||
|
assert first != canonical_fingerprint(
|
||||||
|
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_roundtrip_and_tamper_rejection() -> None:
|
||||||
|
secret = b"test-secret" * 4
|
||||||
|
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
|
||||||
|
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
|
||||||
|
with pytest.raises(ValueError, match="invalid cursor"):
|
||||||
|
decode_cursor(cursor[:-2] + "aa", secret)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_keys_do_not_expose_identity() -> None:
|
||||||
|
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
|
||||||
|
assert "203.0.113.7" not in key
|
||||||
|
assert key.startswith("han:api:rl:ip:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
clock = [10.0]
|
||||||
|
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
|
||||||
|
breaker = CircuitBreaker(2, 30)
|
||||||
|
breaker.failure()
|
||||||
|
breaker.failure()
|
||||||
|
assert not breaker.allow()
|
||||||
|
clock[0] = 41.0
|
||||||
|
assert breaker.allow()
|
||||||
|
breaker.success()
|
||||||
|
assert breaker.allow()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
|
WORKDIR /service
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY alembic.ini pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://unused
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=config.get_main_option("sqlalchemy.url"),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
include_schemas=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run(connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_online() -> None:
|
||||||
|
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(do_run)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_online())
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Create durable OAuth, mapping, inbox, outbox, setup and audit storage."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
revision = "0001_bitrix_local"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_local")
|
||||||
|
Base.metadata.create_all(bind=bind, checkfirst=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
Base.metadata.drop_all(bind=op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HAN Bitrix24 Open Lines adapter."""
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
SCHEMA = "bitrix_local"
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Common:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||||
|
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||||
|
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
|
||||||
|
|
||||||
|
class PortalInstallation(Common, Base):
|
||||||
|
__tablename__ = "portal_installations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_portal_active_domain",
|
||||||
|
"domain",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("record_status = 'A'"),
|
||||||
|
),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
member_id: Mapped[str] = mapped_column(String(128), unique=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(255))
|
||||||
|
client_endpoint: Mapped[str] = mapped_column(String(1024))
|
||||||
|
access_ciphertext: Mapped[str] = mapped_column(Text)
|
||||||
|
access_nonce: Mapped[str] = mapped_column(String(64))
|
||||||
|
refresh_ciphertext: Mapped[str] = mapped_column(Text)
|
||||||
|
refresh_nonce: Mapped[str] = mapped_column(String(64))
|
||||||
|
application_ciphertext: Mapped[str] = mapped_column(Text)
|
||||||
|
application_nonce: Mapped[str] = mapped_column(String(64))
|
||||||
|
key_version: Mapped[str] = mapped_column(String(32))
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
scope: Mapped[str | None] = mapped_column(Text)
|
||||||
|
install_status: Mapped[str] = mapped_column(String(32), default="installed")
|
||||||
|
setup_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorSetup(Common, Base):
|
||||||
|
__tablename__ = "connector_setup"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("portal_id", "connector_id", "line_id"),
|
||||||
|
Index("ix_setup_retry", "next_retry_at", "attempt_count"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||||
|
)
|
||||||
|
connector_id: Mapped[str] = mapped_column(String(64))
|
||||||
|
line_id: Mapped[str] = mapped_column(String(32))
|
||||||
|
registered: Mapped[bool] = mapped_column(default=False)
|
||||||
|
activated: Mapped[bool] = mapped_column(default=False)
|
||||||
|
bindings_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
desired_version: Mapped[str] = mapped_column(String(32), default="1")
|
||||||
|
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class DialogSession(Common, Base):
|
||||||
|
__tablename__ = "dialog_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_dialog_active_external_chat",
|
||||||
|
"external_chat_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("record_status = 'A'"),
|
||||||
|
),
|
||||||
|
Index("ix_dialog_bitrix_chat", "bitrix_chat_id"),
|
||||||
|
Index("ix_dialog_session", "session_id"),
|
||||||
|
Index("ix_dialog_status_updated", "status", "updated_at"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
bitrix_chat_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
session_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||||
|
|
||||||
|
|
||||||
|
class InboxEvent(Common, Base):
|
||||||
|
__tablename__ = "inbox_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("event_id"),
|
||||||
|
Index(
|
||||||
|
"uq_inbox_chat_message",
|
||||||
|
"external_chat_id",
|
||||||
|
"bitrix_message_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("bitrix_message_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index("ix_inbox_worker", "status", "next_attempt_at"),
|
||||||
|
{"schema": SCHEMA},
|
||||||
|
)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(255))
|
||||||
|
event_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
|
normalized_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
api_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
delivery_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundMessage(Common, Base):
|
||||||
|
__tablename__ = "outbound_messages"
|
||||||
|
__table_args__ = (Index("ix_outbound_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True)
|
||||||
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
|
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||||
|
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
response_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryAckOutbox(Common, Base):
|
||||||
|
__tablename__ = "delivery_ack_outbox"
|
||||||
|
__table_args__ = (Index("ix_ack_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||||
|
inbox_event_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
||||||
|
)
|
||||||
|
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class InstallRun(Common, Base):
|
||||||
|
__tablename__ = "install_runs"
|
||||||
|
__table_args__ = ({"schema": SCHEMA},)
|
||||||
|
portal_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
|
status: Mapped[str] = mapped_column(String(32))
|
||||||
|
result_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(Common, Base):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
__table_args__ = (Index("ix_audit_created", "created_at"), {"schema": SCHEMA})
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||||
|
safe_details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info: {title: HAN Bitrix24 Local App, version: 1.0.0}
|
||||||
|
paths:
|
||||||
|
/bitrix/handler:
|
||||||
|
get: {responses: {"200": {description: Callback probe}}}
|
||||||
|
post:
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json: {schema: {type: object}}
|
||||||
|
application/x-www-form-urlencoded: {schema: {type: object}}
|
||||||
|
multipart/form-data: {schema: {type: object}}
|
||||||
|
responses:
|
||||||
|
"200": {description: Duplicate or ignored callback}
|
||||||
|
"202": {description: Durably accepted callback}
|
||||||
|
"400": {description: Invalid callback}
|
||||||
|
"403": {description: Invalid application token}
|
||||||
|
/bitrix/install:
|
||||||
|
get: {responses: {"200": {description: Install probe}}}
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"200": {description: OAuth saved and setup attempted}
|
||||||
|
"400": {description: Invalid install callback}
|
||||||
|
/bitrix/placement:
|
||||||
|
get: {responses: {"200": {description: Connector placement HTML}}}
|
||||||
|
/health/live:
|
||||||
|
get: {responses: {"200": {description: Live}}}
|
||||||
|
/health/ready:
|
||||||
|
get: {responses: {"200": {description: Ready}, "503": {description: Not ready}}}
|
||||||
|
/internal/openlines/v1/messages:
|
||||||
|
post:
|
||||||
|
security: [{BearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||||
|
- {$ref: "#/components/parameters/RequestId"}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json: {schema: {$ref: "#/components/schemas/OutboundMessage"}}
|
||||||
|
responses:
|
||||||
|
"201": {description: Delivered}
|
||||||
|
"200": {description: Idempotent duplicate}
|
||||||
|
"400": {description: Invalid request}
|
||||||
|
"401": {description: Unauthorized}
|
||||||
|
"409": {description: Idempotency key reused}
|
||||||
|
"503": {description: Dependency unavailable or ambiguous delivery}
|
||||||
|
/internal/openlines/v1/dialogs/{external_chat_id}:
|
||||||
|
get:
|
||||||
|
security: [{BearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {name: external_chat_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||||
|
responses:
|
||||||
|
"200": {description: Active dialog mapping}
|
||||||
|
"404": {description: Mapping not found}
|
||||||
|
/internal/openlines/v1/status:
|
||||||
|
get:
|
||||||
|
security: [{BearerAuth: []}]
|
||||||
|
responses: {"200": {description: Safe adapter status}}
|
||||||
|
/internal/openlines/v1/setup/retry:
|
||||||
|
post:
|
||||||
|
security: [{BearerAuth: []}]
|
||||||
|
responses: {"200": {description: Idempotent setup reconcile result}}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth: {type: http, scheme: bearer}
|
||||||
|
parameters:
|
||||||
|
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||||
|
schemas:
|
||||||
|
OutboundFile:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [attachment_id, name, mime_type, size_bytes, download_url]
|
||||||
|
properties:
|
||||||
|
attachment_id: {type: string, format: uuid}
|
||||||
|
name: {type: string, maxLength: 255}
|
||||||
|
mime_type: {type: string, maxLength: 255}
|
||||||
|
size_bytes: {type: integer, minimum: 1, maximum: 5242880}
|
||||||
|
download_url: {type: string, maxLength: 4096}
|
||||||
|
OutboundMessage:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [message_id, external_chat_id, occurred_at, user, message]
|
||||||
|
properties:
|
||||||
|
message_id: {type: string, format: uuid}
|
||||||
|
external_chat_id: {type: string, format: uuid}
|
||||||
|
occurred_at: {type: string, format: date-time}
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [id, display_name]
|
||||||
|
properties:
|
||||||
|
id: {type: string, format: uuid}
|
||||||
|
display_name: {type: string, maxLength: 255}
|
||||||
|
message:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [content_kind, text, files]
|
||||||
|
properties:
|
||||||
|
content_kind: {type: string, enum: [text, file]}
|
||||||
|
text: {type: string, maxLength: 10000}
|
||||||
|
files:
|
||||||
|
type: array
|
||||||
|
maxItems: 1
|
||||||
|
items: {$ref: "#/components/schemas/OutboundFile"}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[project]
|
||||||
|
name = "han-bitrix-local-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.16,<2",
|
||||||
|
"asyncpg>=0.30,<1",
|
||||||
|
"cryptography>=45,<46",
|
||||||
|
"fastapi>=0.116,<1",
|
||||||
|
"httpx>=0.28,<1",
|
||||||
|
"pydantic-settings>=2.10,<3",
|
||||||
|
"python-multipart>=0.0.20,<1",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||||
|
"uvicorn[standard]>=0.35,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused")
|
||||||
|
os.environ.setdefault("BITRIX_CLIENT_ID", "client")
|
||||||
|
os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret")
|
||||||
|
os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token")
|
||||||
|
os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long")
|
||||||
|
os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox")
|
||||||
|
os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long")
|
||||||
|
os.environ.setdefault(
|
||||||
|
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||||
|
base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="),
|
||||||
|
)
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.main import (
|
||||||
|
TokenCipher,
|
||||||
|
canonical_fingerprint,
|
||||||
|
normalize_event,
|
||||||
|
retry_delay,
|
||||||
|
validate_portal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_cipher_binds_aad():
|
||||||
|
cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1")
|
||||||
|
ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access")
|
||||||
|
assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret"
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_message_and_finish():
|
||||||
|
external = str(uuid.uuid4())
|
||||||
|
message = normalize_event(
|
||||||
|
{
|
||||||
|
"event": "ONIMCONNECTORMESSAGEADD",
|
||||||
|
"data": {
|
||||||
|
"MESSAGES": [
|
||||||
|
{
|
||||||
|
"chat": {"id": external},
|
||||||
|
"message": {"id": "b-1", "text": "Ответ", "files": []},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert message["event_type"] == "message.new"
|
||||||
|
assert message["external_chat_id"] == external
|
||||||
|
closed = normalize_event(
|
||||||
|
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||||
|
)
|
||||||
|
assert closed["event_type"] == "dialog.closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_ignores_signed_query_and_portal_validation():
|
||||||
|
payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}}
|
||||||
|
other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}}
|
||||||
|
assert canonical_fingerprint(payload) == canonical_fingerprint(other)
|
||||||
|
validate_portal(
|
||||||
|
"han0107.bitrix24.ru",
|
||||||
|
"https://han0107.bitrix24.ru/rest/",
|
||||||
|
"han0107.bitrix24.ru",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru")
|
||||||
|
assert 0 <= retry_delay(4, 300) <= 8
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
|
WORKDIR /service
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY alembic.ini pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+asyncpg://unused
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", os.environ["BITRIX_SYNC_DATABASE_URL"])
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_offline() -> None:
|
||||||
|
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_online() -> None:
|
||||||
|
engine = async_engine_from_config(config.get_section(config.config_ini_section) or {})
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(do_run)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run(connection) -> None:
|
||||||
|
context.configure(connection=connection)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_online())
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Establish the bitrix-sync connectivity-stub migration baseline."""
|
||||||
|
|
||||||
|
revision = "0001_sync_baseline"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# The connectivity stub deliberately owns no runtime tables.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HAN bitrix-sync DB connectivity stub."""
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hmac
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Annotated, Protocol
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Header, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import Field, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(extra="ignore")
|
||||||
|
app_env: str = "production-like"
|
||||||
|
bitrix_sync_enabled: bool = True
|
||||||
|
bitrix_sync_database_url: str | None = None
|
||||||
|
bitrix_sync_service_token: str = Field(min_length=16)
|
||||||
|
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
|
||||||
|
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
|
||||||
|
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
|
||||||
|
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
|
||||||
|
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
|
||||||
|
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
|
||||||
|
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
|
||||||
|
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_enabled(self) -> Settings:
|
||||||
|
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
|
||||||
|
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Snapshot:
|
||||||
|
state: str
|
||||||
|
started_at: datetime
|
||||||
|
last_started_at: datetime | None = None
|
||||||
|
last_finished_at: datetime | None = None
|
||||||
|
last_success_at: datetime | None = None
|
||||||
|
success: bool | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
consecutive_failures: int = 0
|
||||||
|
next_check_at: datetime | None = None
|
||||||
|
worker_running: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Probe(Protocol):
|
||||||
|
async def check(self) -> None: ...
|
||||||
|
async def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseProbe:
|
||||||
|
def __init__(self, engine: AsyncEngine) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
|
||||||
|
async def check(self) -> None:
|
||||||
|
async with self.engine.connect() as connection:
|
||||||
|
result = await connection.scalar(text("SELECT 1"))
|
||||||
|
if result != 1:
|
||||||
|
raise RuntimeError("unexpected_result")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def database_url(value: str) -> str:
|
||||||
|
if value.startswith("postgresql://"):
|
||||||
|
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def classify_error(exc: Exception) -> str:
|
||||||
|
name = type(exc).__name__.lower()
|
||||||
|
text_value = str(exc).lower()
|
||||||
|
if isinstance(exc, TimeoutError):
|
||||||
|
return "db_query_timeout"
|
||||||
|
if "auth" in name or "password" in text_value:
|
||||||
|
return "db_auth_failed"
|
||||||
|
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
|
||||||
|
return "db_tls_failed"
|
||||||
|
if str(exc) == "unexpected_result":
|
||||||
|
return "unexpected_result"
|
||||||
|
return "db_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def jsonable(value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat().replace("+00:00", "Z")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
|
||||||
|
cfg = settings or Settings()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
app.state.snapshot = Snapshot(
|
||||||
|
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
|
||||||
|
started_at=utcnow(),
|
||||||
|
)
|
||||||
|
app.state.stop = asyncio.Event()
|
||||||
|
app.state.probe = probe
|
||||||
|
app.state.loop_task = None
|
||||||
|
if cfg.bitrix_sync_enabled:
|
||||||
|
if app.state.probe is None:
|
||||||
|
engine = create_async_engine(
|
||||||
|
database_url(cfg.bitrix_sync_database_url or ""),
|
||||||
|
pool_size=cfg.bitrix_sync_db_pool_size,
|
||||||
|
max_overflow=0,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
|
||||||
|
connect_args={"server_settings": {"application_name": "han-bitrix-sync"}},
|
||||||
|
)
|
||||||
|
app.state.probe = DatabaseProbe(engine)
|
||||||
|
await run_probe(app)
|
||||||
|
app.state.snapshot.worker_running = True
|
||||||
|
app.state.loop_task = asyncio.create_task(
|
||||||
|
periodic_loop(app), name="db-connectivity-probe"
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
app.state.snapshot.state = "stopping"
|
||||||
|
app.state.stop.set()
|
||||||
|
if app.state.loop_task:
|
||||||
|
app.state.loop_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await app.state.loop_task
|
||||||
|
if app.state.probe:
|
||||||
|
await app.state.probe.close()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="HAN Bitrix Sync Connectivity Stub",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url=None if cfg.app_env != "test" else "/docs",
|
||||||
|
)
|
||||||
|
app.state.settings = cfg
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id(request: Request, call_next):
|
||||||
|
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request.state.request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_error(_: Request, exc: HTTPException):
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||||
|
|
||||||
|
@app.get("/health/live")
|
||||||
|
async def live():
|
||||||
|
return {"status": "live"}
|
||||||
|
|
||||||
|
@app.get("/health/ready")
|
||||||
|
async def ready(request: Request):
|
||||||
|
snapshot: Snapshot = request.app.state.snapshot
|
||||||
|
if not cfg.bitrix_sync_enabled:
|
||||||
|
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
|
||||||
|
age = (
|
||||||
|
(utcnow() - snapshot.last_success_at).total_seconds()
|
||||||
|
if snapshot.last_success_at
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
max_age = max(
|
||||||
|
cfg.bitrix_sync_ready_max_staleness_sec,
|
||||||
|
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
|
||||||
|
)
|
||||||
|
is_ready = (
|
||||||
|
snapshot.state == "healthy"
|
||||||
|
and snapshot.worker_running
|
||||||
|
and age is not None
|
||||||
|
and age <= max_age
|
||||||
|
)
|
||||||
|
if is_ready:
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"mode": "db_connectivity_stub",
|
||||||
|
"database": {
|
||||||
|
"status": "ok",
|
||||||
|
"last_success_at": jsonable(snapshot.last_success_at),
|
||||||
|
"age_seconds": round(age or 0, 3),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"status": "not_ready",
|
||||||
|
"reason": reason,
|
||||||
|
"database": {
|
||||||
|
"status": "down",
|
||||||
|
"last_success_at": jsonable(snapshot.last_success_at),
|
||||||
|
"consecutive_failures": snapshot.consecutive_failures,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/internal/sync/v1/status")
|
||||||
|
async def status(
|
||||||
|
request: Request,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
):
|
||||||
|
candidate = (
|
||||||
|
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
|
||||||
|
)
|
||||||
|
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
|
||||||
|
raise HTTPException(
|
||||||
|
401,
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "service_unauthorized",
|
||||||
|
"message": "Service authentication failed",
|
||||||
|
"request_id": request.state.request_id,
|
||||||
|
"details": {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
snapshot: Snapshot = request.app.state.snapshot
|
||||||
|
last_check = None
|
||||||
|
if snapshot.last_started_at:
|
||||||
|
last_check = {
|
||||||
|
"started_at": jsonable(snapshot.last_started_at),
|
||||||
|
"finished_at": jsonable(snapshot.last_finished_at),
|
||||||
|
"success": snapshot.success,
|
||||||
|
"duration_ms": snapshot.duration_ms,
|
||||||
|
"error_code": snapshot.error_code,
|
||||||
|
}
|
||||||
|
next_in = (
|
||||||
|
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
|
||||||
|
if snapshot.next_check_at
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"service": "bitrix-sync",
|
||||||
|
"enabled": cfg.bitrix_sync_enabled,
|
||||||
|
"mode": "db_connectivity_stub",
|
||||||
|
"crm_sync_implemented": False,
|
||||||
|
"state": snapshot.state,
|
||||||
|
"started_at": jsonable(snapshot.started_at),
|
||||||
|
"last_check": last_check,
|
||||||
|
"last_success_at": jsonable(snapshot.last_success_at),
|
||||||
|
"consecutive_failures": snapshot.consecutive_failures,
|
||||||
|
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def run_probe(app: FastAPI) -> None:
|
||||||
|
cfg: Settings = app.state.settings
|
||||||
|
snapshot: Snapshot = app.state.snapshot
|
||||||
|
snapshot.last_started_at = utcnow()
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
|
||||||
|
await app.state.probe.check()
|
||||||
|
except Exception as exc:
|
||||||
|
snapshot.success = False
|
||||||
|
snapshot.error_code = classify_error(exc)
|
||||||
|
snapshot.consecutive_failures += 1
|
||||||
|
snapshot.state = "degraded"
|
||||||
|
else:
|
||||||
|
snapshot.success = True
|
||||||
|
snapshot.error_code = None
|
||||||
|
snapshot.consecutive_failures = 0
|
||||||
|
snapshot.last_success_at = utcnow()
|
||||||
|
snapshot.state = "healthy"
|
||||||
|
finally:
|
||||||
|
snapshot.last_finished_at = utcnow()
|
||||||
|
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
async def periodic_loop(app: FastAPI) -> None:
|
||||||
|
cfg: Settings = app.state.settings
|
||||||
|
snapshot: Snapshot = app.state.snapshot
|
||||||
|
try:
|
||||||
|
while not app.state.stop.is_set():
|
||||||
|
if snapshot.consecutive_failures:
|
||||||
|
cap = min(
|
||||||
|
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
|
||||||
|
cfg.bitrix_sync_db_retry_max_sec,
|
||||||
|
)
|
||||||
|
delay = random.uniform(0, cap)
|
||||||
|
else:
|
||||||
|
jitter = (
|
||||||
|
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
|
||||||
|
)
|
||||||
|
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
|
||||||
|
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
await run_probe(app)
|
||||||
|
finally:
|
||||||
|
snapshot.worker_running = False
|
||||||
|
|
||||||
|
|
||||||
|
_settings = Settings()
|
||||||
|
app = create_app(_settings)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
|
||||||
|
paths:
|
||||||
|
/health/live:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Process is live
|
||||||
|
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
|
||||||
|
/health/ready:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200": {description: Latest PostgreSQL probe is fresh and successful}
|
||||||
|
"503": {description: Disabled, stale, or database unavailable}
|
||||||
|
/internal/sync/v1/status:
|
||||||
|
get:
|
||||||
|
security: [{BearerAuth: []}]
|
||||||
|
parameters:
|
||||||
|
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Connectivity-loop status
|
||||||
|
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
|
||||||
|
"401": {description: Service authentication failed}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth: {type: http, scheme: bearer}
|
||||||
|
schemas:
|
||||||
|
Live:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties: {status: {const: live}}
|
||||||
|
Status:
|
||||||
|
type: object
|
||||||
|
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
|
||||||
|
properties:
|
||||||
|
service: {const: bitrix-sync}
|
||||||
|
enabled: {type: boolean}
|
||||||
|
mode: {const: db_connectivity_stub}
|
||||||
|
crm_sync_implemented: {const: false}
|
||||||
|
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
|
||||||
|
started_at: {type: string, format: date-time}
|
||||||
|
last_check: {type: [object, "null"]}
|
||||||
|
last_success_at: {type: [string, "null"], format: date-time}
|
||||||
|
consecutive_failures: {type: integer, minimum: 0}
|
||||||
|
next_check_in_seconds: {type: [number, "null"]}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[project]
|
||||||
|
name = "han-bitrix-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.16,<2",
|
||||||
|
"asyncpg>=0.30,<1",
|
||||||
|
"fastapi>=0.116,<1",
|
||||||
|
"pydantic-settings>=2.10,<3",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||||
|
"uvicorn[standard]>=0.35,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 100
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
|
||||||
|
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.main import Settings, create_app
|
||||||
|
|
||||||
|
|
||||||
|
class Probe:
|
||||||
|
def __init__(self, fail=False):
|
||||||
|
self.fail = fail
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def check(self):
|
||||||
|
self.calls += 1
|
||||||
|
if self.fail:
|
||||||
|
raise OSError("down")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_semantics():
|
||||||
|
settings = Settings(
|
||||||
|
bitrix_sync_enabled=False,
|
||||||
|
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
assert (await client.get("/health/live")).status_code == 200
|
||||||
|
ready = await client.get("/health/ready")
|
||||||
|
assert ready.status_code == 503
|
||||||
|
assert ready.json()["reason"] == "sync_disabled"
|
||||||
|
status = await client.get(
|
||||||
|
"/internal/sync/v1/status",
|
||||||
|
headers={"Authorization": "Bearer test-sync-token-32-characters"},
|
||||||
|
)
|
||||||
|
assert status.json()["state"] == "disabled"
|
||||||
|
assert status.json()["crm_sync_implemented"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initial_probe_and_auth():
|
||||||
|
probe = Probe()
|
||||||
|
settings = Settings(
|
||||||
|
bitrix_sync_enabled=True,
|
||||||
|
bitrix_sync_database_url="postgresql://unused/unused",
|
||||||
|
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||||
|
bitrix_sync_db_check_interval_sec=60,
|
||||||
|
)
|
||||||
|
app = create_app(settings, probe)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
|
assert probe.calls == 1
|
||||||
|
assert (await client.get("/health/ready")).status_code == 200
|
||||||
|
assert (await client.get("/internal/sync/v1/status")).status_code == 401
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# HAN Chat production-like deployment runbook
|
||||||
|
|
||||||
|
This is the executable checklist for the single-VM contour. PostgreSQL and S3
|
||||||
|
are managed external services. Never use `docker compose down -v`, an Alembic
|
||||||
|
downgrade, or a mutable image tag during deployment.
|
||||||
|
|
||||||
|
## Gate 0 — decisions and ownership
|
||||||
|
|
||||||
|
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
|
||||||
|
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
|
||||||
|
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
|
||||||
|
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
|
||||||
|
|
||||||
|
## Gate 1 — VPC, DNS and security groups
|
||||||
|
|
||||||
|
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
|
||||||
|
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
|
||||||
|
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
|
||||||
|
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
|
||||||
|
|
||||||
|
## Gate 2 — VM hardening
|
||||||
|
|
||||||
|
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
|
||||||
|
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
|
||||||
|
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
|
||||||
|
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
|
||||||
|
|
||||||
|
## Gate 3 — managed PostgreSQL
|
||||||
|
|
||||||
|
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
|
||||||
|
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
|
||||||
|
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||||
|
have separate migration/runtime roles with tested negative grants.
|
||||||
|
- [ ] Migration tested against an empty DB and a clone of the previous release.
|
||||||
|
|
||||||
|
## Gate 4 — Selectel S3
|
||||||
|
|
||||||
|
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
|
||||||
|
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
|
||||||
|
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
|
||||||
|
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
|
||||||
|
|
||||||
|
## Gate 5 — immutable release
|
||||||
|
|
||||||
|
- [ ] Checkout is detached at the approved SHA and working tree is clean.
|
||||||
|
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
|
||||||
|
- [ ] Root `docker-compose.yml` is the only deployment entry point.
|
||||||
|
|
||||||
|
## Gate 6 — environment and secrets
|
||||||
|
|
||||||
|
```sh
|
||||||
|
umask 077
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
# Replace placeholders using a protected editor/secret manager.
|
||||||
|
./scripts/validate-env .env
|
||||||
|
docker compose --env-file .env config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Token pairs match, PG verifies TLS, public URLs are HTTPS.
|
||||||
|
- [ ] Mock OTP risk is accepted and all secrets are unique >=128-bit values.
|
||||||
|
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
|
||||||
|
|
||||||
|
## Gate 7 — images and static frontend
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --env-file .env pull
|
||||||
|
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||||
|
docker compose --env-file .env run --rm frontend-static
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
|
||||||
|
- [ ] Build artifacts contain no secrets or unintended source maps.
|
||||||
|
- [ ] At least 30% VM disk remains free.
|
||||||
|
|
||||||
|
## Gate 8 — topology
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --env-file .env config --services
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
|
||||||
|
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
|
||||||
|
- [ ] `backend` and `observability` are internal networks.
|
||||||
|
|
||||||
|
## Gate 9 — ACME/TLS bootstrap
|
||||||
|
|
||||||
|
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||||
|
docker compose --profile certbot run --rm certbot certonly \
|
||||||
|
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||||
|
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||||
|
--agree-tos --no-eff-email --non-interactive
|
||||||
|
docker compose --env-file .env up -d --force-recreate nginx
|
||||||
|
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
|
||||||
|
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
|
||||||
|
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
|
||||||
|
|
||||||
|
## Gate 10 — migrations and seed
|
||||||
|
|
||||||
|
Create a provider PITR marker, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||||
|
deployment/scripts/seed.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
|
||||||
|
- [ ] Seed succeeds twice and mandatory settings contain no secret.
|
||||||
|
- [ ] Schema remains backward-compatible with the previous images.
|
||||||
|
|
||||||
|
## Gate 11 — Keycloak
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d keycloak
|
||||||
|
docker compose ps keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
|
||||||
|
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
|
||||||
|
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
|
||||||
|
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
|
||||||
|
|
||||||
|
## Gate 12 — ordered startup and readiness
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d redis
|
||||||
|
docker compose up -d keycloak otel-collector
|
||||||
|
docker compose up -d message-safety
|
||||||
|
docker compose up -d api-backend
|
||||||
|
docker compose up -d bitrix-local-app bitrix-sync
|
||||||
|
docker compose up -d nginx
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] No restart loop/OOM; critical readiness is green.
|
||||||
|
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
|
||||||
|
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
|
||||||
|
|
||||||
|
## Gate 13 — Bitrix24
|
||||||
|
|
||||||
|
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
|
||||||
|
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
|
||||||
|
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
|
||||||
|
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
|
||||||
|
|
||||||
|
## Gate 14 — smoke and E2E
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deployment/scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
|
||||||
|
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
|
||||||
|
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
|
||||||
|
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
|
||||||
|
- [ ] Logs contain no PII, message body, token or presigned query.
|
||||||
|
|
||||||
|
## Gate 15 — observability
|
||||||
|
|
||||||
|
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
|
||||||
|
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
|
||||||
|
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
|
||||||
|
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
|
||||||
|
|
||||||
|
For local acceptance only, start the redacted debug collector with:
|
||||||
|
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||||
|
|
||||||
|
## Gate 16 — open traffic
|
||||||
|
|
||||||
|
- [ ] Gates 0–15 are signed; fresh backup/PITR evidence and previous images exist.
|
||||||
|
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
|
||||||
|
- [ ] No active page; on-call and product owner accept stub limitations.
|
||||||
|
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
Provider backup/PITR is authoritative. A supplemental verified logical dump:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PG_BACKUP_DSN='postgresql://...?...sslmode=verify-full&sslrootcert=...' \
|
||||||
|
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
|
||||||
|
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
|
||||||
|
Redis may be restored empty; its AOF/RDB is not a business backup.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Only roll back to images compatible with the current schema:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||||
|
deployment/scripts/rollback.sh /secure/path/previous-release.env
|
||||||
|
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Never run Alembic downgrade. After a backward-incompatible migration choose a
|
||||||
|
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
|
||||||
|
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
schema_version: 1
|
||||||
|
settings:
|
||||||
|
auth.phone.enabled: {type: boolean, value: true, public: true}
|
||||||
|
auth.password.enabled: {type: boolean, value: false, public: true}
|
||||||
|
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
|
||||||
|
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
|
||||||
|
operator.call.phone: {type: string, value: "+74999591007", public: true}
|
||||||
|
consent.personal_data.required: {type: boolean, value: true, public: true}
|
||||||
|
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
|
||||||
|
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
|
||||||
|
consent.user_agreement.required: {type: boolean, value: true, public: true}
|
||||||
|
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
|
||||||
|
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
|
||||||
|
consent.marketing.required: {type: boolean, value: false, public: true}
|
||||||
|
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
|
||||||
|
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
|
||||||
|
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
|
||||||
|
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
|
||||||
|
chat.attachments.max_size_mb: {type: integer, value: 5, public: true}
|
||||||
|
chat.attachments.storage: {type: string, value: selectel_s3, public: false}
|
||||||
|
chat.attachments.upload_mode: {type: string, value: presigned_put, public: false}
|
||||||
|
chat.attachments.safety_scan_required: {type: boolean, value: true, public: false}
|
||||||
|
chat.attachments.presigned_upload_ttl_seconds: {type: integer, value: 600, public: true}
|
||||||
|
rate_limit.message_send.per_user: {type: string, value: "30/minute", public: true}
|
||||||
|
rate_limit.message_send.per_dialog: {type: string, value: "20/minute", public: false}
|
||||||
|
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
|
||||||
|
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
|
||||||
|
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
|
||||||
|
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
||||||
|
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
||||||
|
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
services:
|
||||||
|
migrate-api:
|
||||||
|
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||||
|
profiles: ["ops"]
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
entrypoint: []
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
volumes:
|
||||||
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
|
networks: [backend]
|
||||||
|
restart: "no"
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
|
migrate-bitrix-local:
|
||||||
|
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||||
|
profiles: ["ops"]
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
entrypoint: []
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
volumes:
|
||||||
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
|
networks: [backend]
|
||||||
|
restart: "no"
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
|
migrate-bitrix-sync:
|
||||||
|
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||||
|
profiles: ["ops"]
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
entrypoint: []
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
volumes:
|
||||||
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
|
networks: [backend]
|
||||||
|
restart: "no"
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
|
seed-settings:
|
||||||
|
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||||
|
profiles: ["ops"]
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
entrypoint: []
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -ec
|
||||||
|
- >-
|
||||||
|
python -m app.cli.seed_settings
|
||||||
|
--file /deployment/app-settings.production-like.yaml
|
||||||
|
&& python -m app.cli.validate_settings
|
||||||
|
volumes:
|
||||||
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
|
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
||||||
|
networks: [backend]
|
||||||
|
restart: "no"
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
|
toolbox:
|
||||||
|
image: curlimages/curl:8.11.1
|
||||||
|
profiles: ["ops"]
|
||||||
|
entrypoint: ["sleep", "infinity"]
|
||||||
|
networks: [backend, observability]
|
||||||
|
restart: "no"
|
||||||
|
cap_drop: ["ALL"]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${PG_BACKUP_DSN:-}" ]; then
|
||||||
|
echo "PG_BACKUP_DSN is required (managed PostgreSQL TLS DSN, supplied via secure environment)." >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
case "$PG_BACKUP_DSN" in
|
||||||
|
*sslmode=verify-full*sslrootcert=*) ;;
|
||||||
|
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
output_dir=${1:-/opt/han-chat/backups}
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
archive="$output_dir/han-chat-$stamp.dump"
|
||||||
|
|
||||||
|
pg_dump --dbname="$PG_BACKUP_DSN" --format=custom --no-owner --no-privileges --file="$archive"
|
||||||
|
pg_restore --list "$archive" >/dev/null
|
||||||
|
sha256sum "$archive" > "$archive.sha256"
|
||||||
|
chmod 600 "$archive" "$archive.sha256"
|
||||||
|
echo "Logical backup verified: $archive"
|
||||||
|
echo "This supplements, but does not replace, provider backup/PITR and restore rehearsal."
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
|
||||||
|
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
./scripts/validate-env "${ENV_FILE:-.env}"
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" config --quiet
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
|
||||||
|
echo "Migrations completed; record revisions in release evidence."
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
previous_env=${1:-}
|
||||||
|
if [ -z "$previous_env" ] || [ ! -r "$previous_env" ]; then
|
||||||
|
echo "Usage: $0 /secure/path/previous-release.env" >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
|
||||||
|
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
./scripts/validate-env "$previous_env"
|
||||||
|
docker compose --env-file "$previous_env" config --quiet
|
||||||
|
docker compose --env-file "$previous_env" up -d --remove-orphans
|
||||||
|
docker compose --env-file "$previous_env" ps
|
||||||
|
|
||||||
|
echo "Application images rolled back without Alembic downgrade."
|
||||||
|
echo "Run deployment/scripts/smoke.sh with ENV_FILE=$previous_env and verify outbox/inbox idempotency."
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
./scripts/validate-env "${ENV_FILE:-.env}"
|
||||||
|
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm seed-settings
|
||||||
|
echo "Seed and mandatory-settings validation completed."
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
ENV_FILE=${ENV_FILE:-.env}
|
||||||
|
./scripts/validate-env "$ENV_FILE"
|
||||||
|
set -a
|
||||||
|
. "./$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
http_code=$(curl -sS -o /dev/null -w '%{http_code}' "http://${PUBLIC_HOST}/")
|
||||||
|
[ "$http_code" = "308" ] || { echo "Expected HTTP 308, got $http_code" >&2; exit 1; }
|
||||||
|
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/app-config" -o "$tmp/app-config.json"
|
||||||
|
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/content" -o "$tmp/content.json"
|
||||||
|
curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" -o "$tmp/oidc.json"
|
||||||
|
|
||||||
|
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
|
||||||
|
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
|
||||||
|
|
||||||
|
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
|
||||||
|
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
|
||||||
|
printf '%s' "$headers" | grep -qi '^x-request-id:'
|
||||||
|
printf '%s' "$headers" | grep -qi '^content-security-policy:'
|
||||||
|
|
||||||
|
openssl s_client -connect "${PUBLIC_HOST}:443" -servername "$PUBLIC_HOST" </dev/null 2>/dev/null \
|
||||||
|
| openssl x509 -noout -checkend 604800
|
||||||
|
echo "Public edge smoke passed."
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
lock=/tmp/han-chat-cert-renew.lock
|
||||||
|
exec 9>"$lock"
|
||||||
|
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
|
||||||
|
|
||||||
|
docker compose --profile certbot run --rm certbot renew \
|
||||||
|
--webroot -w /var/www/certbot --quiet
|
||||||
|
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||||
|
docker compose exec -T nginx nginx -s reload
|
||||||
|
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: han-chat
|
||||||
|
|
||||||
|
include:
|
||||||
|
- path: ./infra/compose/application.yml
|
||||||
|
- path: ./nginx/docker-compose.yml
|
||||||
|
- path: ./redis/docker-compose.yml
|
||||||
|
- path: ./observability/docker-compose.yml
|
||||||
|
- path: ./deployment/docker-compose.jobs.yml
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public:
|
||||||
|
name: han-chat-public
|
||||||
|
backend:
|
||||||
|
name: han-chat-backend
|
||||||
|
internal: true
|
||||||
|
observability:
|
||||||
|
name: han-chat-observability
|
||||||
|
internal: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
nginx-certs:
|
||||||
|
nginx-acme-webroot:
|
||||||
|
nginx-cache:
|
||||||
|
frontend-static:
|
||||||
|
otel-queue:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.expo
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
|
||||||
|
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
||||||
|
EXPO_PUBLIC_APP_ENV=production-like
|
||||||
|
|
||||||
|
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.expo/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG EXPO_PUBLIC_API_BASE_URL
|
||||||
|
ARG EXPO_PUBLIC_AUTH_BASE_URL
|
||||||
|
ARG EXPO_PUBLIC_KEYCLOAK_REALM
|
||||||
|
ARG EXPO_PUBLIC_KEYCLOAK_CLIENT_ID
|
||||||
|
ARG EXPO_PUBLIC_APP_ENV=production
|
||||||
|
ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
|
||||||
|
EXPO_PUBLIC_AUTH_BASE_URL=$EXPO_PUBLIC_AUTH_BASE_URL \
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
|
||||||
|
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# One-shot Compose init container copies the immutable export to nginx's volume.
|
||||||
|
FROM alpine:3.22 AS static
|
||||||
|
COPY --from=build /app/dist /dist
|
||||||
|
RUN mkdir /output
|
||||||
|
ENTRYPOINT ["/bin/sh", "-ec"]
|
||||||
|
CMD ["rm -rf /output/* /output/.[!.]* /output/..?* 2>/dev/null || true; cp -a /dist/. /output/"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# HAN Chat frontend test site
|
||||||
|
|
||||||
|
Expo / React Native Web SPA для проверки публичных пользовательских потоков HAN Chat.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
npm install
|
||||||
|
npm run web
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверки: `npm test`, `npm run typecheck`, `npm run build`, `npm run test:e2e`.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
`npm run build` создаёт `dist/`. Каталог монтируется в корневой nginx системы; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html` и бессрочно кэшировать hashed assets.
|
||||||
|
|
||||||
|
Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build --target static \
|
||||||
|
--build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \
|
||||||
|
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \
|
||||||
|
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \
|
||||||
|
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend .
|
||||||
|
```
|
||||||
|
|
||||||
|
Во frontend разрешены только публичные URL, realm, client id и имя среды. Service tokens, S3 credentials и mock OTP code добавлять запрещено. На web refresh token хранится в browser storage с известным XSS-риском; production требует строгой CSP и отсутствия сторонних scripts.
|
||||||
|
|
||||||
|
WebSocket использует subprotocols `han-chat-v1` и `bearer.<JWT>`. Они должны совпадать с реализацией api-backend. Query-token намеренно не используется.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ExpoConfig } from "expo/config";
|
||||||
|
|
||||||
|
const config: ExpoConfig = {
|
||||||
|
name: "HAN Chat Test",
|
||||||
|
slug: "han-chat-test",
|
||||||
|
version: "1.0.0",
|
||||||
|
scheme: "han-chat",
|
||||||
|
orientation: "portrait",
|
||||||
|
userInterfaceStyle: "light",
|
||||||
|
experiments: { typedRoutes: true },
|
||||||
|
plugins: ["expo-router", "expo-secure-store"],
|
||||||
|
web: { bundler: "metro", output: "static" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import React from "react";
|
||||||
|
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { AppProvider } from "../src/app-context";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <SafeAreaProvider>
|
||||||
|
<AppProvider>
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</SafeAreaView>
|
||||||
|
</AppProvider>
|
||||||
|
</SafeAreaProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { useApp } from "../../src/app-context";
|
||||||
|
import type { Consents } from "../../src/types";
|
||||||
|
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
|
||||||
|
|
||||||
|
export default function AuthCallbackScreen() {
|
||||||
|
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
|
||||||
|
const app = useApp();
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const retry = () => {
|
||||||
|
if (!params.code || !params.state || typeof window === "undefined") return;
|
||||||
|
const raw = window.sessionStorage.getItem("han.pending-consents");
|
||||||
|
if (!raw) return;
|
||||||
|
setError(undefined);
|
||||||
|
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.error) {
|
||||||
|
setError(new Error("Авторизация отменена или отклонена."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!params.code || !params.state) return;
|
||||||
|
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
|
||||||
|
if (!raw) {
|
||||||
|
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const consents = JSON.parse(raw) as Consents;
|
||||||
|
void app.finishCallback(params.code, params.state, consents)
|
||||||
|
.then(() => window.sessionStorage.removeItem("han.pending-consents"))
|
||||||
|
.catch(setError);
|
||||||
|
}, [params.code, params.state, params.error]);
|
||||||
|
return <View style={styles.page}>
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
|
||||||
|
{!error && <Loading />}
|
||||||
|
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { getDiagnostics, sessionMemory } from "../src/api";
|
||||||
|
import { getTokenInfo } from "../src/auth";
|
||||||
|
import { useApp } from "../src/app-context";
|
||||||
|
import { isProduction } from "../src/config";
|
||||||
|
import { getRealtimeDiagnostics } from "../src/realtime";
|
||||||
|
import { Header, styles } from "../src/ui";
|
||||||
|
|
||||||
|
export default function DiagnosticsScreen() {
|
||||||
|
const app = useApp();
|
||||||
|
const [, render] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => render((value) => value + 1), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
|
||||||
|
<Text style={styles.error}>Экран отключён в production.</Text>
|
||||||
|
</ScrollView>;
|
||||||
|
const token = getTokenInfo();
|
||||||
|
return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.text}>Режим: {app.authStatus}</Text>
|
||||||
|
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
|
||||||
|
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
|
||||||
|
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
|
||||||
|
{getRealtimeDiagnostics().map((item) =>
|
||||||
|
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
|
||||||
|
)}
|
||||||
|
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
|
||||||
|
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
|
||||||
|
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
|
||||||
|
<Text style={styles.badge}>{item.status}</Text>
|
||||||
|
<Text style={styles.text}>{item.method} {item.path}</Text>
|
||||||
|
<Text style={styles.muted}>request_id: {item.requestId}</Text>
|
||||||
|
</View>)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Platform, ScrollView, Text, View } from "react-native";
|
||||||
|
import { useApp } from "../../src/app-context";
|
||||||
|
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
|
||||||
|
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
|
||||||
|
import type { Message } from "../../src/types";
|
||||||
|
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
open: "Открыт",
|
||||||
|
waiting_for_company: "Ожидает ответа компании",
|
||||||
|
waiting_for_client: "Ожидает вашего ответа",
|
||||||
|
closed: "Закрыт",
|
||||||
|
accepted: "Принято",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
failed: "Ошибка доставки",
|
||||||
|
rejected: "Отклонено",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatScreen() {
|
||||||
|
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||||
|
const app = useApp();
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<unknown>();
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||||
|
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||||
|
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||||
|
|
||||||
|
const realtime = useMemo(() => new RealtimeClient(
|
||||||
|
dialogId ? [dialogId] : [],
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "message.new") merge([event.message]);
|
||||||
|
if (event.type === "message.status") {
|
||||||
|
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
|
||||||
|
...old,
|
||||||
|
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (event.type === "dialog.status") void dialog.refetch();
|
||||||
|
},
|
||||||
|
(_, incoming) => merge(incoming),
|
||||||
|
app.setRealtimeState,
|
||||||
|
), [dialogId]);
|
||||||
|
|
||||||
|
function merge(incoming: Message[]) {
|
||||||
|
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
|
||||||
|
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (app.authStatus === "authenticated") realtime.start();
|
||||||
|
return () => realtime.stop();
|
||||||
|
}, [realtime, app.authStatus]);
|
||||||
|
|
||||||
|
const sendText = async () => {
|
||||||
|
const normalized = text.trim();
|
||||||
|
if (!normalized || !dialogId) return;
|
||||||
|
setSending(true); setError(undefined);
|
||||||
|
try {
|
||||||
|
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
|
||||||
|
merge([message]); setText("");
|
||||||
|
} catch (reason) { setError(reason); }
|
||||||
|
finally { setSending(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseFile = () => {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
setError(new Error("Выбор файла в этой тестовой сборке доступен в web."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*,application/pdf";
|
||||||
|
input.onchange = () => { const file = input.files?.[0]; if (file) void sendFile(file); };
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendFile = async (file: File) => {
|
||||||
|
const limits = config.data?.attachments;
|
||||||
|
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
|
||||||
|
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||||
|
if (file.size > max || !allowed.includes(file.type)) {
|
||||||
|
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true); setError(undefined);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadAttachment(dialogId, file);
|
||||||
|
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
|
||||||
|
merge([message]);
|
||||||
|
} catch (reason) { setError(reason); }
|
||||||
|
finally { setSending(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadAttachment = async (attachmentId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
|
||||||
|
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||||
|
} catch (reason) { setError(reason); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const closed = dialog.data?.status === "closed";
|
||||||
|
return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
|
||||||
|
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
|
||||||
|
{(dialog.isLoading || messages.isLoading) && <Loading />}
|
||||||
|
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
|
||||||
|
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
|
||||||
|
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
|
||||||
|
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
|
||||||
|
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
|
||||||
|
{message.attachments.map((attachment) =>
|
||||||
|
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
|
||||||
|
)}
|
||||||
|
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
|
||||||
|
</View>)}
|
||||||
|
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
|
||||||
|
</View>
|
||||||
|
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
|
||||||
|
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
|
||||||
|
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
|
||||||
|
</View>
|
||||||
|
{error && <ErrorNotice error={error} />}
|
||||||
|
</View>}
|
||||||
|
</ScrollView>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { useApp } from "../../src/app-context";
|
||||||
|
import { dialogApi } from "../../src/services";
|
||||||
|
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui";
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
open: "Открыт",
|
||||||
|
waiting_for_company: "Ожидает ответа компании",
|
||||||
|
waiting_for_client: "Ожидает вашего ответа",
|
||||||
|
closed: "Закрыт",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DialogsScreen() {
|
||||||
|
const app = useApp();
|
||||||
|
const dialogs = useInfiniteQuery({
|
||||||
|
queryKey: ["dialogs"],
|
||||||
|
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
enabled: app.authStatus === "authenticated",
|
||||||
|
});
|
||||||
|
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||||
|
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
|
||||||
|
<Link href="/" style={styles.link}>На главную</Link>
|
||||||
|
</ScrollView>;
|
||||||
|
|
||||||
|
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||||
|
{dialogs.isLoading && <Loading />}
|
||||||
|
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
|
||||||
|
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
|
||||||
|
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
|
||||||
|
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
|
||||||
|
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}…</Text>
|
||||||
|
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
|
||||||
|
</View>)}
|
||||||
|
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
|
||||||
|
</ScrollView>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { Linking, ScrollView, Switch, Text, View } from "react-native";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useApp } from "../src/app-context";
|
||||||
|
import { dialogApi, publicApi } from "../src/services";
|
||||||
|
import type { Consents } from "../src/types";
|
||||||
|
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
|
||||||
|
|
||||||
|
const schema = z.object({ text: z.string().trim().min(1, "Введите сообщение").max(4000, "Сообщение слишком длинное") });
|
||||||
|
type Form = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const { authStatus, realtimeState, authorize, signOut } = useApp();
|
||||||
|
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||||
|
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||||
|
const [consentOpen, setConsentOpen] = useState(false);
|
||||||
|
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [sendError, setSendError] = useState<unknown>();
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
|
||||||
|
defaultValues: { text: "" }, resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendAuthenticated = async (text: string) => {
|
||||||
|
setSending(true);
|
||||||
|
setSendError(undefined);
|
||||||
|
try {
|
||||||
|
const dialog = await dialogApi.create(crypto.randomUUID());
|
||||||
|
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
|
||||||
|
reset();
|
||||||
|
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (text: string) => {
|
||||||
|
if (authStatus !== "authenticated") {
|
||||||
|
setPending(text);
|
||||||
|
setConsentOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendAuthenticated(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
if (!required.personal || !required.agreement) return;
|
||||||
|
const versions = config.data?.consents;
|
||||||
|
const consents: Consents = {
|
||||||
|
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
|
||||||
|
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
|
||||||
|
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
|
||||||
|
};
|
||||||
|
setConsentOpen(false);
|
||||||
|
try {
|
||||||
|
const authorized = await authorize(consents);
|
||||||
|
if (authorized && pending) await sendAuthenticated(pending);
|
||||||
|
} catch (error) {
|
||||||
|
setSendError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const questions = content.data?.popular_questions ?? [];
|
||||||
|
return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={authStatus} realtime={realtimeState} onLogout={authStatus === "authenticated" ? () => void signOut() : undefined} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Помощь мигрантам</Text>
|
||||||
|
<Text style={styles.text}>{content.data?.texts.welcome ?? "Задайте вопрос — оператор ответит в чате."}</Text>
|
||||||
|
{(config.isLoading || content.isLoading) && <Loading />}
|
||||||
|
{(config.error || content.error) && <ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />}
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{questions.map((question, index) => {
|
||||||
|
const text = question.text;
|
||||||
|
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
|
||||||
|
})}
|
||||||
|
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
|
||||||
|
<Controller control={control} name="text" render={({ field }) =>
|
||||||
|
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
|
||||||
|
} />
|
||||||
|
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
|
||||||
|
{sendError && <ErrorNotice error={sendError} />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{consentOpen && <View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||||
|
<View style={styles.modal}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
||||||
|
<Text style={styles.text}>Для отправки сообщения необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
||||||
|
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
||||||
|
const item = config.data?.consents?.[key];
|
||||||
|
if (!item) return null;
|
||||||
|
return item.document_url ? <Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
||||||
|
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
||||||
|
</Text> : null;
|
||||||
|
})}
|
||||||
|
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
||||||
|
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
||||||
|
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
||||||
|
<Button title="Отмена" secondary onPress={() => { setConsentOpen(false); setPending(null); }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>}
|
||||||
|
</ScrollView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||||||
|
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
||||||
|
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
|
||||||
|
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { useApp } from "../src/app-context";
|
||||||
|
import { profileApi } from "../src/services";
|
||||||
|
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const app = useApp();
|
||||||
|
const enabled = app.authStatus === "authenticated";
|
||||||
|
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
|
||||||
|
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
|
||||||
|
const [downloadError, setDownloadError] = useState<unknown>();
|
||||||
|
|
||||||
|
const download = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await profileApi.documentUrl(id);
|
||||||
|
if (typeof window !== "undefined") window.location.assign(result.download_url);
|
||||||
|
} catch (error) { setDownloadError(error); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||||
|
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
|
||||||
|
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
|
||||||
|
</ScrollView>;
|
||||||
|
|
||||||
|
const personal = profile.data?.profile.personal_data;
|
||||||
|
return <ScrollView contentContainerStyle={styles.page}>
|
||||||
|
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||||
|
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
|
||||||
|
{(profile.isLoading || documents.isLoading) && <Loading />}
|
||||||
|
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
|
||||||
|
<Row label="ФИО" value={personal?.full_name} />
|
||||||
|
<Row label="Гражданство" value={personal?.citizenship} />
|
||||||
|
<Row label="Телефон в РФ" value={personal?.russian_phone} />
|
||||||
|
<Row label="Зарубежный телефон" value={personal?.foreign_phone} />
|
||||||
|
<Row label="Email" value={personal?.email} />
|
||||||
|
<Text style={styles.muted}>Редактирование профиля недоступно. Для изменения данных напишите оператору.</Text>
|
||||||
|
<Link href="/" style={styles.link}>Написать оператору</Link>
|
||||||
|
</View>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>Документы</Text>
|
||||||
|
{!documents.data?.items.length && <Text style={styles.muted}>Документов пока нет.</Text>}
|
||||||
|
{documents.data?.items.map((document) => <View key={document.document_id} style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.text}>{document.name}</Text>
|
||||||
|
<Text style={styles.muted}>{new Date(document.sent_at).toLocaleDateString("ru-RU")}</Text>
|
||||||
|
</View>
|
||||||
|
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
|
||||||
|
</View>)}
|
||||||
|
{downloadError && <ErrorNotice error={downloadError} />}
|
||||||
|
</View>
|
||||||
|
</ScrollView>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="expo/types" />
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "han-frontend-test-site",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"build": "expo export --platform web",
|
||||||
|
"serve": "npx serve dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "57.0.3",
|
||||||
|
"@hookform/resolvers": "5.4.0",
|
||||||
|
"@tanstack/react-query": "5.101.2",
|
||||||
|
"expo": "57.0.4",
|
||||||
|
"expo-auth-session": "57.0.2",
|
||||||
|
"expo-constants": "57.0.3",
|
||||||
|
"expo-crypto": "57.0.0",
|
||||||
|
"expo-linking": "57.0.2",
|
||||||
|
"expo-router": "57.0.4",
|
||||||
|
"expo-secure-store": "57.0.0",
|
||||||
|
"expo-status-bar": "57.0.0",
|
||||||
|
"expo-web-browser": "57.0.0",
|
||||||
|
"react": "19.2.7",
|
||||||
|
"react-dom": "19.2.7",
|
||||||
|
"react-hook-form": "7.81.0",
|
||||||
|
"react-native": "0.86.0",
|
||||||
|
"react-native-safe-area-context": "5.8.0",
|
||||||
|
"react-native-screens": "4.26.0",
|
||||||
|
"react-native-web": "0.21.2",
|
||||||
|
"zod": "4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"@playwright/test": "1.61.1",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@vitejs/plugin-react": "6.0.3",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.1.4",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: "list",
|
||||||
|
use: { baseURL: "http://127.0.0.1:4173", trace: "on-first-retry" },
|
||||||
|
webServer: {
|
||||||
|
command: "npm run build && npx serve dist -l 4173",
|
||||||
|
url: "http://127.0.0.1:4173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit-mobile", use: { ...devices["iPhone 13"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { env } from "./config";
|
||||||
|
import { getAccessToken, refreshTokens } from "./auth";
|
||||||
|
export { sessionMemory } from "./session";
|
||||||
|
import { sessionMemory } from "./session";
|
||||||
|
|
||||||
|
export type ApiErrorEnvelope = {
|
||||||
|
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
readonly requestId?: string,
|
||||||
|
readonly retryAfter?: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Diagnostic = {
|
||||||
|
at: number;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
|
||||||
|
export const getDiagnostics = () => [...diagnostics];
|
||||||
|
|
||||||
|
function traceparent() {
|
||||||
|
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||||
|
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
||||||
|
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePath(path: string) {
|
||||||
|
return path.split("?")[0] ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseError(response: Response, requestId: string) {
|
||||||
|
let envelope: ApiErrorEnvelope | undefined;
|
||||||
|
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
|
||||||
|
const code = envelope?.error?.code ?? `http_${response.status}`;
|
||||||
|
const retry = Number(response.headers.get("Retry-After"));
|
||||||
|
return new ApiError(
|
||||||
|
response.status,
|
||||||
|
code,
|
||||||
|
envelope?.error?.message ?? "Запрос не выполнен",
|
||||||
|
envelope?.error?.request_id ?? requestId,
|
||||||
|
Number.isFinite(retry) ? retry : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit & { protected?: boolean } = {},
|
||||||
|
replayed = false,
|
||||||
|
): Promise<T> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const isProtected = init.protected ?? false;
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
headers.set("X-Request-ID", requestId);
|
||||||
|
headers.set("traceparent", traceparent());
|
||||||
|
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||||
|
if (isProtected) {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
|
||||||
|
}
|
||||||
|
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
|
||||||
|
diagnostics.unshift({
|
||||||
|
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
|
||||||
|
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
|
||||||
|
});
|
||||||
|
diagnostics.splice(20);
|
||||||
|
if (response.status === 401 && isProtected && !replayed) {
|
||||||
|
await refreshTokens();
|
||||||
|
return apiRequest<T>(path, init, true);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw await parseError(response, requestId);
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const json = (value: unknown) => JSON.stringify(value);
|
||||||
|
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { AppState, Platform } from "react-native";
|
||||||
|
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
|
||||||
|
import { sessionMemory } from "./api";
|
||||||
|
import { authApi, publicApi } from "./services";
|
||||||
|
import type { Consents } from "./types";
|
||||||
|
|
||||||
|
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
|
||||||
|
type AppContextValue = {
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
realtimeState: string;
|
||||||
|
setRealtimeState: (value: string) => void;
|
||||||
|
authorize: (consents: Consents) => Promise<boolean>;
|
||||||
|
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
ensureSession: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Context = createContext<AppContextValue | null>(null);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
|
||||||
|
const [realtimeState, setRealtimeState] = useState("idle");
|
||||||
|
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const toGuest = useCallback(() => {
|
||||||
|
sessionMemory.clear();
|
||||||
|
setRealtimeState("idle");
|
||||||
|
setAuthStatus("guest");
|
||||||
|
queryClient.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configureAuthFailure(toGuest);
|
||||||
|
void publicApi.config().then((config) => {
|
||||||
|
const minutes = config.ux.idle_timeout_minutes;
|
||||||
|
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
void refreshTokens()
|
||||||
|
.then(() => {
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
void authApi.startSession("cold_start").catch(() => undefined);
|
||||||
|
})
|
||||||
|
.catch(() => setAuthStatus("guest"));
|
||||||
|
}, [toGuest]);
|
||||||
|
|
||||||
|
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
|
||||||
|
setAuthStatus("bootstrapping");
|
||||||
|
if (!getAccessToken()) await completeAuthorization(code, state);
|
||||||
|
await authApi.bootstrap(consents);
|
||||||
|
await authApi.startSession("first_launch");
|
||||||
|
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
router.replace("/");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const authorize = useCallback(async (consents: Consents) => {
|
||||||
|
setAuthStatus("authorizing");
|
||||||
|
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", JSON.stringify(consents));
|
||||||
|
const result = await beginAuthorization();
|
||||||
|
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
|
||||||
|
setAuthStatus("guest");
|
||||||
|
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await finishCallback(result.params.code, result.params.state, consents);
|
||||||
|
return true;
|
||||||
|
}, [finishCallback]);
|
||||||
|
|
||||||
|
const ensureSession = useCallback(async () => {
|
||||||
|
if (authStatus !== "authenticated") return;
|
||||||
|
if (!sessionMemory.id) await authApi.startSession("cold_start");
|
||||||
|
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
|
||||||
|
await authApi.startSession("idle_timeout");
|
||||||
|
}
|
||||||
|
sessionMemory.touch();
|
||||||
|
}, [authStatus, idleTimeoutMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
const activity = () => sessionMemory.touch();
|
||||||
|
const resume = () => { if (!document.hidden) void ensureSession(); };
|
||||||
|
window.addEventListener("pointerdown", activity);
|
||||||
|
window.addEventListener("keydown", activity);
|
||||||
|
document.addEventListener("visibilitychange", resume);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", activity);
|
||||||
|
window.removeEventListener("keydown", activity);
|
||||||
|
document.removeEventListener("visibilitychange", resume);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const subscription = AppState.addEventListener("change", (state) => {
|
||||||
|
if (state === "active") void ensureSession();
|
||||||
|
});
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [ensureSession]);
|
||||||
|
|
||||||
|
const value = useMemo<AppContextValue>(() => ({
|
||||||
|
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
|
||||||
|
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
|
||||||
|
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
|
||||||
|
|
||||||
|
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApp() {
|
||||||
|
const value = useContext(Context);
|
||||||
|
if (!value) throw new Error("AppProvider is missing");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAuthForTests() {
|
||||||
|
await clearTokens();
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import * as AuthSession from "expo-auth-session";
|
||||||
|
import * as Crypto from "expo-crypto";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { env, oidcIssuer } from "./config";
|
||||||
|
import { SingleFlight } from "./single-flight";
|
||||||
|
import type { TokenSet } from "./types";
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
const REFRESH_KEY = "han.refresh-token";
|
||||||
|
const PKCE_KEY = "han.pkce";
|
||||||
|
const PKCE_TTL_MS = 10 * 60_000;
|
||||||
|
let tokens: TokenSet | null = null;
|
||||||
|
const refreshFlight = new SingleFlight<TokenSet>();
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let authFailure: (() => void) | undefined;
|
||||||
|
|
||||||
|
const browserStore = {
|
||||||
|
async get(key: string) {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
},
|
||||||
|
async set(key: string, value: string) {
|
||||||
|
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
async del(key: string) {
|
||||||
|
if (typeof window !== "undefined") window.localStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secureStore = {
|
||||||
|
get: (key: string) =>
|
||||||
|
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
|
||||||
|
set: (key: string, value: string) =>
|
||||||
|
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
|
||||||
|
del: (key: string) =>
|
||||||
|
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
||||||
|
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
|
||||||
|
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
|
export function configureAuthFailure(callback: () => void) {
|
||||||
|
authFailure = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
return tokens?.accessToken ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenInfo() {
|
||||||
|
return tokens ? { expiresAt: tokens.expiresAt } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(next: TokenSet) {
|
||||||
|
tokens = next;
|
||||||
|
await secureStore.set(REFRESH_KEY, next.refreshToken);
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
|
||||||
|
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseTokenResponse(response: Response): Promise<TokenSet> {
|
||||||
|
const body = (await response.json()) as Record<string, unknown>;
|
||||||
|
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
|
||||||
|
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: body.access_token,
|
||||||
|
refreshToken: body.refresh_token,
|
||||||
|
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
|
||||||
|
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginAuthorization() {
|
||||||
|
const verifier = random();
|
||||||
|
const state = random();
|
||||||
|
const nonce = random();
|
||||||
|
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
|
||||||
|
encoding: Crypto.CryptoEncoding.BASE64,
|
||||||
|
});
|
||||||
|
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
|
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
|
||||||
|
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
|
||||||
|
client_id: env.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid profile offline_access",
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
})}`;
|
||||||
|
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
|
||||||
|
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
|
||||||
|
const callback = new URL(result.url);
|
||||||
|
return {
|
||||||
|
type: "success" as const,
|
||||||
|
params: {
|
||||||
|
code: callback.searchParams.get("code"),
|
||||||
|
state: callback.searchParams.get("state"),
|
||||||
|
error: callback.searchParams.get("error"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeAuthorization(code: string, state: string) {
|
||||||
|
const raw = await secureStore.get(PKCE_KEY);
|
||||||
|
await secureStore.del(PKCE_KEY);
|
||||||
|
if (!raw) throw new Error("pkce_state_missing");
|
||||||
|
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
|
||||||
|
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
|
||||||
|
throw new Error("pkce_state_invalid");
|
||||||
|
}
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: env.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code,
|
||||||
|
code_verifier: saved.verifier,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const next = await parseTokenResponse(response);
|
||||||
|
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
|
||||||
|
await clearTokens();
|
||||||
|
throw new Error("oidc_nonce_invalid");
|
||||||
|
}
|
||||||
|
await persist(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJwtClaim(token: string, claim: string) {
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
if (!payload) return undefined;
|
||||||
|
try {
|
||||||
|
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
const decoded = decodeURIComponent(
|
||||||
|
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
|
||||||
|
);
|
||||||
|
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshTokens(): Promise<TokenSet> {
|
||||||
|
return refreshFlight.run(async () => {
|
||||||
|
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
|
||||||
|
if (!refreshToken) throw new Error("refresh_token_missing");
|
||||||
|
try {
|
||||||
|
const response = await fetch(tokenEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: env.clientId,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const next = await parseTokenResponse(response);
|
||||||
|
await persist(next);
|
||||||
|
return next;
|
||||||
|
} catch (error) {
|
||||||
|
await clearTokens();
|
||||||
|
authFailure?.();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens() {
|
||||||
|
tokens = null;
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
await secureStore.del(REFRESH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const idToken = tokens?.idToken;
|
||||||
|
await clearTokens();
|
||||||
|
if (idToken) {
|
||||||
|
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const required = (name: string, fallback: string) =>
|
||||||
|
(process.env[name] ?? fallback).replace(/\/$/, "");
|
||||||
|
|
||||||
|
export const env = Object.freeze({
|
||||||
|
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
|
||||||
|
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
|
||||||
|
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
||||||
|
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
||||||
|
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
|
||||||
|
export const isProduction = env.appEnv === "production";
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { env } from "./config";
|
||||||
|
import { getAccessToken, refreshTokens } from "./auth";
|
||||||
|
import { dialogApi } from "./services";
|
||||||
|
import type { Message } from "./types";
|
||||||
|
export { reconcileMessages } from "./reconcile";
|
||||||
|
|
||||||
|
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
|
||||||
|
export type RealtimeEvent =
|
||||||
|
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
|
||||||
|
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
|
||||||
|
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
|
||||||
|
|
||||||
|
const safeCursors = new Map<string, string>();
|
||||||
|
export const getRealtimeDiagnostics = () =>
|
||||||
|
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
|
||||||
|
dialog: `${dialogId.slice(0, 8)}…`,
|
||||||
|
cursor,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function websocketJwtProtocol(token: string) {
|
||||||
|
const bytes = new TextEncoder().encode(token);
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||||
|
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealtimeClient {
|
||||||
|
private socket?: WebSocket;
|
||||||
|
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private pollingTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private disconnectedAt = 0;
|
||||||
|
private attempt = 0;
|
||||||
|
private stopped = true;
|
||||||
|
private cursors = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dialogIds: string[],
|
||||||
|
private readonly onEvent: (event: RealtimeEvent) => void,
|
||||||
|
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
|
||||||
|
private readonly onState: (state: RealtimeState) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
updateDialogs(ids: string[]) {
|
||||||
|
this.dialogIds = [...new Set(ids)];
|
||||||
|
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.stopped) return;
|
||||||
|
this.stopped = false;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||||
|
this.socket?.close();
|
||||||
|
this.onState("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) return;
|
||||||
|
this.onState("connecting");
|
||||||
|
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
|
||||||
|
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
this.attempt = 0;
|
||||||
|
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
|
||||||
|
// уже попадут в merge, а дубли устраняются по message_id.
|
||||||
|
this.subscribe();
|
||||||
|
void this.reconcileAll().then(() => {
|
||||||
|
this.stopPolling();
|
||||||
|
this.onState("websocket");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.socket.onmessage = ({ data }) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
|
||||||
|
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
|
||||||
|
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
|
||||||
|
const known = event as RealtimeEvent;
|
||||||
|
if (known.cursor) {
|
||||||
|
this.cursors.set(known.dialog_id, known.cursor);
|
||||||
|
safeCursors.set(known.dialog_id, known.cursor);
|
||||||
|
}
|
||||||
|
this.onEvent(known);
|
||||||
|
}
|
||||||
|
} catch { /* unknown and malformed events are safely ignored */ }
|
||||||
|
};
|
||||||
|
this.socket.onclose = (event) => {
|
||||||
|
if (this.stopped) return;
|
||||||
|
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
|
||||||
|
if (event.code === 4401 || event.code === 1008) {
|
||||||
|
void refreshTokens().finally(() => this.scheduleReconnect());
|
||||||
|
} else {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.socket.onerror = () => this.socket?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribe() {
|
||||||
|
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
|
||||||
|
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
|
||||||
|
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
||||||
|
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileAll() {
|
||||||
|
await Promise.all(this.dialogIds.map(async (id) => {
|
||||||
|
const page = await dialogApi.messages(id, this.cursors.get(id));
|
||||||
|
if (page.items.length) this.onMessages(id, page.items);
|
||||||
|
if (page.next_cursor) {
|
||||||
|
this.cursors.set(id, page.next_cursor);
|
||||||
|
safeCursors.set(id, page.next_cursor);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.disconnectedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling() {
|
||||||
|
if (this.pollingTimer) return;
|
||||||
|
this.onState("polling");
|
||||||
|
const poll = async () => {
|
||||||
|
if (this.stopped) return;
|
||||||
|
await this.reconcileAll().catch(() => undefined);
|
||||||
|
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
|
||||||
|
this.pollingTimer = setTimeout(poll, delay);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling() {
|
||||||
|
if (this.pollingTimer) clearTimeout(this.pollingTimer);
|
||||||
|
this.pollingTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Message } from "./types";
|
||||||
|
|
||||||
|
export function reconcileMessages(current: Message[], incoming: Message[]) {
|
||||||
|
const byId = new Map(current.map((message) => [message.message_id, message]));
|
||||||
|
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
|
||||||
|
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import type {
|
||||||
|
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const publicApi = {
|
||||||
|
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
|
||||||
|
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
bootstrap: (consents: Consents) =>
|
||||||
|
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
|
||||||
|
method: "POST", protected: true,
|
||||||
|
body: json({ consents, device: deviceMetadata() }),
|
||||||
|
}),
|
||||||
|
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
|
||||||
|
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
|
||||||
|
"/api/v1/analytics/session-start",
|
||||||
|
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
|
||||||
|
);
|
||||||
|
sessionMemory.set(result.ux_session_id);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
saveConsents: (consents: Consents) =>
|
||||||
|
apiRequest<void>("/api/v1/consents", {
|
||||||
|
method: "POST", protected: true, body: json({ consents }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
function deviceMetadata() {
|
||||||
|
const platform = Platform.OS;
|
||||||
|
if (platform !== "ios" && platform !== "android" && platform !== "web") {
|
||||||
|
throw new Error(`Unsupported platform: ${platform}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
app_version: "1.0.0",
|
||||||
|
device_id: "frontend-test-site",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dialogApi = {
|
||||||
|
list: (cursor?: string) =>
|
||||||
|
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
|
||||||
|
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
|
||||||
|
create: (key: string) =>
|
||||||
|
apiRequest<Dialog>("/api/v1/dialogs", {
|
||||||
|
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
|
||||||
|
}),
|
||||||
|
messages: (id: string, after?: string) =>
|
||||||
|
apiRequest<Page<Message>>(
|
||||||
|
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
|
||||||
|
{ protected: true },
|
||||||
|
),
|
||||||
|
sendText: (id: string, text: string, key: string) =>
|
||||||
|
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||||
|
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||||
|
body: json({ content_kind: "text", text }),
|
||||||
|
}),
|
||||||
|
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
|
||||||
|
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
|
||||||
|
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||||
|
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
|
||||||
|
const checksum = await sha256(file);
|
||||||
|
const init = await apiRequest<{
|
||||||
|
attachment_id: string;
|
||||||
|
upload_url: string;
|
||||||
|
upload_headers?: Record<string, string>;
|
||||||
|
expires_at: string;
|
||||||
|
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
|
||||||
|
method: "POST", protected: true, headers: idempotencyHeaders(key),
|
||||||
|
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
|
||||||
|
});
|
||||||
|
const upload = await fetch(init.upload_url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: init.upload_headers ?? { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
|
||||||
|
await apiRequest<void>(
|
||||||
|
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
|
||||||
|
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
|
||||||
|
);
|
||||||
|
return { attachmentId: init.attachment_id, checksum };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(file: Blob) {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||||
|
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileApi = {
|
||||||
|
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
|
||||||
|
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
|
||||||
|
documentUrl: (id: string) =>
|
||||||
|
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
|
||||||
|
attachmentUrl: (dialogId: string, id: string) =>
|
||||||
|
apiRequest<{ download_url: string }>(
|
||||||
|
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
|
||||||
|
{ protected: true },
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
let uxSessionId: string | null = null;
|
||||||
|
let lastActivityAt = Date.now();
|
||||||
|
|
||||||
|
export const sessionMemory = {
|
||||||
|
get id() { return uxSessionId; },
|
||||||
|
get lastActivityAt() { return lastActivityAt; },
|
||||||
|
touch() { lastActivityAt = Date.now(); },
|
||||||
|
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
|
||||||
|
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export class SingleFlight<T> {
|
||||||
|
private running: Promise<T> | null = null;
|
||||||
|
|
||||||
|
run(operation: () => Promise<T>): Promise<T> {
|
||||||
|
if (this.running) return this.running;
|
||||||
|
this.running = operation().finally(() => {
|
||||||
|
this.running = null;
|
||||||
|
});
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export type Consent = { accepted: boolean; version: string };
|
||||||
|
export type Consents = {
|
||||||
|
personal_data: Consent;
|
||||||
|
user_agreement: Consent;
|
||||||
|
marketing: Consent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TokenSet = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
idToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
|
||||||
|
export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string };
|
||||||
|
export type Attachment = {
|
||||||
|
attachment_id: string;
|
||||||
|
file_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
scan_status: "pending" | "clean" | "infected" | "failed";
|
||||||
|
};
|
||||||
|
export type Message = {
|
||||||
|
message_id: string;
|
||||||
|
dialog_id: string;
|
||||||
|
sender_type: "client" | "company";
|
||||||
|
content_kind: "text" | "file";
|
||||||
|
text: string;
|
||||||
|
attachments: Attachment[];
|
||||||
|
safety_status: "pending" | "allowed" | "blocked";
|
||||||
|
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export type Page<T> = { items: T[]; next_cursor: string | null };
|
||||||
|
|
||||||
|
export type Profile = {
|
||||||
|
user_id: string;
|
||||||
|
profile: {
|
||||||
|
personal_data: {
|
||||||
|
full_name: string | null;
|
||||||
|
citizenship: string | null;
|
||||||
|
russian_phone: string | null;
|
||||||
|
foreign_phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
|
documents: { count: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type DocumentItem = {
|
||||||
|
document_id: string;
|
||||||
|
name: string;
|
||||||
|
sent_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicConfig = {
|
||||||
|
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||||
|
operator: { call_phone: string };
|
||||||
|
consents: Record<string, {
|
||||||
|
required: boolean;
|
||||||
|
document_url: string | null;
|
||||||
|
version: string;
|
||||||
|
}>;
|
||||||
|
attachments: {
|
||||||
|
max_size_mb: number;
|
||||||
|
allowed_extensions: string[];
|
||||||
|
allowed_mime_types: string[];
|
||||||
|
};
|
||||||
|
ux: { idle_timeout_minutes: number };
|
||||||
|
};
|
||||||
|
export type PublicContent = {
|
||||||
|
locale: string;
|
||||||
|
texts: Record<string, string>;
|
||||||
|
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Link } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
|
||||||
|
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
|
||||||
|
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
|
||||||
|
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
|
||||||
|
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
|
||||||
|
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
|
||||||
|
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
|
||||||
|
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
|
||||||
|
textarea: { minHeight: 104, textAlignVertical: "top" },
|
||||||
|
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
|
||||||
|
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
|
||||||
|
buttonSecondary: { backgroundColor: "#e8eef8" },
|
||||||
|
buttonDanger: { backgroundColor: "#b42318" },
|
||||||
|
buttonDisabled: { opacity: 0.5 },
|
||||||
|
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
|
||||||
|
buttonTextSecondary: { color: "#173b70" },
|
||||||
|
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
|
||||||
|
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
|
||||||
|
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
|
||||||
|
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
|
||||||
|
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
|
||||||
|
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
|
||||||
|
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
|
||||||
|
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
|
||||||
|
return <View style={styles.header}>
|
||||||
|
<Link href="/" style={styles.link}>HAN Chat</Link>
|
||||||
|
<Link href="/dialogs" style={styles.link}>Диалоги</Link>
|
||||||
|
<Link href="/profile" style={styles.link}>Профиль</Link>
|
||||||
|
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
|
||||||
|
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
|
||||||
|
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ title, onPress, disabled, secondary, danger }: {
|
||||||
|
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return <Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ focused }) => [
|
||||||
|
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
|
||||||
|
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
|
||||||
|
</Pressable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
|
||||||
|
return <View style={{ gap: 6 }}>
|
||||||
|
<Text style={styles.text}>{props.label}</Text>
|
||||||
|
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
|
||||||
|
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading() {
|
||||||
|
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка…</Text></View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||||
|
const requestId = error instanceof ApiError ? error.requestId : undefined;
|
||||||
|
return <View style={{ gap: 8 }}>
|
||||||
|
<Text accessibilityRole="alert" style={styles.error}>
|
||||||
|
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
|
||||||
|
{requestId ? `\nКод обращения: ${requestId}` : ""}
|
||||||
|
</Text>
|
||||||
|
{retry && <Button title="Повторить" secondary onPress={retry} />}
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
|
||||||
|
json: {
|
||||||
|
auth: { phone_enabled: true, password_enabled: false },
|
||||||
|
operator: { call_phone: "+74950000000" },
|
||||||
|
ux: { idle_timeout_minutes: 15 },
|
||||||
|
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
||||||
|
consents: {
|
||||||
|
personal_data: { required: true, version: "2026-07-01", document_url: "https://example.test/personal" },
|
||||||
|
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
|
||||||
|
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await page.route("**/api/v1/public/content", (route) => route.fulfill({
|
||||||
|
json: {
|
||||||
|
locale: "ru",
|
||||||
|
texts: { welcome: "Добро пожаловать в HAN Chat" },
|
||||||
|
popular_questions: [{ id: "visa", mnemonic: "visa", text: "Как оформить визу?" }],
|
||||||
|
version: "1",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("гостевой экран загружает публичный контент", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("heading", { name: "Помощь мигрантам" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
|
||||||
|
await expect(page.getByText(/Гость/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("первое сообщение требует обязательные согласия", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
||||||
|
await page.getByRole("button", { name: "Отправить" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
||||||
|
let protectedCalls = 0;
|
||||||
|
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
|
||||||
|
await page.goto("/profile");
|
||||||
|
await expect(page.getByText("Профиль доступен после авторизации.")).toBeVisible();
|
||||||
|
expect(protectedCalls).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { reconcileMessages } from "../../src/reconcile";
|
||||||
|
import { sessionMemory } from "../../src/session";
|
||||||
|
import { SingleFlight } from "../../src/single-flight";
|
||||||
|
import { websocketJwtProtocol } from "../../src/realtime";
|
||||||
|
import type { Message } from "../../src/types";
|
||||||
|
|
||||||
|
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
|
||||||
|
message_id: id,
|
||||||
|
dialog_id: "dialog",
|
||||||
|
sender_type: "client",
|
||||||
|
content_kind: "text",
|
||||||
|
text: "Тест",
|
||||||
|
attachments: [],
|
||||||
|
safety_status: "allowed",
|
||||||
|
delivery_status: status,
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconcileMessages", () => {
|
||||||
|
it("устраняет дубли, обновляет статус и сортирует сообщения", () => {
|
||||||
|
const result = reconcileMessages(
|
||||||
|
[message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")],
|
||||||
|
[message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")],
|
||||||
|
);
|
||||||
|
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
|
||||||
|
expect(result[1]?.delivery_status).toBe("delivered");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UX session memory", () => {
|
||||||
|
it("хранит идентификатор только в памяти и очищает его", () => {
|
||||||
|
sessionMemory.set("ux-test");
|
||||||
|
expect(sessionMemory.id).toBe("ux-test");
|
||||||
|
sessionMemory.clear();
|
||||||
|
expect(sessionMemory.id).toBeNull();
|
||||||
|
expect(localStorage.getItem("ux_session_id")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SingleFlight", () => {
|
||||||
|
it("объединяет параллельные refresh операции", async () => {
|
||||||
|
const flight = new SingleFlight<number>();
|
||||||
|
let calls = 0;
|
||||||
|
const operation = async () => {
|
||||||
|
calls++;
|
||||||
|
await Promise.resolve();
|
||||||
|
return 42;
|
||||||
|
};
|
||||||
|
const [first, second, third] = await Promise.all([
|
||||||
|
flight.run(operation), flight.run(operation), flight.run(operation),
|
||||||
|
]);
|
||||||
|
expect([first, second, third]).toEqual([42, 42, 42]);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WebSocket authentication protocol", () => {
|
||||||
|
it("кодирует JWT как canonical han.jwt.<base64url(jwt)>", () => {
|
||||||
|
const protocol = websocketJwtProtocol("header.payload.signature");
|
||||||
|
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
|
||||||
|
expect(protocol).not.toContain("=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] },
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
||||||
|
clearMocks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
x-api-runtime: &api-runtime
|
||||||
|
build:
|
||||||
|
context: ../../api-backend
|
||||||
|
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||||
|
env_file:
|
||||||
|
- path: ../../.env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${PG_CA_HOST_PATH}
|
||||||
|
target: /run/secrets/pg-ca.pem
|
||||||
|
read_only: true
|
||||||
|
networks: [backend, observability]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "50m", max-file: "5"}
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend-static:
|
||||||
|
build:
|
||||||
|
context: ../../frontend-test-site
|
||||||
|
target: static
|
||||||
|
args:
|
||||||
|
EXPO_PUBLIC_API_BASE_URL: ${PUBLIC_WEB_URL}
|
||||||
|
EXPO_PUBLIC_AUTH_BASE_URL: ${PUBLIC_AUTH_URL}
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||||
|
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID: han-chat-frontend
|
||||||
|
EXPO_PUBLIC_APP_ENV: ${APP_ENV:-production-like}
|
||||||
|
image: han-chat-frontend-static:${RELEASE_VERSION:-local}
|
||||||
|
volumes:
|
||||||
|
- frontend-static:/output
|
||||||
|
restart: "no"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=8m,mode=1777
|
||||||
|
cap_drop: ["ALL"]
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
build:
|
||||||
|
context: ../../keycloak
|
||||||
|
image: ${KEYCLOAK_IMAGE:-han-chat-keycloak:local}
|
||||||
|
env_file:
|
||||||
|
- path: ../../.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
KC_DB: postgres
|
||||||
|
KC_DB_URL: ${KEYCLOAK_DB_URL}
|
||||||
|
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME}
|
||||||
|
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
|
||||||
|
KC_PROXY_HEADERS: xforwarded
|
||||||
|
KC_HTTP_ENABLED: "true"
|
||||||
|
KC_HTTP_RELATIVE_PATH: /auth
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
KC_METRICS_ENABLED: "true"
|
||||||
|
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
|
||||||
|
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-false}
|
||||||
|
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
|
||||||
|
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
|
||||||
|
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||||
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||||
|
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
|
||||||
|
command: ["start", "--optimized", "--import-realm"]
|
||||||
|
expose: ["8080", "9000"]
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${PG_CA_HOST_PATH}
|
||||||
|
target: /run/secrets/pg-ca.pem
|
||||||
|
read_only: true
|
||||||
|
networks: [public, backend, observability]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 60s
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "50m", max-file: "5"}
|
||||||
|
|
||||||
|
message-safety:
|
||||||
|
build:
|
||||||
|
context: ../../message-safety
|
||||||
|
image: ${MESSAGE_SAFETY_IMAGE:-han-chat-message-safety:local}
|
||||||
|
env_file:
|
||||||
|
- path: ../../.env
|
||||||
|
required: false
|
||||||
|
expose: ["8080"]
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${PG_CA_HOST_PATH}
|
||||||
|
target: /run/secrets/pg-ca.pem
|
||||||
|
read_only: true
|
||||||
|
networks: [backend, observability]
|
||||||
|
depends_on:
|
||||||
|
redis: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "50m", max-file: "5"}
|
||||||
|
|
||||||
|
api-backend:
|
||||||
|
<<: *api-runtime
|
||||||
|
expose: ["8000"]
|
||||||
|
depends_on:
|
||||||
|
redis: {condition: service_healthy}
|
||||||
|
keycloak: {condition: service_healthy}
|
||||||
|
message-safety: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
delivery-worker:
|
||||||
|
<<: *api-runtime
|
||||||
|
entrypoint: []
|
||||||
|
command: ["han-delivery-worker"]
|
||||||
|
depends_on:
|
||||||
|
api-backend: {condition: service_healthy}
|
||||||
|
bitrix-local-app: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-delivery-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
safety-recovery-worker:
|
||||||
|
<<: *api-runtime
|
||||||
|
entrypoint: []
|
||||||
|
command: ["han-safety-worker"]
|
||||||
|
depends_on:
|
||||||
|
api-backend: {condition: service_healthy}
|
||||||
|
message-safety: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-safety-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
cleanup-worker:
|
||||||
|
<<: *api-runtime
|
||||||
|
entrypoint: []
|
||||||
|
command: ["han-cleanup-worker"]
|
||||||
|
depends_on:
|
||||||
|
api-backend: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "from pathlib import Path; assert b'han-cleanup-worker' in Path('/proc/1/cmdline').read_bytes()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
bitrix-local-app:
|
||||||
|
build:
|
||||||
|
context: ../../bitrix-local-app
|
||||||
|
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||||
|
env_file:
|
||||||
|
- path: ../../.env
|
||||||
|
required: false
|
||||||
|
expose: ["8080"]
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${PG_CA_HOST_PATH}
|
||||||
|
target: /run/secrets/pg-ca.pem
|
||||||
|
read_only: true
|
||||||
|
networks: [backend, observability]
|
||||||
|
depends_on:
|
||||||
|
api-backend: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "50m", max-file: "5"}
|
||||||
|
|
||||||
|
bitrix-sync:
|
||||||
|
build:
|
||||||
|
context: ../../bitrix-sync
|
||||||
|
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||||
|
env_file:
|
||||||
|
- path: ../../.env
|
||||||
|
required: false
|
||||||
|
expose: ["8080"]
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${PG_CA_HOST_PATH}
|
||||||
|
target: /run/secrets/pg-ca.pem
|
||||||
|
read_only: true
|
||||||
|
networks: [backend, observability]
|
||||||
|
depends_on:
|
||||||
|
redis: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=3)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: "50m", max-file: "5"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
target
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.iml
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
|
||||||
|
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.internal:6432/han_chat?sslmode=verify-full¤tSchema=keycloak&ApplicationName=keycloak
|
||||||
|
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=bootstrap-admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=replace-with-random-secret
|
||||||
|
|
||||||
|
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||||
|
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
|
||||||
|
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
|
||||||
|
KEYCLOAK_OTP_TTL_SEC=300
|
||||||
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||||
|
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
|
||||||
|
|
||||||
|
KEYCLOAK_LOG_LEVEL=INFO
|
||||||
|
KEYCLOAK_JAVA_OPTS=-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
ARG KEYCLOAK_VERSION=26.1.4
|
||||||
|
|
||||||
|
FROM maven:3.9.9-eclipse-temurin-21 AS provider-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
|
||||||
|
COPY src ./src
|
||||||
|
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||||
|
|
||||||
|
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
||||||
|
COPY --from=provider-build /build/target/han-phone-otp-provider.jar /opt/keycloak/providers/
|
||||||
|
COPY themes/han-phone /opt/keycloak/themes/han-phone
|
||||||
|
ENV KC_HEALTH_ENABLED=true \
|
||||||
|
KC_METRICS_ENABLED=true \
|
||||||
|
KC_DB=postgres \
|
||||||
|
KC_HTTP_RELATIVE_PATH=/auth
|
||||||
|
RUN /opt/keycloak/bin/kc.sh build
|
||||||
|
|
||||||
|
FROM quay.io/keycloak/keycloak:26.1.4
|
||||||
|
COPY --from=keycloak-build --chown=keycloak:keycloak /opt/keycloak/ /opt/keycloak/
|
||||||
|
COPY --chown=keycloak:keycloak realm/han-chat-realm.json /opt/keycloak/data/import/han-chat-realm.json
|
||||||
|
USER 1000
|
||||||
|
EXPOSE 8080 9000
|
||||||
|
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
|
||||||
|
CMD ["start", "--optimized", "--import-realm"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# HAN Chat Keycloak
|
||||||
|
|
||||||
|
Production-like Keycloak 26.1.4 image and realm for OTP-only phone authentication. The module is self-contained and does not publish host ports; root nginx must proxy `/auth/*` to `keycloak:8080`.
|
||||||
|
|
||||||
|
## Security contract
|
||||||
|
|
||||||
|
- Realm `han-chat`; public client `han-chat-frontend`.
|
||||||
|
- Authorization Code flow only, mandatory PKCE S256; implicit, password/direct, device and service-account grants are disabled.
|
||||||
|
- Access tokens contain audience `han-chat-api`, canonical E.164 `phone_number` and boolean `phone_number_verified`.
|
||||||
|
- Access token lifetime is 5 minutes. Refresh token rotation is enabled with max reuse `0`; SSO idle/max are 30/90 days.
|
||||||
|
- Realm brute-force protection uses temporary bounded lockouts.
|
||||||
|
- OTP challenges, send counters and security events are stored in provider-owned PostgreSQL tables in the Keycloak schema. Liquibase migration `han-otp-1.0.0` is applied by Keycloak's JPA entity provider.
|
||||||
|
- OTP and phone values are never logged. Durable rate records use HMAC-SHA256 phone identifiers; challenge verification uses HMAC and constant-time comparison.
|
||||||
|
- Settings are fetched only from `GET /internal/settings/v1/otp` with `Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}`. ETag/cache and bounded last-known-good are supported; an empty or stale cache fails closed.
|
||||||
|
- Mock mode is explicit. Startup rejects missing values, code `1234`, codes shorter than six characters, and HMAC keys shorter than 32 bytes. Disabling mock mode without a real delivery provider fails startup.
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
Requires Java 21 and Maven 3.9:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn -B -ntp clean verify
|
||||||
|
docker build -t han-chat/keycloak:26.1.4-otp-1.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
The Maven build shades only libphonenumber into the provider JAR; Keycloak SPI dependencies remain provided by the pinned server image.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy values from `.env.example` into the root backend `.env`; never commit `.env`. Generate independent random values for admin password, mock code, OTP HMAC key and settings bridge token.
|
||||||
|
|
||||||
|
Before production deployment replace the explicit placeholder entries in `realm/han-chat-realm.json`:
|
||||||
|
|
||||||
|
- `https://APP_LINK_HOST.example/auth/callback`
|
||||||
|
- `https://APP_LINK_HOST.example/auth/logout`
|
||||||
|
- `https://APP_WEB_ORIGIN.example`
|
||||||
|
|
||||||
|
Use exact Expo universal/app links and web origins. Do not replace them with wildcards. `https://tohin.ru/auth/callback` and `han-chat://auth/callback` are already allow-listed.
|
||||||
|
|
||||||
|
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
For standalone validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The service exposes only Docker-network ports:
|
||||||
|
|
||||||
|
- application HTTP: `8080`, relative path `/auth`;
|
||||||
|
- management health and metrics: `9000`;
|
||||||
|
- readiness: `GET http://keycloak:9000/auth/health/ready`;
|
||||||
|
- liveness: `GET http://keycloak:9000/auth/health/live`;
|
||||||
|
- Prometheus metrics: `GET http://keycloak:9000/auth/metrics`.
|
||||||
|
|
||||||
|
Only nginx may publish external ports. Preserve `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443` and the trusted client IP chain.
|
||||||
|
|
||||||
|
## Realm lifecycle
|
||||||
|
|
||||||
|
`--import-realm` is suitable for a clean environment. It does not safely reconcile an existing production realm. For changes to a live realm:
|
||||||
|
|
||||||
|
1. take a managed PostgreSQL backup/PITR checkpoint and export the current realm without users/secrets;
|
||||||
|
2. compare the desired safe subset (clients, scopes, flows, token policy);
|
||||||
|
3. apply through a controlled admin job or Admin API procedure;
|
||||||
|
4. verify discovery issuer, JWKS, PKCE login, refresh rotation and logout;
|
||||||
|
5. retain old passive signing keys until all tokens signed by them expire.
|
||||||
|
|
||||||
|
Private signing keys are generated and stored by Keycloak and are absent from the realm JSON.
|
||||||
|
|
||||||
|
## OTP data and operations
|
||||||
|
|
||||||
|
Provider tables:
|
||||||
|
|
||||||
|
- `han_otp_challenge`: expiring, one-time challenges with optimistic version and pessimistic verification lock;
|
||||||
|
- `han_otp_send_counter`: durable 24-hour counter/cooldown per phone HMAC;
|
||||||
|
- `han_otp_security_event`: append-only minimal outcomes without raw phone or OTP.
|
||||||
|
|
||||||
|
Resend marks an earlier active challenge as superseded. Verification locks a challenge row, increments attempts, and atomically consumes a valid challenge, preventing replay and parallel double use.
|
||||||
|
|
||||||
|
Expired challenge and old security-event retention should be removed by a scheduled database maintenance job executed with the Keycloak schema role. Recommended retention is 24 hours for expired challenges/counters and the legally approved audit retention for security events. Cleanup must run in bounded batches and must not alter standard Keycloak tables.
|
||||||
|
|
||||||
|
## Release and recovery
|
||||||
|
|
||||||
|
Before upgrading Keycloak, read migration notes, rebuild the provider against the exact target SPI version, test on a database clone, and execute OTP login/refresh/logout contract tests. Do not skip major versions without a supported path.
|
||||||
|
|
||||||
|
Backups must include the full Keycloak schema (realm signing keys and provider tables). After restore verify issuer `https://tohin.ru/auth/realms/han-chat`, JWKS, client redirects, browser flow binding, challenge persistence and refresh revocation before opening traffic.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: han-chat/keycloak:26.1.4-otp-1.0.0
|
||||||
|
command: ["start", "--optimized", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
KC_DB: postgres
|
||||||
|
KC_DB_URL: ${KEYCLOAK_DB_URL:?KEYCLOAK_DB_URL is required}
|
||||||
|
KC_DB_URL_PROPERTIES: ${KC_DB_URL_PROPERTIES:-currentSchema=keycloak}
|
||||||
|
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:-https://tohin.ru/auth}
|
||||||
|
KC_HOSTNAME_STRICT: "true"
|
||||||
|
KC_HTTP_ENABLED: "true"
|
||||||
|
KC_HTTP_PORT: "8080"
|
||||||
|
KC_HTTP_RELATIVE_PATH: /auth
|
||||||
|
KC_PROXY_HEADERS: xforwarded
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
KC_METRICS_ENABLED: "true"
|
||||||
|
KC_HTTP_MANAGEMENT_PORT: "9000"
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}
|
||||||
|
KEYCLOAK_OTP_MOCK_ENABLED: ${KEYCLOAK_OTP_MOCK_ENABLED:-true}
|
||||||
|
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
|
||||||
|
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
|
||||||
|
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||||
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||||
|
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||||
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
|
||||||
|
KC_LOG_CONSOLE_OUTPUT: json
|
||||||
|
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-INFO}
|
||||||
|
JAVA_OPTS_APPEND: ${KEYCLOAK_JAVA_OPTS:--XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=35}
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
- "9000"
|
||||||
|
networks:
|
||||||
|
- public
|
||||||
|
- backend
|
||||||
|
- observability
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 60s
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m,mode=1770
|
||||||
|
- /opt/keycloak/data/tmp:size=64m,uid=1000,gid=0,mode=0770
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
stop_grace_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public:
|
||||||
|
backend:
|
||||||
|
observability:
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>ru.han.chat</groupId>
|
||||||
|
<artifactId>han-phone-otp-provider</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<keycloak.version>26.1.4</keycloak.version>
|
||||||
|
<libphonenumber.version>8.13.55</libphonenumber.version>
|
||||||
|
<junit.version>5.11.4</junit.version>
|
||||||
|
<mockito.version>5.15.2</mockito.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi-private</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-services</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-model-jpa</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.libphonenumber</groupId>
|
||||||
|
<artifactId>libphonenumber</artifactId>
|
||||||
|
<version>${libphonenumber.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-junit-jupiter</artifactId>
|
||||||
|
<version>${mockito.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>han-phone-otp-provider</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.2</version>
|
||||||
|
<configuration>
|
||||||
|
<useModulePath>false</useModulePath>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>shade</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<artifactSet>
|
||||||
|
<includes>
|
||||||
|
<include>com.googlecode.libphonenumber:libphonenumber</include>
|
||||||
|
</includes>
|
||||||
|
</artifactSet>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
<shadedArtifactAttached>false</shadedArtifactAttached>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
{
|
||||||
|
"realm": "han-chat",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "HAN Chat",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"registrationEmailAsUsername": false,
|
||||||
|
"resetPasswordAllowed": false,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"loginWithEmailAllowed": false,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"verifyEmail": false,
|
||||||
|
"rememberMe": true,
|
||||||
|
"sslRequired": "external",
|
||||||
|
"defaultSignatureAlgorithm": "RS256",
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"accessCodeLifespan": 60,
|
||||||
|
"accessCodeLifespanLogin": 300,
|
||||||
|
"ssoSessionIdleTimeout": 2592000,
|
||||||
|
"ssoSessionMaxLifespan": 7776000,
|
||||||
|
"clientSessionIdleTimeout": 2592000,
|
||||||
|
"clientSessionMaxLifespan": 7776000,
|
||||||
|
"revokeRefreshToken": true,
|
||||||
|
"refreshTokenMaxReuse": 0,
|
||||||
|
"offlineSessionMaxLifespanEnabled": true,
|
||||||
|
"offlineSessionMaxLifespan": 0,
|
||||||
|
"bruteForceProtected": true,
|
||||||
|
"permanentLockout": false,
|
||||||
|
"maxTemporaryLockouts": 0,
|
||||||
|
"failureFactor": 5,
|
||||||
|
"waitIncrementSeconds": 60,
|
||||||
|
"quickLoginCheckMilliSeconds": 1000,
|
||||||
|
"minimumQuickLoginWaitSeconds": 60,
|
||||||
|
"maxFailureWaitSeconds": 900,
|
||||||
|
"maxDeltaTimeSeconds": 43200,
|
||||||
|
"eventsEnabled": true,
|
||||||
|
"eventsExpiration": 7776000,
|
||||||
|
"enabledEventTypes": [
|
||||||
|
"LOGIN", "LOGIN_ERROR", "LOGOUT", "LOGOUT_ERROR",
|
||||||
|
"REFRESH_TOKEN", "REFRESH_TOKEN_ERROR", "REVOKE_GRANT", "REVOKE_GRANT_ERROR"
|
||||||
|
],
|
||||||
|
"adminEventsEnabled": true,
|
||||||
|
"adminEventsDetailsEnabled": false,
|
||||||
|
"internationalizationEnabled": true,
|
||||||
|
"supportedLocales": ["ru"],
|
||||||
|
"defaultLocale": "ru",
|
||||||
|
"loginTheme": "han-phone",
|
||||||
|
"browserFlow": "han-phone-otp-browser",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "han-chat-frontend",
|
||||||
|
"name": "HAN Chat Frontend",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"authorizationServicesEnabled": false,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"fullScopeAllowed": false,
|
||||||
|
"redirectUris": [
|
||||||
|
"https://tohin.ru/auth/callback",
|
||||||
|
"han-chat://auth/callback",
|
||||||
|
"https://APP_LINK_HOST.example/auth/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"https://tohin.ru",
|
||||||
|
"https://APP_WEB_ORIGIN.example"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "https://tohin.ru/##han-chat://auth/logout##https://APP_LINK_HOST.example/auth/logout",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"use.refresh.tokens": "true",
|
||||||
|
"client.use.lightweight.access.token.enabled": "false"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"],
|
||||||
|
"optionalClientScopes": ["offline_access"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "phone",
|
||||||
|
"description": "Verified E.164 phone claims",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "phone number",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "phone_number",
|
||||||
|
"claim.name": "phone_number",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone verified",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "phone_number_verified",
|
||||||
|
"claim.name": "phone_number_verified",
|
||||||
|
"jsonType.label": "boolean",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "han-chat-api",
|
||||||
|
"description": "HAN Chat API audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "false",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "han-chat-api audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "han-chat-api",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"authenticationFlows": [
|
||||||
|
{
|
||||||
|
"alias": "han-phone-otp-browser",
|
||||||
|
"description": "Cookie SSO or phone OTP only",
|
||||||
|
"providerId": "basic-flow",
|
||||||
|
"topLevel": true,
|
||||||
|
"builtIn": false,
|
||||||
|
"authenticationExecutions": [
|
||||||
|
{
|
||||||
|
"authenticator": "auth-cookie",
|
||||||
|
"requirement": "ALTERNATIVE",
|
||||||
|
"priority": 10,
|
||||||
|
"authenticatorFlow": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"flowAlias": "han-phone-otp-forms",
|
||||||
|
"requirement": "ALTERNATIVE",
|
||||||
|
"priority": 20,
|
||||||
|
"authenticatorFlow": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": "han-phone-otp-forms",
|
||||||
|
"description": "Normalize phone, reserve challenge and verify OTP",
|
||||||
|
"providerId": "basic-flow",
|
||||||
|
"topLevel": false,
|
||||||
|
"builtIn": false,
|
||||||
|
"authenticationExecutions": [
|
||||||
|
{
|
||||||
|
"authenticator": "han-phone-identity",
|
||||||
|
"requirement": "REQUIRED",
|
||||||
|
"priority": 10,
|
||||||
|
"authenticatorFlow": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"authenticator": "han-phone-otp",
|
||||||
|
"requirement": "REQUIRED",
|
||||||
|
"priority": 20,
|
||||||
|
"authenticatorFlow": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requiredActions": [],
|
||||||
|
"components": {
|
||||||
|
"org.keycloak.keys.KeyProvider": [
|
||||||
|
{
|
||||||
|
"name": "rsa-generated",
|
||||||
|
"providerId": "rsa-generated",
|
||||||
|
"subType": "rsa-generated",
|
||||||
|
"config": {
|
||||||
|
"priority": ["100"],
|
||||||
|
"algorithm": ["RS256"],
|
||||||
|
"keySize": ["2048"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user