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

430 lines
14 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import tempfile
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
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.event_bus import append_outbox_event, event_bus_enabled
from services.shared.models import HealthResponse, RecordingOut, RecordingRegisterIn
from services.shared.security import get_actor, require_roles
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import CallRecordingRow, VoiceEventRow
app = FastAPI(title="recording-service", version="1.0.0")
init_sql_schema()
ALLOWED_AUDIO_TYPES = {
".wav": "audio/wav",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
}
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _trusted_import_subjects() -> set[str]:
raw = os.getenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge")
return {item.strip() for item in raw.split(",") if item.strip()}
def _require_import_upload_actor(actor: dict = Depends(get_actor)) -> dict:
if actor.get("auth_source") == "service":
if str(actor.get("sub") or "").strip() in _trusted_import_subjects():
return actor
raise HTTPException(status_code=403, detail="Untrusted service subject")
if _bool_env("RECORDING_IMPORT_ALLOW_ADMIN", True) and actor.get("role") == Role.ADMIN.value:
return actor
raise HTTPException(status_code=403, detail="Insufficient role")
def _recordings_root() -> Path:
base_dir = os.getenv("CC_RECORDINGS_DIR")
if base_dir:
root = Path(base_dir).resolve()
else:
data_dir = Path(os.getenv("CC_DATA_DIR", ".data")).resolve()
root = data_dir / "recordings"
root.mkdir(parents=True, exist_ok=True)
return root
def _max_bytes() -> int:
raw = os.getenv("RECORDING_MAX_BYTES", "26214400").strip()
try:
value = int(raw)
except ValueError:
value = 26214400
return max(value, 1024)
def _safe_file_name(value: str | None, source_path: Path) -> str:
base_name = (value or source_path.name or "recording.wav").strip()
cleaned = Path(base_name).name.replace(" ", "-")
return cleaned or "recording.wav"
def _normalize_audio_type(file_name: str, mime_type: str | None) -> tuple[str, str]:
suffix = Path(file_name).suffix.lower()
canonical = ALLOWED_AUDIO_TYPES.get(suffix)
if not canonical:
raise HTTPException(status_code=415, detail="Unsupported audio format")
return canonical, suffix
def _sha256_for(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _copy_into_storage(
*,
source_path: Path,
recording_id: str,
file_name: str,
) -> tuple[Path, int, str]:
if not source_path.exists() or not source_path.is_file():
raise HTTPException(status_code=404, detail="Source recording not found")
size_bytes = source_path.stat().st_size
if size_bytes > _max_bytes():
raise HTTPException(status_code=413, detail="Recording exceeds max size")
day_prefix = utc_now_iso().split("T", 1)[0].split("-")
target_dir = _recordings_root().joinpath(*day_prefix)
target_dir.mkdir(parents=True, exist_ok=True)
target_path = (target_dir / f"{recording_id}_{file_name}").resolve()
shutil.copy2(source_path, target_path)
checksum = _sha256_for(target_path)
return target_path, size_bytes, checksum
def _to_out(row: CallRecordingRow) -> RecordingOut:
return RecordingOut(
recording_id=row.recording_id,
channel="voice",
call_id=row.call_id,
interaction_id=row.interaction_id,
source_event_id=row.source_event_id,
file_name=row.file_name,
mime_type=row.mime_type,
size_bytes=row.size_bytes,
duration_seconds=row.duration_seconds,
storage_backend="local_fs",
status=row.status,
recorded_at=row.recorded_at,
created_at=row.created_at,
updated_at=row.updated_at,
archived_at=row.archived_at,
)
def _load_existing_by_source_event(session, source_event_id: str | None) -> CallRecordingRow | None:
if not source_event_id:
return None
return session.execute(
select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id)
).scalar_one_or_none()
def _register_recording(
session,
*,
call_id: str,
interaction_id: str | None,
source_path: Path,
file_name: str | None,
mime_type: str | None,
duration_seconds: int | None,
recorded_at: str | None,
source_event_id: str | None,
) -> CallRecordingRow:
existing = _load_existing_by_source_event(session, source_event_id)
if existing:
return existing
recording_id = new_id("rec")
safe_name = _safe_file_name(file_name, source_path)
normalized_mime, _ = _normalize_audio_type(safe_name, mime_type)
target_path: Path | None = None
target_path, size_bytes, checksum = _copy_into_storage(
source_path=source_path,
recording_id=recording_id,
file_name=safe_name,
)
now = utc_now_iso()
row = CallRecordingRow(
recording_id=recording_id,
channel="voice",
call_id=call_id,
interaction_id=interaction_id,
source_event_id=source_event_id,
file_name=safe_name,
storage_backend="local_fs",
storage_path=str(target_path),
mime_type=normalized_mime,
size_bytes=size_bytes,
duration_seconds=duration_seconds,
checksum_sha256=checksum,
status="ready",
recorded_at=recorded_at or now,
created_at=now,
updated_at=now,
archived_at=None,
)
session.add(row)
if event_bus_enabled():
append_outbox_event(
session,
event_type="call.recording.ready",
producer_service="recording-service",
entity_type="recording",
entity_id=recording_id,
correlation_id=interaction_id or call_id,
payload={
"event": "call.recording.ready",
"recording_id": recording_id,
"call_id": call_id,
"interaction_id": interaction_id,
"source_event_id": source_event_id,
"file_name": safe_name,
"mime_type": normalized_mime,
"duration_seconds": duration_seconds,
"status": "ready",
"recorded_at": recorded_at or now,
},
)
try:
session.commit()
except IntegrityError:
session.rollback()
if target_path is not None and target_path.exists():
target_path.unlink(missing_ok=True)
existing = _load_existing_by_source_event(session, source_event_id)
if existing is not None:
return existing
raise
session.refresh(row)
return row
def _get_recording_row(session, recording_id: str) -> CallRecordingRow:
row = session.execute(
select(CallRecordingRow).where(CallRecordingRow.recording_id == recording_id)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Recording not found")
return row
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="recording-service")
@app.post(
"/recordings/import-from-voice-event/{event_id}",
response_model=RecordingOut,
)
def import_from_voice_event(
event_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> RecordingOut:
session = get_session()
try:
voice_event = session.execute(
select(VoiceEventRow).where(VoiceEventRow.event_id == event_id)
).scalar_one_or_none()
if not voice_event:
raise HTTPException(status_code=404, detail="Voice event not found")
if voice_event.event_type != "recording.ready":
raise HTTPException(status_code=400, detail="Voice event is not recording.ready")
payload = json.loads(voice_event.payload_json or "{}")
source_path_raw = str(payload.get("source_path") or "").strip()
if not source_path_raw:
raise HTTPException(status_code=400, detail="Recording source_path is required")
row = _register_recording(
session,
call_id=voice_event.call_id,
interaction_id=voice_event.interaction_id,
source_path=Path(source_path_raw).resolve(),
file_name=payload.get("file_name"),
mime_type=payload.get("mime_type"),
duration_seconds=payload.get("duration_seconds"),
recorded_at=payload.get("recorded_at"),
source_event_id=voice_event.event_id,
)
return _to_out(row)
finally:
session.close()
@app.post("/recordings/register", response_model=RecordingOut)
def register_recording(
payload: RecordingRegisterIn,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> RecordingOut:
session = get_session()
try:
row = _register_recording(
session,
call_id=payload.call_id,
interaction_id=payload.interaction_id,
source_path=Path(payload.source_path).resolve(),
file_name=payload.file_name,
mime_type=payload.mime_type,
duration_seconds=payload.duration_seconds,
recorded_at=payload.recorded_at,
source_event_id=payload.source_event_id,
)
return _to_out(row)
finally:
session.close()
@app.post("/recordings/import-upload", response_model=RecordingOut)
def import_uploaded_recording(
file: UploadFile = File(...),
call_id: str = Form(...),
interaction_id: str | None = Form(default=None),
source_event_id: str | None = Form(default=None),
file_name: str | None = Form(default=None),
mime_type: str | None = Form(default=None),
duration_seconds: int | None = Form(default=None),
recorded_at: str | None = Form(default=None),
_: dict = Depends(_require_import_upload_actor),
) -> RecordingOut:
suffix = Path(file.filename or file_name or "recording.wav").suffix or ".wav"
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(prefix="mvpcc-rec-", suffix=suffix, delete=False) as handle:
shutil.copyfileobj(file.file, handle)
temp_path = Path(handle.name)
session = get_session()
try:
row = _register_recording(
session,
call_id=call_id,
interaction_id=interaction_id,
source_path=temp_path.resolve(),
file_name=file_name or file.filename,
mime_type=mime_type or file.content_type,
duration_seconds=duration_seconds,
recorded_at=recorded_at,
source_event_id=source_event_id,
)
return _to_out(row)
finally:
session.close()
finally:
try:
file.file.close()
except Exception: # noqa: BLE001
pass
if temp_path and temp_path.exists():
temp_path.unlink(missing_ok=True)
@app.get("/recordings", response_model=list[RecordingOut])
def list_recordings(
call_id: str | None = None,
interaction_id: str | None = None,
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> list[RecordingOut]:
session = get_session()
try:
stmt = select(CallRecordingRow).order_by(CallRecordingRow.id.desc())
if call_id:
stmt = stmt.where(CallRecordingRow.call_id == call_id)
if interaction_id:
stmt = stmt.where(CallRecordingRow.interaction_id == interaction_id)
if status:
stmt = stmt.where(CallRecordingRow.status == status)
rows = session.execute(stmt).scalars().all()
return [_to_out(row) for row in rows[:limit]]
finally:
session.close()
@app.get("/recordings/{recording_id}", response_model=RecordingOut)
def get_recording(
recording_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> RecordingOut:
session = get_session()
try:
return _to_out(_get_recording_row(session, recording_id))
finally:
session.close()
@app.get("/recordings/{recording_id}/content")
def get_recording_content(
recording_id: str,
disposition: str = Query(default="inline"),
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> FileResponse:
if disposition not in {"inline", "attachment"}:
raise HTTPException(status_code=400, detail="Invalid disposition")
session = get_session()
try:
row = _get_recording_row(session, recording_id)
path = Path(row.storage_path)
if not path.exists() or not path.is_file():
row.status = "missing"
row.updated_at = utc_now_iso()
session.commit()
raise HTTPException(status_code=410, detail="Recording file is missing")
return FileResponse(
path=path,
media_type=row.mime_type,
filename=row.file_name,
content_disposition_type=disposition,
)
finally:
session.close()
@app.post("/recordings/{recording_id}/archive", response_model=RecordingOut)
def archive_recording(
recording_id: str,
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> RecordingOut:
session = get_session()
try:
row = _get_recording_row(session, recording_id)
now = utc_now_iso()
row.status = "archived"
row.archived_at = now
row.updated_at = now
session.commit()
session.refresh(row)
return _to_out(row)
finally:
session.close()