Files
call-center/services/voice_adapter_service/app.py
T

128 lines
4.2 KiB
Python

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.asc())).scalars().all()
rows = rows[-limit:]
return [_to_out(r) for r in rows]
finally:
session.close()