@@ -0,0 +1,110 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user