from __future__ import annotations import json import os from fastapi import Depends, FastAPI, HTTPException from sqlalchemy import select from sqlalchemy.exc import IntegrityError from services.shared.core import Role, new_id, utc_now_iso from services.shared.db import get_session from services.shared.models import HealthResponse, VoiceEventIn, VoiceEventOut from services.shared.security import get_actor from services.shared.sql_init import init_sql_schema from services.shared.sql_models import VoiceEventRow app = FastAPI(title="voice-adapter-service", version="1.0.0") init_sql_schema() def _trusted_service_subjects() -> set[str]: raw = os.getenv("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge,svc:ivr-service") return {item.strip() for item in raw.split(",") if item.strip()} def _is_trusted_service_actor(actor: dict) -> bool: return ( actor.get("auth_source") == "service" and str(actor.get("sub") or "").strip() in _trusted_service_subjects() ) def _require_voice_event_ingest(actor: dict = Depends(get_actor)) -> dict: if actor.get("auth_source") == "service": if _is_trusted_service_actor(actor): return actor raise HTTPException(status_code=403, detail="Untrusted service subject") if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.OPERATOR.value}: return actor raise HTTPException(status_code=403, detail="Insufficient role") def _require_voice_event_read(actor: dict = Depends(get_actor)) -> dict: if actor.get("auth_source") == "service": if _is_trusted_service_actor(actor): return actor raise HTTPException(status_code=403, detail="Untrusted service subject") if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.ANALYST.value}: return actor raise HTTPException(status_code=403, detail="Insufficient role") def _to_out(row: VoiceEventRow) -> VoiceEventOut: return VoiceEventOut( event_id=row.event_id, event_type=row.event_type, call_id=row.call_id, interaction_id=row.interaction_id, source_event_id=row.source_event_id, payload=json.loads(row.payload_json or "{}"), created_at=row.created_at, ) def _load_existing_by_source_event(session, source_event_id: str | None) -> VoiceEventRow | None: if not source_event_id: return None return session.execute( select(VoiceEventRow).where(VoiceEventRow.source_event_id == source_event_id) ).scalar_one_or_none() @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse(status="ok", service="voice-adapter-service") @app.post("/integrations/voice/events", response_model=VoiceEventOut) def ingest_voice_event( payload: VoiceEventIn, _: dict = Depends(_require_voice_event_ingest), ) -> VoiceEventOut: session = get_session() try: existing = _load_existing_by_source_event(session, payload.source_event_id) if existing is not None: return _to_out(existing) row = VoiceEventRow( event_id=new_id("vev"), event_type=payload.event_type, call_id=payload.call_id, interaction_id=payload.interaction_id, source_event_id=payload.source_event_id, payload_json=json.dumps(payload.payload, ensure_ascii=False), created_at=utc_now_iso(), ) session.add(row) try: session.commit() except IntegrityError: session.rollback() existing = _load_existing_by_source_event(session, payload.source_event_id) if existing is not None: return _to_out(existing) raise session.refresh(row) return _to_out(row) finally: session.close() @app.get("/integrations/voice/events", response_model=list[VoiceEventOut]) def list_voice_events( limit: int = 100, _: dict = Depends(_require_voice_event_read), ) -> list[VoiceEventOut]: session = get_session() try: rows = session.execute( select(VoiceEventRow).order_by(VoiceEventRow.id.desc()).limit(max(limit, 1)) ).scalars().all() return [_to_out(r) for r in reversed(rows)] finally: session.close()