Compare commits

...
Author SHA1 Message Date
Pavel Eliseev 7780b484f3 feat(bot): add MAX notifications adapter and operator handoff
CI / ruff · pytest (pull_request) Successful in 2m27s
Refs #83
2026-10-04 16:10:30 +03:00
Pavel Eliseev 28967c7c37 fix(contract): redact input values in settings errors
CI / ruff · pytest (pull_request) Successful in 2m22s
Refs #87
2026-10-04 16:10:30 +03:00
Soyavo afbfbad19e Merge pull request 'feat(backend): prepare MAX linking, webhook API and durable notifications' (#86) from feat/82-max-backend into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:09:13 +03:00
Pavel Eliseev 461c3d02ea fix(backend): redact retryable MAX storage failures
CI / ruff · pytest (pull_request) Successful in 2m4s
Refs #82
2026-10-04 16:04:38 +03:00
Pavel Eliseev 64655ad934 feat(backend): add durable MAX linking and notification queue
Refs #82
2026-10-04 16:04:38 +03:00
Soyavo db3072d759 Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 16:02:52 +03:00
Pavel Eliseev b3e103954f feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s
Refs #81
2026-10-04 15:58:25 +03:00
Eleboba bde6ec6855 Merge pull request 'chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера' (#80) from chore/server-deploy into dev
CI / ruff · pytest (push) Successful in 2m13s
2026-10-04 15:48:56 +03:00
ElebobaandClaude Opus 5.5 ade93d959b feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors
CI / ruff · pytest (pull_request) Successful in 2m15s
OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error
crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception
degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's
OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:46:21 +03:00
ElebobaandClaude Opus 5.5 bc044650ac chore(devops): deploy script tolerates offline fetch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
ElebobaandClaude Opus 5.5 473b884ed8 chore(devops): server deploy next to vobot — compose override, nginx vhost, deploy script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
27 changed files with 1975 additions and 18 deletions
+10
View File
@@ -37,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER=
GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+115
View File
@@ -0,0 +1,115 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
try:
await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True}
+2 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures
from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
@@ -46,6 +46,7 @@ from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router)
router.include_router(patient_web_router)
+150
View File
@@ -0,0 +1,150 @@
# Подключение будущего бота MAX
Серверная часть готова к подключению. Сам бот ещё не создан, секреты не заполнены,
реальная отправка не включена. Изменений интерфейса сайта в этой поставке нет.
Интеграция уведомляет **самого пациента**; врачебные оповещения остаются в существующем канале проекта.
## Что реализовано
- Одноразовая ссылка для привязки аккаунта MAX к текущему пациенту сайта, срок по умолчанию 10 минут.
- Подтверждение подключения, `/help`, `/stop`, отключение при остановке или удалении бота в MAX.
- Нейтральные напоминания из существующего планировщика сайта: тесты, дневник, приём лекарств и другие события outbox.
- Уведомление о завершении теста: сервер каждые примерно 30 секунд находит новые завершённые
`QuestionnaireRecord` и `ScaleResult`, начиная с момента подключения MAX. Незавершённые и старые тесты не отправляются.
- Постоянная очередь, восстановление после перезапуска, ограниченные повторы при сетевых ошибках и ответах 429/5xx.
- В сообщении только короткий текст и кнопка на сайт. Ответы, оценки, диагнозы, назначения и ключ входа на сайт
в MAX не передаются. Переписка с ботом не сохраняется и не отправляется в LLM.
## 1. Создать бота — будущему владельцу
1. Открыть [MAX для партнёров](https://business.max.ru/) и пройти регистрацию/проверку профиля.
Условия допуска зависят от типа владельца; актуальный порядок — в
[официальной инструкции подключения](https://dev.max.ru/help/platform_connection).
2. Создать чат-бота, оформить описание и пройти требуемую MAX модерацию.
3. В настройках бота получить токен и скопировать имя из его публичной ссылки `https://max.ru/ИМЯ`.
4. Передать администратору сервера имя бота, а токен — через защищённое хранилище секретов.
Токен не нужен в переписке, MR или репозитории.
## 2. Заполнить настройки сервера
Нужны доступная identity/clinical БД проекта, публичный HTTPS-адрес сайта и HTTPS-адрес API на порту 443.
Входящие POST от MAX должны доходить до API без браузерной CAPTCHA или страницы авторизации.
Прокси должен передавать заголовок `X-Max-Bot-Api-Secret` и не журналировать тело webhook/Authorization.
Дополнительные таблицы identity создаются существующим `init_db()` при запуске приложения.
В `.env` сервера (вне Git) заполнить:
```dotenv
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=https://api.example.org/api/v1/webhooks/max
MAX_SITE_URL=https://patient.example.org/
MAX_LINK_TTL_SECONDS=600
# При необходимости — PEM-файл доверенного CA, доступный внутри контейнера:
MAX_CA_BUNDLE=
DEMO_CLOCK=false
```
- `MAX_BOT_USERNAME` — только имя, без `@`, домена или параметров ссылки.
- `MAX_WEBHOOK_SECRET` — отдельная случайная строка, не токен бота: 32–256 символов из `A–Z a–z 0–9 _ -`.
Сгенерировать локально, например `python -c 'import secrets; print(secrets.token_urlsafe(32))'`, сохранить в секреты.
- `MAX_SITE_URL` ведёт в обычный личный кабинет; не добавлять в адрес ключи доступа или данные пациента.
- Исходящие запросы идут на `https://platform-api2.max.ru`, токен передаётся заголовком `Authorization`.
Верификация TLS всегда включена. Если системе нужен корневой сертификат Минцифры, установить его по
официальной инструкции MAX и указать PEM-путь в `MAX_CA_BUNDLE`; не отключать проверку TLS.
- Переменные должны попасть именно в окружение API-контейнера/процесса, а не только панели сайта.
В текущем Compose сервис `app` уже получает `.env` через `env_file`.
После изменения окружения пересоздать процесс/контейнер API. Новые зависимости не требуются.
## 3. Проверить и включить
В каталоге проекта, в том же окружении, где работает API:
```sh
uv run python -m app.channels.max_setup check
```
Команда проверит токен и совпадение имени бота. Сообщений пользователям она не отправляет.
Затем установить `MAX_ENABLED=true`, перезапустить API и выполнить:
```sh
uv run python -m app.channels.max_setup subscribe
uv run python -m app.channels.max_setup status
```
`subscribe` регистрирует только заданный webhook с секретом и событиями
`bot_started`, `message_created`, `bot_stopped`, `dialog_removed`. Старые чужие подписки автоматически
не удаляются: при переносе сервера владелец должен проверить их отдельно через MAX API.
`status` проверяет регистрацию адреса; это ещё не проверка доступности сервера из MAX.
Команды не печатают токены, секрет webhook или сырые ответы платформы.
При запуске через Compose вместо `uv run python ...` использовать
`docker compose exec app python -m app.channels.max_setup check` (также `subscribe` и `status`).
После изменения `.env` пересоздать API: `docker compose up -d --build --force-recreate app`.
Запускать один процесс API с MAX на один токен бота: его worker отправляет до одного сообщения в секунду.
БД защищает очередь от параллельного захвата, но общий лимит скорости нескольких процессов не координируется.
Планировщик и MAX worker запускаются вместе с FastAPI, отдельный процесс бота не нужен.
При `MAX_ENABLED=false` сетевых вызовов MAX и фонового worker нет; сайт продолжает обычную работу.
## API для разработчика сайта
В проекте используется ключ текущего пациента, полученный при входе. Для этих ручек передавать его
как `Authorization: Bearer <patient_token>`; это существующая авторизация прототипа, не новая система SSO.
Не брать token другого пациента и не публиковать демонстрационные учётные записи для реального использования.
| Метод и путь | Запрос | Ответ |
|---|---|---|
| `GET /api/v1/me/notifications/max` | Authorization | `available`, `connected`, `last_delivery` |
| `POST /api/v1/me/notifications/max/link` | Authorization; JSON `{"consent": true}` | `url`, `expires_at` |
| `DELETE /api/v1/me/notifications/max` | Authorization | состояние с `connected: false` |
| `POST /api/v1/webhooks/max` | MAX JSON + `X-Max-Bot-Api-Secret` | `{"success": true}` после записи события |
Поток подключения: пациент явно соглашается на уведомления → сайт получает `url` → открывает ссылку MAX →
пользователь запускает бота → webhook подтверждает привязку → повторный GET показывает `connected: true`.
Ссылка одноразовая; новая ссылка отменяет предыдущую. Изначальный ключ пациента не входит в URL MAX.
Для привязки и доставки дополнительно нужны действующее согласие и подтверждение совершеннолетия в состоянии пациента.
Активную привязку нельзя молча переназначить другому аккаунту: сначала отключить её.
Коды ошибок: 401 — неизвестный/отсутствующий ключ пациента; 403 — нет согласия или неверный секрет webhook;
422 — неверный запрос; 413 — webhook больше 64 КиБ; 503 — MAX выключен или хранилище события недоступно.
Повтор webhook с тем же идентификатором события не создаёт новых отправок. Групповые чаты игнорируются.
`last_delivery`: `queued`, `sending`, `sent`, `failed`, `cancelled` или `null`.
`sent` означает принятие сообщения API MAX, а не прочтение человеком.
## Пробное подключение после создания бота
1. Войти на сайте синтетическим пациентом и дать необходимые согласия.
2. Получить ссылку через POST выше (Swagger `/docs` или будущий интерфейс сайта), перейти в MAX и запустить бота.
3. Убедиться, что GET показывает `connected: true` и приходит подтверждение.
4. Завершить тест на сайте; примерно через 30 секунд плюс очередь должно прийти сообщение без результатов.
5. Дождаться события планировщика и проверить нейтральное напоминание с кнопкой на сайт.
6. Отправить `/stop`: GET должен показать `connected: false`, ожидающие уведомления отменяются.
7. Повторить подключение новой ссылкой, проверить DELETE через API и остановку бота через интерфейс MAX.
При недоставке проверить настройки и подписку, доступность HTTPS, состояние очереди и коды ошибок в логах.
`http_401`/`http_403` требуют проверки токена/прав; `http_429` — ожидания; `transport` — сети/TLS;
`failed` после шести попыток требует вмешательства администратора. Детали ответов и идентификаторы в логи не выводятся.
Отключение отменяет очередь; уже принятый MAX запрос отозвать невозможно.
## Гарантии и ограничения
Очередь и подтверждения событий сохраняются в identity БД. Клинические записи остаются в clinical БД.
Для событий завершения тестов БД повторно просматривается после перезапуска; ключи уведомлений предотвращают
повторную постановку. Напоминания используют существующий механизм outbox/повторов планировщика.
При временных ошибках — до 6 попыток с увеличением задержки и учётом `Retry-After`; зависшая отправка получает
новую аренду через 60 секунд. Потеря процесса между принятием сообщения MAX и записью успеха в БД может
привести к повторному сообщению: MAX не предоставляет ключ идемпотентности отправки.
Не удалять строки дедупликации/очереди без согласованной политики хранения. При большом количестве пациентов
нужны пакетное сканирование завершённых тестов и общий ограничитель скорости; текущая реализация рассчитана на MVP.
Проверки разработчика: `make lint test`; тесты MAX работают с синтетикой и подменённым HTTP-транспортом.
Реальную доставку подтвердит только пробное подключение после выдачи токена и настройки публичного сервера.
Официальные контракты: [отправка сообщений](https://dev.max.ru/docs-api/methods/POST/messages),
[подписка webhook](https://dev.max.ru/docs-api/methods/POST/subscriptions),
[события](https://dev.max.ru/docs-api/objects/Update),
[ссылки запуска бота](https://dev.max.ru/docs/chatbots/bots-coding/prepare).
+224
View File
@@ -0,0 +1,224 @@
"""Notifications-only MAX adapter; no patient text enters the dialog engine or an LLM.
Webhook transactions persist receipts and replies together, then return immediately.
The worker delivers from identity DB with leases and bounded retries. MAX has no send
idempotency key: a crash after API acceptance and before commit can duplicate a message.
"""
import asyncio
import contextlib
import json
import logging
from functools import lru_cache
import yaml
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import get_settings
from app.core import clock
from app.db.max_notifications import MaxBinding, MaxReceipt
from app.db.session import get_identity_session
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import Outbox, integrations
log = logging.getLogger(__name__)
class Texts(BaseModel):
connected: str = Field(max_length=4000)
not_connected: str = Field(max_length=4000)
invalid: str = Field(max_length=4000)
stopped: str = Field(max_length=4000)
help: str = Field(max_length=4000)
notification: str = Field(max_length=4000)
completed: str = Field(max_length=4000)
open_site: str = Field(max_length=128)
@lru_cache
def texts() -> Texts:
path = get_settings().config_dir / "texts" / "max.yaml"
return Texts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
def _event(event: dict) -> tuple[str, int, str, str] | None:
"""Normalize only documented private-user events; unknown/malformed events are ignored."""
kind = event.get("update_type")
stamp = event.get("timestamp")
if type(stamp) is not int or stamp < 0:
return None
message_id = ""
if kind == "message_created":
message = event.get("message")
if not isinstance(message, dict):
return None
recipient, body = message.get("recipient"), message.get("body")
if not isinstance(recipient, dict) or recipient.get("chat_type") != "dialog" or not isinstance(body, dict):
return None
user, text, message_id = message.get("sender"), body.get("text"), body.get("mid")
if not isinstance(message_id, str) or not message_id or not isinstance(text, str):
return None
elif kind in ("bot_started", "bot_stopped", "dialog_removed"):
user, text = event.get("user"), event.get("payload") or ""
else:
return None
if not isinstance(user, dict) or user.get("is_bot") is not False:
return None
uid = user.get("user_id")
if type(uid) is not int or not 0 < uid < 2**63 or not isinstance(text, str) or len(text) > 4000:
return None
# Names, profile details and raw text are neither persisted nor logged.
receipt = store.digest(f"{kind}:{uid}:{message_id or stamp}")
return kind, uid, text.strip(), receipt
async def handle_update(event: dict) -> None:
if not get_settings().max_enabled or (parsed := _event(event)) is None:
return
kind, user_id, text, receipt = parsed
now = clock.now()
try:
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt):
return
session.add(MaxReceipt(digest=receipt, received_at=now))
await session.flush()
binding = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
reply_token = None
command, _, payload = text.partition(" ")
if kind in ("bot_stopped", "dialog_removed") or command.lower() == "/stop":
if binding:
await store.disconnect(session, binding.token)
# When MAX itself stops/deletes the bot, do not attempt a reply.
if kind != "message_created":
return
reply = "stopped"
elif kind == "bot_started" or command.lower() == "/start":
code = text if kind == "bot_started" else payload.strip()
if code:
token = await store.code_token(session, code, now) if len(code) <= 128 else None
linked = (
token and await store.active_patient(token) and await store.bind(session, code, user_id, now)
)
reply = "connected" if linked else "invalid"
reply_token = token if linked else None
else:
reply = "connected" if binding and binding.enabled else "not_connected"
reply_token = binding.token if binding and binding.enabled else None
else:
reply = "help"
await store.enqueue(session, key=receipt, user_id=user_id, token=reply_token, kind=reply, now=now)
except IntegrityError:
# A concurrent delivery of the same webhook can race on its receipt primary key.
async with get_identity_session() as session:
if await session.get(MaxReceipt, receipt) is None:
raise
class MaxOutbox:
def __init__(self, fallback: Outbox):
self.fallback = fallback
async def push(self, token: str, replies: list[Reply]) -> None:
if replies and get_settings().max_enabled and await store.active_patient(token):
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
if binding and binding.enabled:
# Hash the event for retry deduplication; never store/transmit its clinical text.
event = json.dumps([r.model_dump() for r in replies], sort_keys=True, ensure_ascii=False)
key = store.digest(f"{token}:{binding.connected_at.isoformat()}:{clock.today()}:{event}")
await store.enqueue(
session, key=key, user_id=binding.user_id, token=token, kind="notification", now=clock.now()
)
await self.fallback.push(token, replies)
async def deliver_one(client: MaxClient) -> bool:
async with get_identity_session() as session:
row = await store.claim(session, clock.now())
if row is None:
return False
if row.token is not None:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, row.token)
valid = (
binding
and binding.enabled
and binding.user_id == row.user_id
and binding.connected_at <= row.created_at
)
if not valid or not await store.active_patient(row.token):
async with get_identity_session() as session:
await store.finish(session, row, state="cancelled", now=clock.now())
return True
settings, messages = get_settings(), texts()
try:
await client.send(
row.user_id, getattr(messages, row.kind), site_url=settings.max_site_url, button=messages.open_site
)
except MaxApiError as exc:
retry = exc.retryable and row.attempts < store.MAX_ATTEMPTS
delay = max(exc.retry_after, min(300, 5 * 2 ** (row.attempts - 1)))
async with get_identity_session() as session:
await store.finish(
session,
row,
state="queued" if retry else "failed",
now=clock.now(),
error=exc.code,
retry_seconds=delay if retry else 0,
)
log.warning("MAX delivery deferred=%s code=%s", retry, exc.code)
else:
async with get_identity_session() as session:
await store.finish(session, row, state="sent", now=clock.now())
return True
async def _run(client: MaxClient) -> None:
tick = 0
while True:
try:
if tick % 30 == 0:
async with get_identity_session() as session:
await store.collect_completed(session, clock.now())
await deliver_one(client)
except Exception as exc:
# No exception text: SQL/HTTP errors may contain identifiers or parameters.
log.error("MAX worker failed type=%s", type(exc).__name__)
tick += 1
# One send per second per process, below MAX's two-per-second per-dialog limit.
await asyncio.sleep(1)
_client: MaxClient | None = None
_task: asyncio.Task | None = None
_wrapper: MaxOutbox | None = None
async def start() -> None:
global _client, _task, _wrapper
if not get_settings().max_enabled or _task is not None:
return
texts() # Validate templates before replacing a working outbox.
_client = MaxClient(get_settings())
_wrapper = MaxOutbox(integrations.outbox)
integrations.outbox = _wrapper
_task = asyncio.create_task(_run(_client), name="max-notifications")
async def stop() -> None:
global _client, _task, _wrapper
if _task:
_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _task
if _wrapper and integrations.outbox is _wrapper:
integrations.outbox = _wrapper.fallback
if _client:
await _client.close()
_client = _task = _wrapper = None
+101
View File
@@ -0,0 +1,101 @@
"""Small MAX Bot API client. Secrets stay in headers; failures never expose response bodies."""
import logging
import ssl
import httpx
from app.config import Settings
API_URL = "https://platform-api2.max.ru"
UPDATE_TYPES = ["bot_started", "message_created", "bot_stopped", "dialog_removed"]
class _HideRecipient(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# httpx INFO normally logs query strings, which include a MAX user_id.
return "platform-api2.max.ru" not in record.getMessage()
logging.getLogger("httpx").addFilter(_HideRecipient())
class MaxApiError(Exception):
def __init__(self, code: str, *, retryable: bool = False, retry_after: float = 0):
self.code, self.retryable, self.retry_after = code, retryable, retry_after
super().__init__(code)
class MaxClient:
def __init__(self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None):
context = ssl.create_default_context()
if settings.max_ca_bundle:
context.load_verify_locations(cafile=str(settings.max_ca_bundle))
self.http = httpx.AsyncClient(
base_url=API_URL,
headers={"Authorization": settings.max_bot_token.get_secret_value()},
timeout=httpx.Timeout(5),
verify=context,
follow_redirects=False,
transport=transport,
)
async def close(self) -> None:
await self.http.aclose()
async def request(self, method: str, path: str, **kwargs) -> dict:
try:
response = await self.http.request(method, path, **kwargs)
except httpx.RequestError:
raise MaxApiError("transport", retryable=True) from None
if not response.is_success:
try:
delay = min(3600, max(0, float(response.headers.get("Retry-After", "0"))))
except ValueError:
delay = 0
raise MaxApiError(
f"http_{response.status_code}",
retryable=response.status_code in (408, 429) or response.status_code >= 500,
retry_after=delay,
)
try:
data = response.json()
except ValueError:
raise MaxApiError("invalid_response", retryable=True) from None
if not isinstance(data, dict):
raise MaxApiError("invalid_response", retryable=True)
if data.get("success") is False:
raise MaxApiError("rejected")
return data
async def send(self, user_id: int, text: str, *, site_url: str, button: str) -> None:
data = await self.request(
"POST",
"/messages",
params={"user_id": user_id, "disable_link_preview": "true"},
json={
"text": text,
"notify": True,
"attachments": [
{
"type": "inline_keyboard",
"payload": {"buttons": [[{"type": "link", "text": button, "url": site_url}]]},
}
],
},
)
if not isinstance(data.get("message"), dict):
raise MaxApiError("missing_message", retryable=True)
async def subscribe(self, settings: Settings) -> None:
data = await self.request(
"POST",
"/subscriptions",
json={
"url": settings.max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings.max_webhook_secret.get_secret_value(),
},
)
if data.get("success") is not True:
raise MaxApiError("subscription_not_confirmed")
+56
View File
@@ -0,0 +1,56 @@
"""Operator commands for a future bot owner. Never runs as part of application startup."""
import argparse
import asyncio
import sys
from pydantic import ValidationError
from app.channels.max_client import MaxApiError, MaxClient
from app.config import Settings
async def run(command: str) -> None:
# Validate the same configuration even while the deployed app is still disabled.
settings = Settings(max_enabled=True)
client = MaxClient(settings)
try:
bot = await client.request("GET", "/me")
if bot.get("is_bot") is not True:
raise MaxApiError("not_a_bot")
if bot.get("username") != settings.max_bot_username:
raise MaxApiError("bot_username_mismatch")
if command == "subscribe":
await client.subscribe(settings)
print("Webhook MAX зарегистрирован. Проверьте командой status, затем выполните пробное подключение.")
elif command == "status":
result = await client.request("GET", "/subscriptions")
subscriptions = result.get("subscriptions", [])
configured = any(isinstance(s, dict) and s.get("url") == settings.max_webhook_url for s in subscriptions)
print("Нужный webhook зарегистрирован." if configured else "Нужный webhook не найден.")
if not configured:
raise MaxApiError("webhook_missing")
else:
print("Токен принят MAX, имя бота совпадает с настройкой. Подписка не изменялась.")
finally:
await client.close()
def main() -> int:
parser = argparse.ArgumentParser(description="Подготовка MAX: параметры берутся только из .env/окружения.")
parser.add_argument("command", choices=("check", "subscribe", "status"))
args = parser.parse_args()
try:
asyncio.run(run(args.command))
except ValidationError:
print("Проверьте параметры MAX в .env по app/channels/MAX_SETUP.md.", file=sys.stderr)
return 1
except (MaxApiError, OSError) as exc:
code = exc.code if isinstance(exc, MaxApiError) else "tls_or_file_error"
print(f"Не удалось выполнить настройку MAX: {code}. Секреты и ответ сервера скрыты.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+35 -2
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -12,7 +14,7 @@ ROOT_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
# Empty values in .env (e.g. `TG_BOT_TOKEN=`) fall back to defaults instead of failing validation.
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True)
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_ignore_empty=True, hide_input_in_errors=True)
# Two separate databases (ADR-003): identity holds direct identifiers, clinical holds pseudonymised data.
identity_db_url: str = "postgresql+asyncpg://identity_app:identity_app@localhost:5432/identity"
@@ -35,6 +37,7 @@ class Settings(BaseSettings):
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
@@ -42,6 +45,36 @@ class Settings(BaseSettings):
tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# Virtual clock for the demo (ADR-005).
demo_clock: bool = True
tz: str = "Europe/Moscow"
+1
View File
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)
+10 -5
View File
@@ -22,6 +22,7 @@ from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
GigaChatToolProvider,
MockAgentProvider,
OpenRouterProvider,
ProviderError,
@@ -49,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off"
mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
@@ -58,15 +59,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
log.warning("chat agent: gigachat provider not implemented yet — disabled")
return None
if not s.gigachat_credentials:
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
return None
return GigaChatToolProvider(s)
return None
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name != "mock" else messages
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
@@ -139,6 +141,9 @@ async def answer_chat(
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None
except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500
log.exception("chat agent provider=%s crashed", provider.name)
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
+86 -3
View File
@@ -68,9 +68,10 @@ class OpenRouterProvider:
if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
except ValueError:
detail = ""
err = resp.json().get("error", "")
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
except (ValueError, AttributeError):
detail = resp.text[:200]
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}")
try:
@@ -90,6 +91,88 @@ class OpenRouterProvider:
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
class GigaChatToolProvider:
"""GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps:
assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name.
GigaChat returns at most one function call per turn — the agent loop handles that naturally."""
name = "gigachat"
def __init__(self, settings: Settings, client: Any | None = None) -> None:
self._settings = settings
self._client = client
def _get_client(self) -> Any:
if self._client is None:
from gigachat import GigaChat
s = self._settings
self._client = GigaChat(
credentials=s.gigachat_credentials,
scope=s.gigachat_scope,
model=s.gigachat_agent_model,
verify_ssl_certs=s.gigachat_verify_ssl,
timeout=s.chat_agent_timeout_s,
)
return self._client
@staticmethod
def to_messages(messages: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import FunctionCall, Messages, MessagesRole
out = []
for m in messages:
role = m["role"]
if role == "system":
out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"]))
elif role == "user":
out.append(Messages(role=MessagesRole.USER, content=m["content"]))
elif role == "assistant":
calls = m.get("tool_calls") or []
for c in calls or [None]:
fc = None
if c is not None:
fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"]))
out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc))
elif role == "tool":
out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"]))
return out
@staticmethod
def to_functions(tools: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import Function
return [Function.model_validate(t["function"]) for t in tools]
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
from gigachat.models import Chat
chat = Chat(
messages=self.to_messages(messages),
functions=self.to_functions(tools),
function_call="auto",
temperature=0.1,
max_tokens=900,
)
try:
resp = await self._get_client().achat(chat)
except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK
raise ProviderError(type(exc).__name__) from exc
msg = resp.choices[0].message
calls = []
if msg.function_call is not None:
args = msg.function_call.arguments or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args))
usage = getattr(resp, "usage", None)
log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None))
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
+18 -6
View File
@@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start()
await telegram.start()
yield
await telegram.stop()
await scheduler.stop()
await dispose()
max_channel = None
try:
await telegram.start()
if settings.max_enabled:
from app.channels import max_bot
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield
finally:
await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose()
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
+8
View File
@@ -0,0 +1,8 @@
connected: "Уведомления подключены. Здесь будут появляться напоминания с сайта. Отключить их можно командой /stop."
not_connected: "Чтобы получать уведомления, откройте сайт и подключите MAX в настройках уведомлений."
invalid: "Не удалось подключить уведомления. Создайте новую ссылку на сайте. Если MAX уже подключён к другому аккаунту, сначала отключите прежнюю привязку."
stopped: "Уведомления отключены. Подключить их снова можно по новой ссылке с сайта."
help: "Этот бот присылает уведомления. Пройти тесты, посмотреть результаты и продолжить общение можно на сайте. Команда /stop отключает уведомления."
notification: "На сайте есть напоминание для вас. Откройте личный кабинет, чтобы продолжить."
completed: "Прохождение теста завершено. Подробности доступны в личном кабинете на сайте."
open_site: "Открыть сайт"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout:
# infra/deploy/deploy.sh [git-ref] default: origin/dev
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
set -euo pipefail
REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot}
DIR=/srv/ptsr
git fetch -q origin || true
SHA=$(git rev-parse --short "$REF")
echo "deploy $REF ($SHA) -> $HOST:$DIR"
git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new"
ssh "$HOST" bash -s <<SH
set -euo pipefail
cd $DIR
cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
cd app
echo "$SHA" > REVISION
docker run --rm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
curl -sf 127.0.0.1:8010/health && echo " api ok"
SH
+30
View File
@@ -0,0 +1,30 @@
# Server override (shared host with vobot): loopback-only ports behind host nginx, restart policy, memory caps.
# docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build
services:
postgres:
ports: !reset []
restart: unless-stopped
mem_limit: 512m
app:
ports: !override
- "127.0.0.1:8010:8000"
restart: unless-stopped
mem_limit: 768m
dashboard:
command:
- streamlit
- run
- dashboard/app.py
- --server.port=8501
- --server.address=0.0.0.0
- --server.baseUrlPath=doctor
- --browser.gatherUsageStats=false
environment:
API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor
ports: !override
- "127.0.0.1:8511:8501"
restart: unless-stopped
mem_limit: 768m
+52
View File
@@ -0,0 +1,52 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only).
# / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru
map $http_upgrade $ptsr_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
+103
View File
@@ -0,0 +1,103 @@
"""MAX endpoints authenticate before reading bindings or accepting events."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.exc import OperationalError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.integrations import max_store
from app.main import app
BASE = "/api/v1/me/notifications/max"
HOOK = "/api/v1/webhooks/max"
TOKEN = "synthetic-patient"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
SECRET = "test-secret-" * 4
@pytest.fixture
async def client(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org" + HOOK,
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
get_settings.cache_clear()
@pytest.mark.parametrize("auth", [{}, {"Authorization": "Bearer unknown"}, {"Authorization": "Basic test"}])
async def test_auth_required(client, auth):
assert (await client.get(BASE, headers=auth)).status_code == 401
assert (await client.post(BASE + "/link", headers=auth, json={"consent": True})).status_code == 401
assert (await client.delete(BASE, headers=auth)).status_code == 401
async def test_link_requires_consent_and_hides_patient_credential(client):
for body in ({}, {"consent": False}, {"consent": True, "user_id": 100}):
assert (await client.post(BASE + "/link", headers=AUTH, json=body)).status_code == 422
response = await client.post(BASE + "/link", headers=AUTH, json={"consent": True})
assert response.status_code == 200
assert TOKEN not in response.text
url = urlsplit(response.json()["url"])
assert url.netloc == "max.ru" and url.path == "/test_bot"
code = parse_qs(url.query)["start"][0]
async with session.get_identity_session() as s:
assert await max_store.bind(s, code, 100, clock.now()) == TOKEN
assert (await client.get(BASE, headers=AUTH)).json()["connected"] is True
assert (await client.delete(BASE, headers=AUTH)).json()["connected"] is False
async def test_revoke_consent_prevents_linking(client):
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 403
async def test_disabled_service_still_allows_disconnect(client, monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
assert (await client.get(BASE, headers=AUTH)).json()["available"] is False
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 503
assert (await client.post(HOOK, json={})).status_code == 503
assert (await client.delete(BASE, headers=AUTH)).status_code == 200
async def test_webhook_secret_checked_before_body_or_adapter(client):
assert (await client.post(HOOK, content="not-json")).status_code == 403
assert (await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": "wrong"}, json={})).status_code == 403
headers = {"X-Max-Bot-Api-Secret": SECRET}
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
async def test_webhook_storage_failure_is_retryable_without_identity_details(client, monkeypatch):
handler = AsyncMock(side_effect=OperationalError("private SQL", {"token": TOKEN}, Exception("private")))
monkeypatch.setitem(sys.modules, "app.channels.max_bot", SimpleNamespace(handle_update=handler))
response = await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": SECRET}, json={})
assert response.status_code == 503 and TOKEN not in response.text and "private" not in response.text
+47
View File
@@ -0,0 +1,47 @@
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def configured(**overrides):
fields = {
"max_enabled": True,
"max_bot_token": "test-only-token",
"max_bot_username": "test_bot",
"max_webhook_secret": "test-only-secret-" * 3,
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
"max_site_url": "https://patient.example.org/#/today",
}
return Settings(_env_file=None, **(fields | overrides))
def test_max_disabled_by_default():
assert not Settings(_env_file=None).max_enabled
def test_max_secrets_are_redacted():
settings = configured()
assert "test-only-token" not in repr(settings)
assert "test-only-secret" not in repr(settings)
@pytest.mark.parametrize(
"overrides",
[
{"max_bot_token": ""},
{"max_bot_username": "@test_bot"},
{"max_webhook_secret": "short"},
{"max_site_url": "http://patient.example.org"},
{"max_webhook_url": "http://api.example.org"},
{"max_webhook_url": "https://api.example.org:8443/hook"},
{"max_webhook_url": "https://user:password@api.example.org/hook"},
{"max_webhook_url": "https://api.example.org/hook#fragment"},
{"max_site_url": "https://patient.example.org?token=credential"},
],
)
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
with pytest.raises(ValidationError):
configured(**overrides)
+21
View File
@@ -0,0 +1,21 @@
"""Startup validation errors must not print configuration credentials."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def test_incomplete_max_configuration_does_not_expose_token():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled=True, max_bot_token="SYNTHETIC_SECRET")
message = str(error.value)
assert "MAX_BOT_USERNAME" in message
assert "SYNTHETIC_SECRET" not in message and "input_value" not in message
def test_invalid_field_does_not_expose_accidentally_pasted_secret():
with pytest.raises(ValidationError) as error:
Settings(_env_file=None, max_enabled="SYNTHETIC_SECRET")
assert "max_enabled" in str(error.value)
assert "SYNTHETIC_SECRET" not in str(error.value)
+9 -1
View File
@@ -53,7 +53,15 @@ async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
identity_tables = await _tables(session.identity_engine)
clinical_tables = await _tables(session.clinical_engine)
assert identity_tables == {"patients", "doctors", "connect_codes"}
assert identity_tables == {
"patients",
"doctors",
"connect_codes",
"max_bindings",
"max_link_codes",
"max_webhook_receipts",
"max_deliveries",
}
assert {
"patient_profiles",
"item_responses",
+123
View File
@@ -0,0 +1,123 @@
"""Persistent, one-time linking and queue recovery; synthetic identities only."""
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.integrations import max_store as store
@pytest.fixture(autouse=True)
async def database():
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
yield
await session.configure()
async def test_codes_are_hashed_expiring_one_time_and_replaced():
now = clock.now()
async with session.get_identity_session() as s:
old, _ = await store.issue_code(s, "patient", now, 60)
code, expires = await store.issue_code(s, "patient", now, 60)
assert await store.code_token(s, old, now) is None
row = (await s.scalars(select(MaxLinkCode))).one()
assert row.digest != code and row.digest == store.digest(code)
assert await store.code_token(s, code, expires) is None
assert await store.bind(s, code, 100, now) == "patient"
assert await store.bind(s, code, 100, now) is None
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, "patient")).user_id == 100
async def test_active_binding_cannot_be_reassigned_in_either_direction():
now = clock.now()
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, "one", now, 60)
await store.bind(s, code, 100, now)
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) is None
assert await store.code_token(s, code, now) == "two"
code, _ = await store.issue_code(s, "one", now, 60)
assert await store.bind(s, code, 200, now) is None
await store.disconnect(s, "one")
assert await store.code_token(s, code, now) is None
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) == "two"
async def test_disconnect_cancels_pending_delivery_and_prevents_stale_ack():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, token="one", kind="notification", now=now)
row = await store.claim(s, now)
async with session.get_identity_session() as s:
await store.disconnect(s, "one")
await store.finish(s, row, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "cancelled"
async def test_queue_survives_sessions_deduplicates_and_recovers_expired_lease():
now = clock.now()
async with session.get_identity_session() as s:
for _ in range(2):
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
async with session.get_identity_session() as s:
first = await store.claim(s, now)
assert first.attempts == 1
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(seconds=59)) is None
async with session.get_identity_session() as s:
second = await store.claim(s, now + timedelta(seconds=61))
assert second.id == first.id and second.lease != first.lease and second.attempts == 2
async with session.get_identity_session() as s:
await store.finish(s, first, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "sending"
await store.finish(s, second, state="sent", now=now)
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async def test_retry_deadline_and_attempt_limit():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
for attempt in range(store.MAX_ATTEMPTS):
when = now + timedelta(seconds=attempt * 120)
async with session.get_identity_session() as s:
row = await store.claim(s, when)
async with session.get_identity_session() as s:
await store.finish(s, row, state="queued", now=when, retry_seconds=120, error="http_429")
async with session.get_identity_session() as s:
assert await store.claim(s, when + timedelta(seconds=119)) is None
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async with session.get_identity_session() as s:
row = await s.get(MaxDelivery, "job")
assert row.state == "failed" and row.attempts == store.MAX_ATTEMPTS
async def test_completion_scan_ignores_old_and_incomplete_tests_and_recovers_without_duplicates():
now = clock.now()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token="one", doctor_id="")
await repo.set_dialog_state(s, token="one", state="idle", data={"consent": True, "adult": True}, now=now)
for ts, complete in ((now - timedelta(days=1), True), (now, True), (now, False)):
s.add(ScaleResult(token="one", ts=ts, scale="phq9", total=0, severity="none", complete=complete))
async with session.get_identity_session() as s:
s.add(MaxBinding(token="one", user_id=100, enabled=True, connected_at=now))
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, now)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.kind == "completed" and row.token == "one"
assert "phq9" not in row.id
+72
View File
@@ -197,3 +197,75 @@ async def test_empty_final_answer_is_nudged(sertraline):
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
async def test_region_block_string_error_degrades():
# Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."}
client = httpx.AsyncClient(
transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"}))
)
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unexpected_provider_bug_degrades():
class Broken:
name = "broken"
async def chat(self, messages, tools):
raise AttributeError("boom")
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None
# ------------------------------------------------------------------------------------------------- gigachat
class _FakeGiga:
"""Mimics gigachat.GigaChat.achat: first a function_call, then the final text."""
def __init__(self):
self.chats = []
async def achat(self, chat):
from types import SimpleNamespace as NS
self.chats.append(chat)
if len(self.chats) == 1:
fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"})
msg = NS(content="", function_call=fc)
else:
msg = NS(
content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.",
function_call=None,
)
return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42))
async def test_gigachat_tool_loop(sertraline):
from app.llm.agent.providers import GigaChatToolProvider
fake = _FakeGiga()
s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat"
second = fake.chats[1]
roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages]
assert roles == ["system", "user", "assistant", "function"]
assert second.messages[2].function_call.name == "check_drug_for_patient"
assert second.messages[3].name == "check_drug_for_patient"
assert {f.name for f in second.functions} == {
"search_guidelines",
"get_patient_record",
"get_drug_info",
"check_drug_for_patient",
}
def test_build_provider_prefers_gigachat():
from app.llm.agent.providers import GigaChatToolProvider
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
+281
View File
@@ -0,0 +1,281 @@
"""End-to-end server-only MAX flow, real SQLite transactions and mocked network."""
from datetime import timedelta
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.channels import max_bot
from app.channels.max_client import MaxApiError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxReceipt
from app.dialog.contracts import Reply
from app.integrations import max_store as store
from app.integrations.base import MemoryOutbox, integrations
from app.main import app
TOKEN = "synthetic-patient"
SECRET = "synthetic-webhook-secret-" * 2
@pytest.fixture(autouse=True)
async def database(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org/api/v1/webhooks/max",
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
max_bot.texts.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
yield
await max_bot.stop()
await session.configure()
get_settings.cache_clear()
def event(kind="bot_started", text="", uid=100, *, stamp=1000):
user = {"user_id": uid, "is_bot": False, "first_name": "Synthetic"}
if kind == "message_created":
return {
"update_type": kind,
"timestamp": stamp,
"message": {
"sender": user,
"recipient": {"chat_type": "dialog", "chat_id": 123},
"body": {"mid": f"mid-{stamp}", "text": text},
},
}
return {"update_type": kind, "timestamp": stamp, "user": user, "chat_id": 123, "payload": text}
async def connect():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code))
return code
async def test_http_connect_webhook_duplicate_notification_and_stop():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
auth = {"Authorization": f"Bearer {TOKEN}"}
linked = await client.post("/api/v1/me/notifications/max/link", headers=auth, json={"consent": True})
code = parse_qs(urlsplit(linked.json()["url"]).query)["start"][0]
headers = {"X-Max-Bot-Api-Secret": SECRET}
for _ in range(2):
assert (
await client.post("/api/v1/webhooks/max", headers=headers, json=event(text=code))
).status_code == 200
assert (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async with session.get_identity_session() as s:
assert len((await s.scalars(select(MaxReceipt))).all()) == 1
assert len((await s.scalars(select(MaxDelivery))).all()) == 1
sender = AsyncMock()
assert await max_bot.deliver_one(sender)
assert sender.send.call_args.args == (100, max_bot.texts().connected)
await client.post("/api/v1/webhooks/max", headers=headers, json=event("message_created", "/stop", stamp=2000))
assert not (await client.get("/api/v1/me/notifications/max", headers=auth)).json()["connected"]
async def test_unbound_start_help_and_invalid_code_are_generic():
for e in (event(), event("message_created", "private patient text", stamp=2000), event(text="expired", stamp=3000)):
await max_bot.handle_update(e)
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery))).all()
assert {r.kind for r in rows} == {"not_connected", "help", "invalid"}
assert all(r.token is None for r in rows)
assert not (await s.scalars(select(MaxBinding))).all()
@pytest.mark.parametrize("kind", ["bot_stopped", "dialog_removed", "message_created"])
async def test_stopping_cancels_pending_confirmation_and_reminders(kind):
await connect()
await max_bot.MaxOutbox(MemoryOutbox()).push(TOKEN, [Reply(text="private scheduled question")])
await max_bot.handle_update(event(kind, "/stop", stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.token == TOKEN))).all()
assert rows and all(r.state == "cancelled" for r in rows)
sender = AsyncMock()
await max_bot.deliver_one(sender)
assert sender.send.call_count == (1 if kind == "message_created" else 0)
async def test_private_patient_content_stays_in_fallback_only():
await connect()
fallback = MemoryOutbox()
wrapper = max_bot.MaxOutbox(fallback)
replies = [Reply(text="SYNTHETIC confidential result 42", kind="question")]
await wrapper.push(TOKEN, replies)
await wrapper.push(TOKEN, replies)
assert fallback.drain(TOKEN) == replies * 2
async with session.get_identity_session() as s:
rows = (await s.scalars(select(MaxDelivery).where(MaxDelivery.kind == "notification"))).all()
assert len(rows) == 1
assert "confidential" not in repr(rows[0].__dict__)
sender = AsyncMock()
while await max_bot.deliver_one(sender):
pass
assert any(c.args[1] == max_bot.texts().notification for c in sender.send.call_args_list)
assert all("confidential" not in c.args[1] and TOKEN not in str(c.kwargs) for c in sender.send.call_args_list)
async def test_revoked_consent_prevents_binding_and_delivery():
await connect()
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.send.assert_not_called()
async with session.get_identity_session() as s:
await store.disconnect(s, TOKEN)
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event(text=code, stamp=2000))
async with session.get_identity_session() as s:
assert not (await s.get(MaxBinding, TOKEN)).enabled
@pytest.mark.parametrize(
"bad",
[
{},
{"update_type": "other", "timestamp": 1},
event() | {"timestamp": True},
event() | {"user": {"user_id": True, "is_bot": False}},
event() | {"user": {"user_id": 100, "is_bot": True}},
event() | {"payload": {"wrong": "type"}},
{"update_type": "message_created", "timestamp": 1, "message": {}},
],
)
async def test_malformed_or_bot_events_are_ignored(bad):
await max_bot.handle_update(bad)
async with session.get_identity_session() as s:
assert not (await s.scalars(select(MaxReceipt))).all()
async def test_group_cannot_claim_a_patient_link():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
update = event("message_created", "/start " + code)
update["message"]["recipient"]["chat_type"] = "chat"
await max_bot.handle_update(update)
async with session.get_identity_session() as s:
assert await s.get(MaxBinding, TOKEN) is None
assert await store.code_token(s, code, clock.now()) == TOKEN
async def test_429_respects_retry_deadline_and_then_succeeds(monkeypatch):
await max_bot.handle_update(event())
now = clock.now()
monkeypatch.setattr(clock, "now", lambda: now)
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_429", retryable=True, retry_after=120)
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "queued" and row.next_attempt_at == now + timedelta(seconds=120)
assert not await max_bot.deliver_one(sender)
now += timedelta(seconds=121)
sender.send.side_effect = None
assert await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.state == "sent" and row.attempts == 2
async def test_permanent_error_does_not_retry():
await max_bot.handle_update(event())
sender = AsyncMock()
sender.send.side_effect = MaxApiError("http_403")
await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
async with session.get_identity_session() as s:
assert (await s.scalars(select(MaxDelivery))).one().state == "failed"
@pytest.mark.parametrize("source", ["scale", "questionnaire"])
async def test_completed_test_produces_one_neutral_notification(source):
await connect()
sender = AsyncMock()
await max_bot.deliver_one(sender)
sender.reset_mock()
async with session.get_clinical_session() as s:
now = clock.now()
if source == "scale":
s.add(ScaleResult(token=TOKEN, ts=now, scale="phq9", total=1, severity="synthetic", complete=True))
else:
s.add(
QuestionnaireRecord(
id="synthetic-form",
token=TOKEN,
kind="intake",
version="test",
policy_version="test",
started_at=now,
completed_at=now,
day=now.date(),
status="complete",
answers={},
details={},
)
)
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, clock.now())
assert await max_bot.deliver_one(sender)
assert not await max_bot.deliver_one(sender)
sender.send.assert_awaited_once_with(
100, max_bot.texts().completed, site_url="https://example.org", button=max_bot.texts().open_site
)
async def test_private_start_command_accepts_issued_code():
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, TOKEN, clock.now(), 600)
await max_bot.handle_update(event("message_created", "/start " + code))
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, TOKEN)).user_id == 100
async def test_disabled_lifecycle_never_constructs_client(monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: pytest.fail("disabled MAX must not create a network client"))
before = integrations.outbox
await max_bot.start()
await max_bot.handle_update(event())
await max_bot.stop()
assert integrations.outbox is before
async def test_lifecycle_wraps_once_restores_and_closes(monkeypatch):
sender = AsyncMock()
monkeypatch.setattr(max_bot, "MaxClient", lambda _: sender)
before = integrations.outbox
await max_bot.start()
first = integrations.outbox
await max_bot.start()
assert integrations.outbox is first and first.fallback is before
await max_bot.stop()
assert integrations.outbox is before
sender.close.assert_awaited_once()
+95
View File
@@ -0,0 +1,95 @@
"""Wire contract with MAX, using an in-process HTTP fake (no real bot/token)."""
import json
import logging
import httpx
import pytest
from app.channels.max_client import API_URL, UPDATE_TYPES, MaxApiError, MaxClient
from app.config import Settings
def settings():
return Settings(
_env_file=None,
max_enabled=True,
max_bot_token="synthetic-secret-token",
max_bot_username="synthetic_bot",
max_webhook_secret="synthetic-webhook-secret-" * 2,
max_webhook_url="https://api.example.org/api/v1/webhooks/max",
max_site_url="https://example.org",
)
async def test_message_wire_contract_and_no_identifiers_in_logs(caplog):
def handler(request):
assert str(request.url).startswith(API_URL + "/messages?")
assert request.url.params["user_id"] == "1234567890123"
assert "access_token" not in request.url.params
assert request.headers["Authorization"] == "synthetic-secret-token"
body = json.loads(request.content)
assert body["text"] == "neutral reminder" and "format" not in body
button = body["attachments"][0]["payload"]["buttons"][0][0]
assert button == {"type": "link", "text": "Open", "url": "https://example.org"}
return httpx.Response(200, json={"message": {"body": {"mid": "synthetic-id"}}})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with caplog.at_level(logging.INFO):
await client.send(1234567890123, "neutral reminder", site_url="https://example.org", button="Open")
await client.close()
assert "1234567890123" not in caplog.text and "synthetic-secret-token" not in caplog.text
@pytest.mark.parametrize(
"status,retry", [(429, True), (500, True), (503, True), (408, True), (401, False), (403, False)]
)
async def test_error_codes_are_redacted_and_classified(status, retry):
client = MaxClient(
settings(),
transport=httpx.MockTransport(
lambda r: httpx.Response(
status, json={"message": "sensitive provider text"}, headers={"Retry-After": "120"}
)
),
)
with pytest.raises(MaxApiError) as error:
await client.send(100, "neutral", site_url="https://example.org", button="Open")
assert error.value.retryable is retry and error.value.retry_after == 120
assert "sensitive" not in str(error.value)
await client.close()
@pytest.mark.parametrize("body", [{"success": False, "message": "private"}, {}, {"success": True}, []])
async def test_http_200_is_not_enough_to_mark_delivered(body):
client = MaxClient(settings(), transport=httpx.MockTransport(lambda r: httpx.Response(200, json=body)))
with pytest.raises(MaxApiError):
await client.send(100, "neutral", site_url="https://example.org", button="Open")
await client.close()
async def test_timeout_is_retryable_without_request_details():
def handler(request):
raise httpx.ReadTimeout("private URL", request=request)
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
with pytest.raises(MaxApiError, match="^transport$") as error:
await client.request("GET", "/me")
assert error.value.retryable
await client.close()
async def test_subscription_includes_secret_and_exact_event_types():
def handler(request):
assert request.url.path == "/subscriptions"
body = json.loads(request.content)
assert body == {
"url": settings().max_webhook_url,
"update_types": UPDATE_TYPES,
"secret": settings().max_webhook_secret.get_secret_value(),
}
return httpx.Response(200, json={"success": True})
client = MaxClient(settings(), transport=httpx.MockTransport(handler))
await client.subscribe(settings())
await client.close()
+51
View File
@@ -0,0 +1,51 @@
"""Operator tools do not send messages or expose credentials."""
from unittest.mock import AsyncMock
import pytest
from app.channels import max_setup
from app.channels.max_client import MaxApiError
from app.config import Settings
@pytest.fixture
def client(monkeypatch):
settings = Settings(_env_file=None, max_bot_username="test_bot", max_webhook_url="https://api.example.org/hook")
sender = AsyncMock()
sender.request.return_value = {"is_bot": True, "username": "test_bot"}
monkeypatch.setattr(max_setup, "Settings", lambda **kwargs: settings)
monkeypatch.setattr(max_setup, "MaxClient", lambda cfg: sender)
return sender
async def test_check_only_reads_bot_identity(client):
await max_setup.run("check")
client.request.assert_awaited_once_with("GET", "/me")
client.subscribe.assert_not_called()
client.send.assert_not_called()
client.close.assert_awaited_once()
async def test_subscribe_is_explicit_and_checks_bot_first(client):
await max_setup.run("subscribe")
client.subscribe.assert_awaited_once()
client.send.assert_not_called()
async def test_mismatched_bot_does_not_change_webhooks(client):
client.request.return_value = {"is_bot": True, "username": "another_bot"}
with pytest.raises(MaxApiError, match="bot_username_mismatch"):
await max_setup.run("subscribe")
client.subscribe.assert_not_called()
client.close.assert_awaited_once()
async def test_status_checks_exact_configured_webhook(client):
client.request.side_effect = [
{"is_bot": True, "username": "test_bot"},
{"subscriptions": [{"url": "https://elsewhere.example.org"}]},
]
with pytest.raises(MaxApiError, match="webhook_missing"):
await max_setup.run("status")
client.subscribe.assert_not_called()