- Remove duplicate function definitions with hardcoded "AI-оператор" strings (ai_voice_runtime, ai_orchestrator, voice_name_config, voice.py) - Remove unreachable dead code after return in ai_voice_runtime - Add SQL LIMIT to 17 unbounded queries across 12 services to prevent OOM - Move Python-side filtering to SQL WHERE in reporting_service - Downgrade 19 logger.warning to logger.info for normal-flow events in media_runtime Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
14 KiB
Python
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.limit(max(limit, 1))).scalars().all()
|
|
return [_to_out(row) for row in rows]
|
|
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()
|