111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""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 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
|
|
|
|
await handle_update(event)
|
|
return {"success": True}
|