from __future__ import annotations import base64 import hashlib import hmac import json import logging import os from datetime import datetime, timedelta, timezone from typing import Any import httpx LOGGER = logging.getLogger("uvicorn.error") def _b64url_encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") def _sign_hs256(message: str, secret: str) -> str: digest = hmac.new( secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256, ).digest() return _b64url_encode(digest) def _issue_service_token(secret: str) -> str: now = datetime.now(timezone.utc) header = {"alg": "HS256", "typ": "JWT"} payload = { "sub": "svc:realtime-voice", "username": "realtime-voice", "role": "admin", "auth_source": "service", "iat": int(now.timestamp()), "exp": int((now + timedelta(seconds=300)).timestamp()), } encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) message = f"{encoded_header}.{encoded_payload}" return f"{message}.{_sign_hs256(message, secret)}" def _enabled() -> bool: return os.getenv("CRM_INTERACTION_ENABLED", "0").strip().lower() in {"1", "true", "yes"} def _base_url() -> str: return str(os.getenv("CRM_INTERACTION_SERVICE_URL", "http://interaction-service:8000")).rstrip("/") def _secret() -> str: return str(os.getenv("CRM_APP_TOKEN_SECRET", "")).strip() def _queue_id() -> str | None: raw = os.getenv("CRM_QUEUE_ID", "").strip() return raw or None async def create_interaction(*, call_id: str) -> str | None: if not _enabled(): return None secret = _secret() if not secret: LOGGER.warning("crm: CRM_APP_TOKEN_SECRET not configured, skipping interaction creation") return None token = _issue_service_token(secret) payload: dict[str, Any] = { "channel": "voice", "subject": f"AI Voice Call [{call_id}]", "priority": 3, } queue = _queue_id() if queue: payload["queue_id"] = queue try: async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.post( f"{_base_url()}/interactions", json=payload, headers={"Authorization": f"Bearer {token}"}, ) resp.raise_for_status() data = resp.json() interaction_id = str(data.get("interaction_id") or "").strip() LOGGER.info("crm: interaction created interaction_id=%s call_id=%s", interaction_id, call_id) return interaction_id or None except Exception: LOGGER.exception("crm: failed to create interaction call_id=%s", call_id) return None async def close_interaction(interaction_id: str) -> None: if not _enabled(): return secret = _secret() if not secret: return token = _issue_service_token(secret) try: async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.patch( f"{_base_url()}/interactions/{interaction_id}/status", json={"status": "closed"}, headers={"Authorization": f"Bearer {token}"}, ) resp.raise_for_status() LOGGER.info("crm: interaction closed interaction_id=%s", interaction_id) except Exception: LOGGER.exception("crm: failed to close interaction interaction_id=%s", interaction_id)