From 71bf69521bf598fb2e23d808fee7fb79129ca2c7 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:41:45 -0400 Subject: [PATCH 1/9] chore: remove legacy twilio_adapter shim The top-level twilio_adapter/ package was a backwards-compatibility layer superseded by adapters/twilio/. Nothing on main imports it (verified by grep); the top-level tests/test_{config,builder,webhook}.py already target adapters.twilio, not this shim. Also drops it from the Dockerfile COPY and pyproject's packaging/coverage config. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 - pyproject.toml | 4 +- twilio_adapter/__init__.py | 26 ---- twilio_adapter/builder.py | 248 ------------------------------------- twilio_adapter/config.py | 81 ------------ twilio_adapter/poster.py | 71 ----------- twilio_adapter/tracker.py | 114 ----------------- twilio_adapter/webhook.py | 231 ---------------------------------- 8 files changed, 2 insertions(+), 774 deletions(-) delete mode 100644 twilio_adapter/__init__.py delete mode 100644 twilio_adapter/builder.py delete mode 100644 twilio_adapter/config.py delete mode 100644 twilio_adapter/poster.py delete mode 100644 twilio_adapter/tracker.py delete mode 100644 twilio_adapter/webhook.py diff --git a/Dockerfile b/Dockerfile index 975202d..485048b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ WORKDIR /app COPY pyproject.toml README.md ./ COPY core ./core COPY adapters ./adapters -COPY twilio_adapter ./twilio_adapter COPY main.py ./ RUN pip install --no-cache-dir . \ diff --git a/pyproject.toml b/pyproject.toml index 2926e60..b4cb04d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ Issues = "https://github.com/vcon-dev/vcon-telephony-adapters/issues" [tool.setuptools.packages.find] where = ["."] -include = ["core*", "adapters*", "twilio_adapter*"] +include = ["core*", "adapters*"] [tool.black] line-length = 100 @@ -101,5 +101,5 @@ disallow_untyped_defs = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] -addopts = "-v --cov=core --cov=adapters --cov=twilio_adapter --cov-report=term-missing" +addopts = "-v --cov=core --cov=adapters --cov-report=term-missing" asyncio_mode = "auto" diff --git a/twilio_adapter/__init__.py b/twilio_adapter/__init__.py deleted file mode 100644 index 963c4f3..0000000 --- a/twilio_adapter/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Twilio adapter - backwards compatibility layer. - -This module re-exports from the new adapters.twilio package for -backward compatibility with existing code that imports from twilio_adapter. -""" - -__version__ = "0.1.0" - -# Re-export from new location for backwards compatibility -from adapters.twilio.builder import TwilioRecordingData -from adapters.twilio.builder import TwilioVconBuilder as VconBuilder -from adapters.twilio.config import TwilioConfig as Config -from adapters.twilio.webhook import create_app - -# Also export core modules that were previously in this package -from core.poster import HttpPoster -from core.tracker import StateTracker - -__all__ = [ - "Config", - "TwilioRecordingData", - "VconBuilder", - "HttpPoster", - "StateTracker", - "create_app", -] diff --git a/twilio_adapter/builder.py b/twilio_adapter/builder.py deleted file mode 100644 index afac447..0000000 --- a/twilio_adapter/builder.py +++ /dev/null @@ -1,248 +0,0 @@ -"""vCon builder to create vCons from Twilio recordings.""" - -import base64 -import logging -from datetime import datetime, timezone -from typing import Any - -import requests -from vcon import Vcon -from vcon.dialog import Dialog -from vcon.party import Party - -logger = logging.getLogger(__name__) - - -# MIME type mapping for Twilio recording formats -MIME_TYPES = { - "wav": "audio/wav", - "mp3": "audio/mpeg", -} - - -class TwilioRecordingData: - """Data class to hold Twilio recording webhook data.""" - - def __init__(self, webhook_data: dict[str, Any]): - """Initialize from Twilio webhook payload. - - Args: - webhook_data: Dictionary of Twilio webhook parameters - """ - # Core identifiers - self.recording_sid = webhook_data.get("RecordingSid", "") - self.account_sid = webhook_data.get("AccountSid", "") - self.call_sid = webhook_data.get("CallSid", "") - - # Recording details - self.recording_url = webhook_data.get("RecordingUrl", "") - self.recording_status = webhook_data.get("RecordingStatus", "") - self.recording_duration = webhook_data.get("RecordingDuration") - self.recording_channels = webhook_data.get("RecordingChannels", "1") - self.recording_source = webhook_data.get("RecordingSource", "") - self.recording_start_time = webhook_data.get("RecordingStartTime") - - # Call participants - self.from_number = webhook_data.get("From", "") - self.to_number = webhook_data.get("To", "") - self.caller = webhook_data.get("Caller", self.from_number) - self.called = webhook_data.get("Called", self.to_number) - - # Direction and status - self.direction = webhook_data.get("Direction", "") - self.call_status = webhook_data.get("CallStatus", "") - - # Additional metadata - self.api_version = webhook_data.get("ApiVersion", "") - self.forwarded_from = webhook_data.get("ForwardedFrom") - self.caller_city = webhook_data.get("CallerCity") - self.caller_state = webhook_data.get("CallerState") - self.caller_zip = webhook_data.get("CallerZip") - self.caller_country = webhook_data.get("CallerCountry") - self.called_city = webhook_data.get("CalledCity") - self.called_state = webhook_data.get("CalledState") - self.called_zip = webhook_data.get("CalledZip") - self.called_country = webhook_data.get("CalledCountry") - - # Store raw data for additional fields - self._raw_data = webhook_data - - @property - def duration_seconds(self) -> float | None: - """Get recording duration in seconds.""" - if self.recording_duration: - try: - return float(self.recording_duration) - except (ValueError, TypeError): - return None - return None - - @property - def start_time(self) -> datetime: - """Get recording start time as datetime.""" - if self.recording_start_time: - try: - # Twilio sends timestamps in RFC 2822 format - from email.utils import parsedate_to_datetime - - return parsedate_to_datetime(self.recording_start_time) - except Exception: - pass - return datetime.now(timezone.utc) - - -class VconBuilder: - """Builds vCon objects from Twilio recording data.""" - - def __init__( - self, - download_recordings: bool = True, - recording_format: str = "wav", - twilio_auth: tuple | None = None, - ): - """Initialize builder. - - Args: - download_recordings: Whether to download and embed recording audio - recording_format: Preferred format for recordings (wav or mp3) - twilio_auth: Tuple of (account_sid, auth_token) for Twilio API - """ - self.download_recordings = download_recordings - self.recording_format = recording_format - self.twilio_auth = twilio_auth - - def _download_recording(self, recording_url: str) -> bytes | None: - """Download recording audio from Twilio. - - Args: - recording_url: Base URL to the recording - - Returns: - Raw audio bytes or None if download fails - """ - # Append format extension to get the audio file - url = f"{recording_url}.{self.recording_format}" - - try: - response = requests.get(url, auth=self.twilio_auth, timeout=60) - - if response.status_code == 200: - logger.debug(f"Downloaded recording: {len(response.content)} bytes") - return response.content - else: - logger.error( - f"Failed to download recording from {url}: " f"status {response.status_code}" - ) - return None - - except Exception as e: - logger.error(f"Error downloading recording from {url}: {e}") - return None - - def build(self, recording_data: TwilioRecordingData) -> Vcon | None: - """Build a vCon from Twilio recording data. - - Args: - recording_data: TwilioRecordingData object with webhook payload - - Returns: - Vcon object or None if building fails - """ - try: - # Create vCon - vcon = Vcon.build_new() - - # Set creation time - start_time = recording_data.start_time - try: - vcon.created_at = start_time.isoformat() - except AttributeError: - logger.debug("Could not set created_at attribute") - - # Add parties (caller and callee) - caller_party = Party(tel=recording_data.from_number) - callee_party = Party(tel=recording_data.to_number) - vcon.add_party(caller_party) - vcon.add_party(callee_party) - - # Determine who initiated based on direction - # inbound = external party called in, outbound = we called out - originator = 0 if recording_data.direction == "outbound" else 1 - - # Build dialog - mime_type = MIME_TYPES.get(self.recording_format, "audio/wav") - dialog_kwargs = { - "type": "recording", - "start": start_time, - "parties": [0, 1], - "originator": originator, - "mimetype": mime_type, - } - - # Add duration if available - if recording_data.duration_seconds is not None: - dialog_kwargs["duration"] = recording_data.duration_seconds - - # Download and embed recording if configured - if self.download_recordings and recording_data.recording_url: - audio_data = self._download_recording(recording_data.recording_url) - if audio_data: - audio_base64 = base64.b64encode(audio_data).decode("utf-8") - dialog_kwargs["body"] = audio_base64 - dialog_kwargs["encoding"] = "base64" - dialog_kwargs["filename"] = ( - f"{recording_data.recording_sid}.{self.recording_format}" - ) - else: - # Fall back to URL reference if download fails - logger.warning( - f"Download failed, using URL reference for {recording_data.recording_sid}" - ) - dialog_kwargs["url"] = f"{recording_data.recording_url}.{self.recording_format}" - elif recording_data.recording_url: - # Use URL reference without downloading - dialog_kwargs["url"] = f"{recording_data.recording_url}.{self.recording_format}" - - # Create dialog - dialog = Dialog(**dialog_kwargs) - vcon.add_dialog(dialog) - - # Add metadata tags - vcon.add_tag("source", "twilio_adapter") - vcon.add_tag("recording_sid", recording_data.recording_sid) - vcon.add_tag("call_sid", recording_data.call_sid) - vcon.add_tag("account_sid", recording_data.account_sid) - - if recording_data.direction: - vcon.add_tag("direction", recording_data.direction) - - if recording_data.recording_source: - vcon.add_tag("recording_source", recording_data.recording_source) - - if recording_data.duration_seconds is not None: - vcon.add_tag("duration_seconds", f"{recording_data.duration_seconds:.2f}") - - # Add geographic metadata if available - if recording_data.caller_city: - vcon.add_tag("caller_city", recording_data.caller_city) - if recording_data.caller_state: - vcon.add_tag("caller_state", recording_data.caller_state) - if recording_data.caller_country: - vcon.add_tag("caller_country", recording_data.caller_country) - if recording_data.called_city: - vcon.add_tag("called_city", recording_data.called_city) - if recording_data.called_state: - vcon.add_tag("called_state", recording_data.called_state) - if recording_data.called_country: - vcon.add_tag("called_country", recording_data.called_country) - - logger.info( - f"Created vCon {vcon.uuid} from Twilio recording {recording_data.recording_sid} " - f"(from: {recording_data.from_number}, to: {recording_data.to_number})" - ) - - return vcon - - except Exception as e: - logger.error(f"Error building vCon from recording {recording_data.recording_sid}: {e}") - return None diff --git a/twilio_adapter/config.py b/twilio_adapter/config.py deleted file mode 100644 index 5aa737e..0000000 --- a/twilio_adapter/config.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Configuration management for Twilio adapter using .env file.""" - -import os - -from dotenv import load_dotenv - - -class Config: - """Manages configuration from environment variables.""" - - def __init__(self, env_file: str | None = None): - """Load configuration from .env file.""" - if env_file: - load_dotenv(env_file) - else: - load_dotenv() - - # Server settings - self.host = os.getenv("HOST", "0.0.0.0") - self.port = int(os.getenv("PORT", "8080")) - - # Twilio settings (for signature validation) - self.twilio_account_sid = os.getenv("TWILIO_ACCOUNT_SID") - self.twilio_auth_token = os.getenv("TWILIO_AUTH_TOKEN") - self.validate_twilio_signature = os.getenv("VALIDATE_TWILIO_SIGNATURE", "true").lower() in ( - "true", - "1", - "yes", - ) - - if self.validate_twilio_signature and not self.twilio_auth_token: - raise ValueError("TWILIO_AUTH_TOKEN is required when VALIDATE_TWILIO_SIGNATURE is true") - - # Webhook URL for signature validation (optional, can be auto-detected) - self.webhook_url = os.getenv("WEBHOOK_URL") - - # Conserver settings (required) - self.conserver_url = os.getenv("CONSERVER_URL") - if not self.conserver_url: - raise ValueError("CONSERVER_URL environment variable is required") - - # Optional conserver authentication - self.conserver_api_token = os.getenv("CONSERVER_API_TOKEN") - self.conserver_header_name = os.getenv("CONSERVER_HEADER_NAME", "x-conserver-api-token") - - # State tracking - self.state_file = os.getenv("STATE_FILE", ".twilio_adapter_state.json") - - # Ingress lists for vCon routing - ingress_lists_str = os.getenv("INGRESS_LISTS", "") - self.ingress_lists = [item.strip() for item in ingress_lists_str.split(",") if item.strip()] - - # Recording download settings - self.download_recordings = os.getenv("DOWNLOAD_RECORDINGS", "true").lower() in ( - "true", - "1", - "yes", - ) - - # Recording format preference (wav or mp3) - self.recording_format = os.getenv("RECORDING_FORMAT", "wav").lower() - if self.recording_format not in ("wav", "mp3"): - raise ValueError( - f"RECORDING_FORMAT must be 'wav' or 'mp3', got: {self.recording_format}" - ) - - # Logging level - self.log_level = os.getenv("LOG_LEVEL", "INFO").upper() - - def get_headers(self) -> dict[str, str]: - """Get HTTP headers for conserver requests.""" - headers = {"Content-Type": "application/json"} - if self.conserver_api_token: - headers[self.conserver_header_name] = self.conserver_api_token - return headers - - def get_twilio_auth(self) -> tuple | None: - """Get Twilio authentication tuple for API requests.""" - if self.twilio_account_sid and self.twilio_auth_token: - return (self.twilio_account_sid, self.twilio_auth_token) - return None diff --git a/twilio_adapter/poster.py b/twilio_adapter/poster.py deleted file mode 100644 index 1e1d6f6..0000000 --- a/twilio_adapter/poster.py +++ /dev/null @@ -1,71 +0,0 @@ -"""HTTP poster to send vCons to conserver endpoint.""" - -import logging - -import requests -from vcon import Vcon - -logger = logging.getLogger(__name__) - - -class HttpPoster: - """Posts vCons to HTTP conserver endpoint.""" - - def __init__(self, url: str, headers: dict[str, str], ingress_lists: list[str] | None = None): - """Initialize HTTP poster. - - Args: - url: Conserver endpoint URL - headers: HTTP headers to include in requests - ingress_lists: Optional list of ingress queue names to route vCons to - """ - self.url = url - self.headers = headers - self.ingress_lists = ingress_lists or [] - - def post(self, vcon: Vcon) -> bool: - """Post vCon to conserver endpoint. - - Args: - vcon: Vcon object to post - - Returns: - True if post was successful, False otherwise - """ - try: - # Build URL with ingress_lists query parameter if configured - url = self.url - params = {} - if self.ingress_lists: - params["ingress_lists"] = ",".join(self.ingress_lists) - logger.info( - f"Posting vCon {vcon.uuid} to {url} " - f"with ingress_lists: {', '.join(self.ingress_lists)}" - ) - else: - logger.info(f"Posting vCon {vcon.uuid} to {url}") - - # Convert vCon to JSON - vcon_json = vcon.to_json() - - # POST to endpoint - response = requests.post( - url, params=params, data=vcon_json, headers=self.headers, timeout=30 - ) - - # Check if response indicates success - if 200 <= response.status_code < 300: - logger.info( - f"Successfully posted vCon {vcon.uuid} " f"(status: {response.status_code})" - ) - return True - else: - logger.error( - f"Failed to post vCon {vcon.uuid} " - f"(status: {response.status_code}, response: {response.text[:200]})" - ) - return False - - except Exception as e: - logger.error(f"Error posting vCon {vcon.uuid} to {self.url}: {e}") - return False diff --git a/twilio_adapter/tracker.py b/twilio_adapter/tracker.py deleted file mode 100644 index bf200af..0000000 --- a/twilio_adapter/tracker.py +++ /dev/null @@ -1,114 +0,0 @@ -"""State tracker to prevent reprocessing of recordings.""" - -import json -import logging -from datetime import datetime, timezone -from pathlib import Path - -logger = logging.getLogger(__name__) - - -class StateTracker: - """Tracks processed recordings to avoid duplicates.""" - - def __init__(self, state_file: str): - """Initialize state tracker. - - Args: - state_file: Path to JSON file storing state - """ - self.state_file = Path(state_file) - self.state: dict[str, dict] = {} - self._load() - - def _load(self): - """Load state from file.""" - if self.state_file.exists(): - try: - with open(self.state_file) as f: - self.state = json.load(f) - logger.info(f"Loaded state for {len(self.state)} processed recordings") - except Exception as e: - logger.error(f"Error loading state file: {e}") - self.state = {} - else: - self.state = {} - - def _save(self): - """Save state to file.""" - try: - with open(self.state_file, "w") as f: - json.dump(self.state, f, indent=2) - except Exception as e: - logger.error(f"Error saving state file: {e}") - - def is_processed(self, recording_sid: str) -> bool: - """Check if recording has been processed. - - Args: - recording_sid: Twilio RecordingSid - - Returns: - True if recording has been processed - """ - return recording_sid in self.state - - def mark_processed( - self, - recording_sid: str, - vcon_uuid: str, - status: str = "success", - call_sid: str | None = None, - from_number: str | None = None, - to_number: str | None = None, - ): - """Mark recording as processed. - - Args: - recording_sid: Twilio RecordingSid - vcon_uuid: UUID of the created vCon - status: Processing status (success, failed, etc.) - call_sid: Optional Twilio CallSid - from_number: Optional caller phone number - to_number: Optional callee phone number - """ - entry = { - "vcon_uuid": vcon_uuid, - "timestamp": datetime.now(timezone.utc).isoformat(), - "status": status, - } - - if call_sid: - entry["call_sid"] = call_sid - if from_number: - entry["from"] = from_number - if to_number: - entry["to"] = to_number - - self.state[recording_sid] = entry - self._save() - logger.debug(f"Marked recording {recording_sid} as processed (status: {status})") - - def get_vcon_uuid(self, recording_sid: str) -> str | None: - """Get vCon UUID for a processed recording. - - Args: - recording_sid: Twilio RecordingSid - - Returns: - vCon UUID or None if not processed - """ - entry = self.state.get(recording_sid) - return entry.get("vcon_uuid") if entry else None - - def get_processing_status(self, recording_sid: str) -> str | None: - """Get processing status for a recording. - - Args: - recording_sid: Twilio RecordingSid - - Returns: - Processing status or None if not processed - """ - entry = self.state.get(recording_sid) - return entry.get("status") if entry else None diff --git a/twilio_adapter/webhook.py b/twilio_adapter/webhook.py deleted file mode 100644 index ef92228..0000000 --- a/twilio_adapter/webhook.py +++ /dev/null @@ -1,231 +0,0 @@ -"""FastAPI webhook receiver for Twilio recording status callbacks.""" - -import logging - -from fastapi import FastAPI, Form, HTTPException, Request -from fastapi.responses import PlainTextResponse -from twilio.request_validator import RequestValidator - -from .builder import TwilioRecordingData, VconBuilder -from .config import Config -from .poster import HttpPoster -from .tracker import StateTracker - -logger = logging.getLogger(__name__) - - -def create_app(config: Config) -> FastAPI: - """Create and configure the FastAPI application. - - Args: - config: Application configuration - - Returns: - Configured FastAPI application - """ - app = FastAPI( - title="vCon Twilio Adapter", - description="Receives Twilio recording webhooks and creates vCons", - version="0.1.0", - ) - - # Initialize components - builder = VconBuilder( - download_recordings=config.download_recordings, - recording_format=config.recording_format, - twilio_auth=config.get_twilio_auth(), - ) - poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) - tracker = StateTracker(config.state_file) - - # Twilio signature validator - validator = None - if config.validate_twilio_signature and config.twilio_auth_token: - validator = RequestValidator(config.twilio_auth_token) - - async def validate_twilio_request(request: Request) -> bool: - """Validate that request came from Twilio. - - Args: - request: FastAPI request object - - Returns: - True if request is valid - - Raises: - HTTPException: If validation fails - """ - if not config.validate_twilio_signature: - return True - - if not validator: - logger.warning("Signature validation enabled but validator not configured") - return True - - # Get the URL for validation - url = config.webhook_url or str(request.url) - - # Get the signature header - signature = request.headers.get("X-Twilio-Signature", "") - - # Get form data for validation - form_data = await request.form() - params = dict(form_data.items()) - - # Validate the request - if not validator.validate(url, params, signature): - logger.warning(f"Invalid Twilio signature for request to {url}") - raise HTTPException(status_code=403, detail="Invalid Twilio signature") - - return True - - @app.get("/health") - async def health_check(): - """Health check endpoint.""" - return {"status": "healthy", "service": "vcon-telephony-adapters-twilio"} - - @app.post("/webhook/recording", response_class=PlainTextResponse) - async def recording_status_callback( - request: Request, - RecordingSid: str = Form(...), - AccountSid: str = Form(default=""), - CallSid: str = Form(default=""), - RecordingUrl: str = Form(default=""), - RecordingStatus: str = Form(default=""), - RecordingDuration: str | None = Form(default=None), - RecordingChannels: str = Form(default="1"), - RecordingSource: str = Form(default=""), - RecordingStartTime: str | None = Form(default=None), - From: str = Form(default="", alias="From"), - To: str = Form(default="", alias="To"), - Caller: str = Form(default=""), - Called: str = Form(default=""), - Direction: str = Form(default=""), - CallStatus: str = Form(default=""), - ApiVersion: str = Form(default=""), - ForwardedFrom: str | None = Form(default=None), - CallerCity: str | None = Form(default=None), - CallerState: str | None = Form(default=None), - CallerZip: str | None = Form(default=None), - CallerCountry: str | None = Form(default=None), - CalledCity: str | None = Form(default=None), - CalledState: str | None = Form(default=None), - CalledZip: str | None = Form(default=None), - CalledCountry: str | None = Form(default=None), - ): - """Handle Twilio recording status callback. - - This endpoint receives webhooks from Twilio when recordings are ready. - It creates a vCon from the recording data and posts it to the conserver. - """ - # Validate Twilio signature - await validate_twilio_request(request) - - logger.info( - f"Received recording callback: RecordingSid={RecordingSid}, " - f"Status={RecordingStatus}, CallSid={CallSid}" - ) - - # Only process completed recordings - if RecordingStatus != "completed": - logger.info(f"Ignoring recording {RecordingSid} with status: {RecordingStatus}") - return "OK" - - # Check if already processed - if tracker.is_processed(RecordingSid): - logger.info(f"Recording {RecordingSid} already processed, skipping") - return "OK" - - # Build webhook data dictionary - webhook_data = { - "RecordingSid": RecordingSid, - "AccountSid": AccountSid, - "CallSid": CallSid, - "RecordingUrl": RecordingUrl, - "RecordingStatus": RecordingStatus, - "RecordingDuration": RecordingDuration, - "RecordingChannels": RecordingChannels, - "RecordingSource": RecordingSource, - "RecordingStartTime": RecordingStartTime, - "From": From, - "To": To, - "Caller": Caller or From, - "Called": Called or To, - "Direction": Direction, - "CallStatus": CallStatus, - "ApiVersion": ApiVersion, - "ForwardedFrom": ForwardedFrom, - "CallerCity": CallerCity, - "CallerState": CallerState, - "CallerZip": CallerZip, - "CallerCountry": CallerCountry, - "CalledCity": CalledCity, - "CalledState": CalledState, - "CalledZip": CalledZip, - "CalledCountry": CalledCountry, - } - - # Parse recording data - recording_data = TwilioRecordingData(webhook_data) - - # Build vCon - vcon = builder.build(recording_data) - if not vcon: - logger.error(f"Failed to build vCon for recording {RecordingSid}") - tracker.mark_processed( - RecordingSid, - "", - status="build_failed", - call_sid=CallSid, - from_number=From, - to_number=To, - ) - return "OK" - - # Post to conserver - success = poster.post(vcon) - - if success: - tracker.mark_processed( - RecordingSid, - vcon.uuid, - status="success", - call_sid=CallSid, - from_number=From, - to_number=To, - ) - logger.info(f"Successfully processed recording {RecordingSid} -> vCon {vcon.uuid}") - else: - tracker.mark_processed( - RecordingSid, - vcon.uuid, - status="post_failed", - call_sid=CallSid, - from_number=From, - to_number=To, - ) - logger.error(f"Failed to post vCon {vcon.uuid} for recording {RecordingSid}") - - # Always return 200 OK to Twilio to prevent retries - return "OK" - - @app.get("/status/{recording_sid}") - async def get_recording_status(recording_sid: str): - """Get processing status for a recording. - - Args: - recording_sid: Twilio RecordingSid - - Returns: - Processing status information - """ - if not tracker.is_processed(recording_sid): - raise HTTPException(status_code=404, detail="Recording not found") - - return { - "recording_sid": recording_sid, - "vcon_uuid": tracker.get_vcon_uuid(recording_sid), - "status": tracker.get_processing_status(recording_sid), - } - - return app From 8d603a5ef2d47af168b16dc2edf44b232087e00c Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:42:00 -0400 Subject: [PATCH 2/9] feat(vapi): add VAPI adapter Webhook-based: VAPI posts an end-of-call-report server message per call. Authenticated via constant-time comparison of the shared secret VAPI echoes back in x-vapi-secret; fails closed like every other webhook adapter (config raises at startup unless the secret is set or ALLOW_UNSIGNED_WEBHOOKS=true). VAPI also offers HMAC and OAuth auth with a configurable header; this implements the simpler, documented shared-secret header, not the HMAC variant (see README). Wired to core.lawful_basis and core.media_publisher; ADAPTER_SOURCE attachments use mediatype (draft-04), not mimetype. Not registered against core.base_builder.BaseVconBuilder: VAPI's payload carries the whole conversation (per-turn messages, transcript, analysis) in one event rather than one recording per event, so the builder is written directly against vcon-lib following the same conventions instead. Co-Authored-By: Claude Opus 5.5 --- adapters/vapi/__init__.py | 11 ++ adapters/vapi/builder.py | 274 ++++++++++++++++++++++++++++++++++++++ adapters/vapi/config.py | 55 ++++++++ adapters/vapi/webhook.py | 119 +++++++++++++++++ 4 files changed, 459 insertions(+) create mode 100644 adapters/vapi/__init__.py create mode 100644 adapters/vapi/builder.py create mode 100644 adapters/vapi/config.py create mode 100644 adapters/vapi/webhook.py diff --git a/adapters/vapi/__init__.py b/adapters/vapi/__init__.py new file mode 100644 index 0000000..54e4c3d --- /dev/null +++ b/adapters/vapi/__init__.py @@ -0,0 +1,11 @@ +"""VAPI adapter for vCon telephony adapters.""" + +from .builder import VapiVconBuilder +from .config import VapiConfig +from .webhook import create_app + +__all__ = [ + "VapiConfig", + "VapiVconBuilder", + "create_app", +] diff --git a/adapters/vapi/builder.py b/adapters/vapi/builder.py new file mode 100644 index 0000000..c8e3bcb --- /dev/null +++ b/adapters/vapi/builder.py @@ -0,0 +1,274 @@ +"""vCon builder for VAPI end-of-call-report messages. + +VAPI (https://vapi.ai) is a voice-AI agent platform. Its `end-of-call-report` +server message carries the whole conversation at once (per-turn messages, +transcript, analysis, recording URL), unlike the recording-webhook adapters +(Twilio, Bandwidth, ...) which get one event per recording. That shape does +not fit `core.base_builder.BaseVconBuilder` (single recording dialog, single +download), so this builder is written directly against vcon-lib, following +the same conventions `BaseVconBuilder` uses: lawful basis is applied via +`LawfulBasisConfig`, audio is re-hosted through the configured +`AudioPublisher` rather than embedded when one is configured, `mediatype` +(never `mimetype`) is used throughout, and every attachment gets the +draft-04 required fields (`start`, `party`, `dialog`, and `mediatype` when a +`body` is present) backfilled before the vCon is returned. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import shutil +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + +from vcon import Vcon +from vcon.dialog import Dialog +from vcon.party import Party + +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import AudioPublisher, PublishingError + +logger = logging.getLogger(__name__) + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sha512_content_hash(data: bytes) -> str: + return "sha512-" + _base64url(hashlib.sha512(data).digest()) + + +def _epoch_ms_to_iso(epoch_ms: Any) -> str | None: + if epoch_ms is None: + return None + try: + return datetime.fromtimestamp(float(epoch_ms) / 1000.0).isoformat() + except (ValueError, TypeError): + return None + + +def _strip_empty_placeholders(d: dict, keys: tuple = ("meta", "metadata")) -> None: + """vcon-lib's Dialog.to_dict emits empty `{}` placeholders when unset.""" + for k in keys: + if k in d and d[k] == {}: + del d[k] + + +class VapiVconBuilder: + """Builds a vCon from a VAPI `end-of-call-report` message payload.""" + + ADAPTER_SOURCE = "vapi" + + def __init__( + self, + *, + customer_party_name: str = "Customer", + agent_party_name: str = "AI Agent", + download_recordings: bool = True, + publisher: AudioPublisher | None = None, + lawful_basis: LawfulBasisConfig | None = None, + ): + self.customer_party_name = customer_party_name + self.agent_party_name = agent_party_name + self.download_recordings = download_recordings + self.publisher = publisher + self.lawful_basis = lawful_basis + + def build(self, vapi_message: dict[str, Any]) -> Vcon | None: + """Build a vCon from a VAPI 'end-of-call-report' message payload. + + Returns None for any other message type (status updates, etc.) so the + webhook handler can ack and ignore it. + """ + try: + if vapi_message.get("type") != "end-of-call-report": + logger.debug("Ignoring non-end-of-call VAPI message") + return None + + vcon = Vcon.build_new() + + if vapi_message.get("startedAt"): + vcon.vcon_dict["created_at"] = vapi_message["startedAt"] + + vcon.add_party(Party(name=self.customer_party_name, role="customer")) + vcon.add_party(Party(name=self.agent_party_name, role="agent")) + + artifact = vapi_message.get("artifact") or {} + for msg in artifact.get("messages", []) or []: + role = msg.get("role") + if role not in ("user", "bot"): + continue + start_iso = _epoch_ms_to_iso(msg.get("time")) + vcon.add_dialog( + Dialog( + type="text", + start=start_iso, + parties=[0, 1], + originator=0 if role == "user" else 1, + body=msg.get("message", ""), + encoding="none", + mediatype="text/plain", + ) + ) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + self._add_recording_dialog(vcon, vapi_message) + + if vapi_message.get("transcript"): + vcon.add_analysis( + type="transcript", + dialog=0, + vendor="vapi", + body=vapi_message["transcript"], + encoding="none", + ) + + analysis_block = vapi_message.get("analysis") or {} + if analysis_block.get("summary"): + vcon.add_analysis( + type="summary", + dialog=0, + vendor="vapi", + body=analysis_block["summary"], + encoding="none", + ) + if "successEvaluation" in analysis_block: + vcon.add_analysis( + type="success_evaluation", + dialog=0, + vendor="vapi", + body=json.dumps({"value": analysis_block["successEvaluation"]}), + encoding="json", + ) + + call = vapi_message.get("call") + call_id = call.get("id") if isinstance(call, dict) else None + call_record = { + k: v + for k, v in { + "cost": vapi_message.get("cost"), + "durationSeconds": vapi_message.get("durationSeconds"), + "endedReason": vapi_message.get("endedReason"), + "callId": call_id, + }.items() + if v is not None + } + if call_record: + vcon.add_attachment( + purpose="call_record", + body=json.dumps(call_record), + encoding="json", + party=0, + dialog=0, + ) + + vcon.add_tag("source", self.ADAPTER_SOURCE) + if call_id: + vcon.add_tag("call_id", call_id) + + if self.lawful_basis is None or not self.lawful_basis.apply(vcon): + logger.warning( + "vCon %s carries no lawful_basis attachment. Set LAWFUL_BASIS " + "before handling real conversations.", + vcon.uuid, + ) + + # Backfill draft-04 required attachment fields the library + # doesn't set on its own (see core.base_builder.build for the + # equivalent, kept in step with it here since this builder + # doesn't subclass BaseVconBuilder). + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + if attachment.get("body") is not None: + attachment.setdefault("mediatype", "application/json") + + return vcon + + except Exception as e: # noqa: BLE001 - a build failure must not crash the webhook + logger.error(f"Error building VAPI vCon: {e}") + return None + + def _add_recording_dialog(self, vcon: Vcon, vapi_message: dict[str, Any]) -> None: + recording_url = vapi_message.get("recordingUrl") + if not recording_url: + return + + call = vapi_message.get("call") + call_id = (call.get("id") if isinstance(call, dict) else None) or "vapi-call" + filename = f"{call_id}.wav" + duration = vapi_message.get("durationSeconds") + + dialog_kwargs: dict[str, Any] = { + "type": "recording", + "start": _epoch_ms_to_iso(vapi_message.get("startedAt")) + or vapi_message.get("startedAt"), + "parties": [0, 1], + "mediatype": "audio/wav", + } + if duration is not None: + dialog_kwargs["duration"] = duration + + if self.publisher is not None: + published = self._download_and_publish(recording_url, filename) + if published is not None: + dialog_kwargs["url"] = published[0] + dialog_kwargs["content_hash"] = published[1] + dialog_kwargs["filename"] = filename + else: + logger.error( + "Publishing failed for VAPI call %s; emitting no recording dialog " + "rather than a link that will not resolve", + call_id, + ) + return + elif self.download_recordings: + audio_bytes = self._download(recording_url) + if audio_bytes: + dialog_kwargs["body"] = _base64url(audio_bytes) + dialog_kwargs["encoding"] = "base64url" + dialog_kwargs["content_hash"] = _sha512_content_hash(audio_bytes) + dialog_kwargs["filename"] = filename + else: + dialog_kwargs["url"] = recording_url + else: + dialog_kwargs["url"] = recording_url + + vcon.add_dialog(Dialog(**dialog_kwargs)) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + @staticmethod + def _download(recording_url: str) -> bytes | None: + import requests + + try: + response = requests.get(recording_url, timeout=60) + response.raise_for_status() + return response.content + except requests.RequestException as e: + logger.error(f"Failed to download VAPI recording: {e}") + return None + + def _download_and_publish(self, recording_url: str, filename: str) -> tuple[str, str] | None: + audio_bytes = self._download(recording_url) + if not audio_bytes: + return None + + tmp_dir = Path(tempfile.mkdtemp(prefix="vcon-media-")) + tmp_path = tmp_dir / filename + try: + tmp_path.write_bytes(audio_bytes) + published = self.publisher.publish(tmp_path, filename) + return published.url, published.content_hash + except PublishingError as exc: + logger.error(f"Publishing VAPI recording failed: {exc}") + return None + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/adapters/vapi/config.py b/adapters/vapi/config.py new file mode 100644 index 0000000..a3d8262 --- /dev/null +++ b/adapters/vapi/config.py @@ -0,0 +1,55 @@ +"""Configuration management for the VAPI adapter.""" + +import logging +import os + +from core.base_config import BaseConfig + +logger = logging.getLogger(__name__) + + +class VapiConfig(BaseConfig): + """VAPI-specific configuration. + + VAPI (https://vapi.ai) posts an `end-of-call-report` server message when a + voice-AI call finishes. Authentication is a shared secret VAPI echoes back + in the `x-vapi-secret` header on every server-URL request it makes + (https://docs.vapi.ai/server-url/server-authentication, "legacy" header + form of its Bearer-token auth). VAPI also offers HMAC-signature + authentication with a configurable header name, but the shared-secret + header is the simpler, documented default and is what this adapter + verifies with a constant-time comparison. + """ + + def __init__(self, env_file: str | None = None): + super().__init__(env_file) + + self.state_file = os.getenv("STATE_FILE", ".vapi_adapter_state.json") + + # Party display names, since VAPI's end-of-call-report identifies + # parties by role ("user"/"bot") rather than name. + self.customer_party_name = os.getenv("VAPI_CUSTOMER_NAME", "Customer") + self.agent_party_name = os.getenv("VAPI_AGENT_NAME", "AI Agent") + + # Shared secret VAPI sends back in the `x-vapi-secret` header. + self.webhook_secret = os.getenv("VAPI_WEBHOOK_SECRET") + + self.validate_webhook = os.getenv("VALIDATE_VAPI_WEBHOOK", "true").lower() in ( + "true", + "1", + "yes", + ) + + if self.validate_webhook and not self.webhook_secret: + if self.allow_unsigned_webhooks: + logger.warning( + "VALIDATE_VAPI_WEBHOOK is enabled but VAPI_WEBHOOK_SECRET is not set; " + "accepting unsigned webhooks because ALLOW_UNSIGNED_WEBHOOKS=true" + ) + else: + raise ValueError( + "VAPI_WEBHOOK_SECRET is required when VALIDATE_VAPI_WEBHOOK is true. " + "Set it to the secret configured on the VAPI server URL, or set " + "ALLOW_UNSIGNED_WEBHOOKS=true to accept unsigned webhooks (not " + "recommended)." + ) diff --git a/adapters/vapi/webhook.py b/adapters/vapi/webhook.py new file mode 100644 index 0000000..a96eba5 --- /dev/null +++ b/adapters/vapi/webhook.py @@ -0,0 +1,119 @@ +"""FastAPI webhook receiver for VAPI server messages.""" + +from __future__ import annotations + +import hmac +import logging + +from fastapi import FastAPI, Header, HTTPException, Request + +from core.poster import HttpPoster +from core.tracker import StateTracker + +from .builder import VapiVconBuilder +from .config import VapiConfig + +logger = logging.getLogger(__name__) + + +def create_app(config: VapiConfig) -> FastAPI: + """Create and configure the FastAPI application for the VAPI adapter.""" + app = FastAPI( + title="vCon VAPI Adapter", + description="Receives VAPI end-of-call-report webhooks and creates vCons", + version="0.1.0", + ) + + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + + builder = VapiVconBuilder( + customer_party_name=config.customer_party_name, + agent_party_name=config.agent_party_name, + download_recordings=config.download_recordings, + publisher=publisher, + lawful_basis=lawful_basis, + ) + poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) + tracker = StateTracker(config.state_file) + + def validate_secret(secret_header: str | None) -> bool: + """Constant-time comparison of the `x-vapi-secret` header. + + Fails closed: if validation is on and no secret is configured, the + adapter refuses to start (see VapiConfig), so this is only ever + reached with a secret configured, or with ALLOW_UNSIGNED_WEBHOOKS set. + """ + if not config.validate_webhook: + return True + if not config.webhook_secret: + # Only reachable with ALLOW_UNSIGNED_WEBHOOKS=true. + return True + if not secret_header: + return False + return hmac.compare_digest( + secret_header.encode("utf-8"), config.webhook_secret.encode("utf-8") + ) + + @app.get("/health") + async def health_check(): + return {"status": "healthy", "service": "vcon-vapi-adapter"} + + @app.post("/vapi") + async def vapi_webhook( + request: Request, + x_vapi_secret: str | None = Header(default=None), + ): + if not validate_secret(x_vapi_secret): + logger.warning("Invalid VAPI webhook secret") + raise HTTPException(status_code=401, detail="Invalid secret") + + try: + data = await request.json() + except Exception: + logger.error("Failed to parse JSON body") + raise HTTPException(status_code=400, detail="Invalid JSON") from None + + if "message" not in data: + raise HTTPException(status_code=422, detail="Missing 'message' key in payload") + + message = data["message"] + call = message.get("call") + call_id = call.get("id") if isinstance(call, dict) else None + + if message.get("type") != "end-of-call-report": + return {"status": "ignored"} + + if call_id and tracker.is_processed(call_id): + logger.info(f"VAPI call {call_id} already processed, skipping") + return {"status": "already_processed"} + + vcon = builder.build(message) + if vcon is None: + return {"status": "ignored"} + + success = poster.post(vcon) + if call_id: + tracker.mark_processed( + call_id, + vcon.uuid, + status="success" if success else "post_failed", + ) + + if success: + return {"status": "ok", "uuid": vcon.uuid} + return {"status": "post_failed", "uuid": vcon.uuid} + + return app From ce331358072c6a96d81ae9687d29d465fc846f39 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:42:06 -0400 Subject: [PATCH 3/9] feat(pipecat): add Pipecat adapter In-process observer, not a webhook receiver: Pipecat runs as a library inside the caller's own voice-AI pipeline process, so there is no inbound request to authenticate. VconConversationObserver accumulates per-turn state via frame-processor hooks and builds a vCon on end-of-conversation; as_frame_processor() wraps it as a real pipecat FrameProcessor, importing pipecat-ai lazily so the rest of the monorepo doesn't need it installed. `python main.py pipecat` starts a health-check-only FastAPI app so the adapter registers and containerizes consistently with the others. Wired to core.lawful_basis and core.media_publisher like the other new adapters; written directly against vcon-lib rather than core.base_builder.BaseVconBuilder since a Pipecat conversation is multi-turn text plus one optional audio dialog, not a single recording download. Co-Authored-By: Claude Opus 5.5 --- adapters/pipecat/__init__.py | 24 +++++ adapters/pipecat/builder.py | 199 +++++++++++++++++++++++++++++++++++ adapters/pipecat/config.py | 30 ++++++ adapters/pipecat/observer.py | 161 ++++++++++++++++++++++++++++ adapters/pipecat/webhook.py | 33 ++++++ 5 files changed, 447 insertions(+) create mode 100644 adapters/pipecat/__init__.py create mode 100644 adapters/pipecat/builder.py create mode 100644 adapters/pipecat/config.py create mode 100644 adapters/pipecat/observer.py create mode 100644 adapters/pipecat/webhook.py diff --git a/adapters/pipecat/__init__.py b/adapters/pipecat/__init__.py new file mode 100644 index 0000000..f92cbda --- /dev/null +++ b/adapters/pipecat/__init__.py @@ -0,0 +1,24 @@ +"""Pipecat adapter: accumulates conversation state from a Pipecat pipeline and +emits vCons. + +Pipecat (https://github.com/pipecat-ai/pipecat) is a voice-AI agent +framework. Unlike webhook-based telephony adapters or pollers, the +integration happens inside the running pipeline: a `VconConversationObserver` +is registered as a frame processor that observes TranscriptionFrame, +LLMResponseFrame, etc., accumulates state, and emits a spec-compliant vCon at +end-of-conversation. See `observer.py` for the integration pattern. +""" + +from .builder import PipecatConversationState, PipecatTurn, PipecatVconBuilder +from .config import PipecatConfig +from .observer import VconConversationObserver +from .webhook import create_app + +__all__ = [ + "PipecatConfig", + "PipecatConversationState", + "PipecatTurn", + "PipecatVconBuilder", + "VconConversationObserver", + "create_app", +] diff --git a/adapters/pipecat/builder.py b/adapters/pipecat/builder.py new file mode 100644 index 0000000..b444771 --- /dev/null +++ b/adapters/pipecat/builder.py @@ -0,0 +1,199 @@ +"""Pipecat vCon builder. + +Converts accumulated Pipecat conversation state into a vCon. Pipecat doesn't +have a single "end-of-call payload" like a webhook adapter; state accumulates +across frame events (see `observer.py`) and the vCon is built when the +conversation ends. As with the VAPI adapter, this doesn't fit +`core.base_builder.BaseVconBuilder`'s single-recording-download shape, so it +is written directly against vcon-lib but follows the same conventions: +`LawfulBasisConfig` for the lawful-basis attachment, the configured +`AudioPublisher` for re-hosting captured audio instead of always embedding +it, `mediatype` throughout, and the draft-04 attachment-field backfill. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import shutil +import tempfile +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Literal + +from vcon import Vcon +from vcon.dialog import Dialog +from vcon.party import Party + +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import AudioPublisher, PublishingError + +logger = logging.getLogger(__name__) + + +@dataclass +class PipecatTurn: + """One turn of a Pipecat conversation: a user or assistant utterance.""" + + role: Literal["user", "assistant"] + text: str + start_time: datetime + end_time: datetime | None = None + + +@dataclass +class PipecatConversationState: + """Accumulated state for a single Pipecat conversation.""" + + conversation_id: str + started_at: datetime + ended_at: datetime | None = None + user_party: dict | None = None + agent_party: dict | None = None + turns: list[PipecatTurn] = field(default_factory=list) + audio_bytes: bytes | None = None + audio_mediatype: str = "audio/wav" + metadata: dict = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sha512_content_hash(data: bytes) -> str: + return "sha512-" + _base64url(hashlib.sha512(data).digest()) + + +def _strip_empty_placeholders(d: dict, keys: tuple = ("meta", "metadata")) -> None: + for k in keys: + if k in d and d[k] == {}: + del d[k] + + +class PipecatVconBuilder: + ADAPTER_SOURCE = "pipecat" + + def __init__( + self, + *, + publisher: AudioPublisher | None = None, + lawful_basis: LawfulBasisConfig | None = None, + ): + self.publisher = publisher + self.lawful_basis = lawful_basis + + def build(self, state: PipecatConversationState) -> Vcon: + vcon = Vcon.build_new() + vcon.vcon_dict["created_at"] = state.started_at.isoformat() + if state.ended_at: + vcon.vcon_dict["updated_at"] = state.ended_at.isoformat() + + user_kwargs = state.user_party or {"role": "user"} + agent_kwargs = state.agent_party or {"name": "Agent", "role": "agent"} + vcon.add_party(Party(**user_kwargs)) + vcon.add_party(Party(**agent_kwargs)) + + for turn in state.turns: + vcon.add_dialog( + Dialog( + type="text", + start=turn.start_time, + parties=[0, 1], + originator=0 if turn.role == "user" else 1, + body=turn.text, + encoding="none", + mediatype="text/plain", + duration=( + (turn.end_time - turn.start_time).total_seconds() if turn.end_time else None + ), + ) + ) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + if state.audio_bytes: + self._add_audio_dialog(vcon, state) + + body = {"conversation_id": state.conversation_id} + if state.metadata: + body["metadata"] = state.metadata + vcon.add_attachment( + purpose="call_record", + body=json.dumps(body), + encoding="json", + party=0, + dialog=0, + ) + + vcon.add_tag("source", self.ADAPTER_SOURCE) + vcon.add_tag("conversation_id", state.conversation_id) + for tag in state.tags: + vcon.add_tag("user_tag", tag) + + if self.lawful_basis is None or not self.lawful_basis.apply(vcon): + logger.warning( + "vCon %s carries no lawful_basis attachment. Set LAWFUL_BASIS " + "before handling real conversations.", + vcon.uuid, + ) + + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + if attachment.get("body") is not None: + attachment.setdefault("mediatype", "application/json") + + return vcon + + def _add_audio_dialog(self, vcon: Vcon, state: PipecatConversationState) -> None: + duration = (state.ended_at - state.started_at).total_seconds() if state.ended_at else None + extension = state.audio_mediatype.split("/")[-1] or "wav" + filename = f"{state.conversation_id}.{extension}" + + dialog_kwargs: dict = { + "type": "recording", + "start": state.started_at, + "parties": [0, 1], + "mediatype": state.audio_mediatype, + } + if duration is not None: + dialog_kwargs["duration"] = duration + + if self.publisher is not None: + published = self._publish(state.audio_bytes, filename) + if published is not None: + dialog_kwargs["url"] = published[0] + dialog_kwargs["content_hash"] = published[1] + dialog_kwargs["filename"] = filename + else: + logger.error( + "Publishing failed for Pipecat conversation %s; emitting no " + "recording dialog rather than a link that will not resolve", + state.conversation_id, + ) + return + else: + dialog_kwargs["body"] = _base64url(state.audio_bytes) + dialog_kwargs["encoding"] = "base64url" + dialog_kwargs["content_hash"] = _sha512_content_hash(state.audio_bytes) + dialog_kwargs["filename"] = filename + + vcon.add_dialog(Dialog(**dialog_kwargs)) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + def _publish(self, audio_bytes: bytes, filename: str) -> tuple[str, str] | None: + tmp_dir = Path(tempfile.mkdtemp(prefix="vcon-media-")) + tmp_path = tmp_dir / filename + try: + tmp_path.write_bytes(audio_bytes) + published = self.publisher.publish(tmp_path, filename) + return published.url, published.content_hash + except PublishingError as exc: + logger.error(f"Publishing Pipecat audio failed: {exc}") + return None + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/adapters/pipecat/config.py b/adapters/pipecat/config.py new file mode 100644 index 0000000..eb3af7a --- /dev/null +++ b/adapters/pipecat/config.py @@ -0,0 +1,30 @@ +"""Configuration for the Pipecat adapter. + +Pipecat (https://github.com/pipecat-ai/pipecat) is a voice-AI pipeline +framework, not a service that calls a webhook: the integration runs inside +the user's own pipeline process (see `observer.py`). There is no inbound +request to authenticate, so this config carries no webhook-validation +settings — only the pieces every adapter needs to post a finished vCon to +the conserver with a lawful basis and (optionally) re-hosted audio. + +`python main.py pipecat` starts a minimal FastAPI app exposing only +`/health`, so the adapter registers and containerizes like the others; the +real integration point is importing `VconConversationObserver` into a +Pipecat pipeline (see the package docstring in `__init__.py`). +""" + +import os + +from core.base_config import BaseConfig + + +class PipecatConfig(BaseConfig): + """Pipecat-specific configuration.""" + + def __init__(self, env_file: str | None = None): + super().__init__(env_file) + + self.state_file = os.getenv("STATE_FILE", ".pipecat_adapter_state.json") + + self.customer_party_name = os.getenv("PIPECAT_USER_NAME") + self.agent_party_name = os.getenv("PIPECAT_AGENT_NAME", "Agent") diff --git a/adapters/pipecat/observer.py b/adapters/pipecat/observer.py new file mode 100644 index 0000000..dc85385 --- /dev/null +++ b/adapters/pipecat/observer.py @@ -0,0 +1,161 @@ +"""Pipecat FrameProcessor that accumulates conversation state into a vCon. + +Designed to plug into a Pipecat pipeline. Listens for TranscriptionFrame, +LLMTextFrame / LLMFullResponseEndFrame, and EndFrame; builds a +`PipecatConversationState` incrementally; emits a vCon on EndFrame. + +USAGE (rough sketch, verify against your Pipecat version): + + from pipecat.pipeline.pipeline import Pipeline + from adapters.pipecat import VconConversationObserver + + observer = VconConversationObserver( + conversation_id="call-123", + on_vcon=lambda v: my_poster.post(v), + ) + + pipeline = Pipeline([ + transport.input(), + stt, + observer, # <-- intercepts user transcripts + llm, + observer, # <-- same instance also intercepts LLM output + tts, + transport.output(), + ]) + +This module deliberately imports `pipecat` lazily so the rest of the +monorepo doesn't need it. Install Pipecat separately to use this adapter: + + pip install pipecat-ai +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any + +from vcon import Vcon + +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import AudioPublisher + +from .builder import PipecatConversationState, PipecatTurn, PipecatVconBuilder + +logger = logging.getLogger(__name__) + + +class VconConversationObserver: + """Pipecat FrameProcessor that builds a vCon as the conversation unfolds. + + NOTE: this class is intentionally NOT a subclass of Pipecat's + `FrameProcessor` at import time — that would force every user of this + monorepo to install pipecat-ai. The real subclass is created lazily via + `as_frame_processor()`. + """ + + def __init__( + self, + *, + conversation_id: str, + on_vcon: Callable[[Vcon], None], + user_party: dict | None = None, + agent_party: dict | None = None, + builder: PipecatVconBuilder | None = None, + publisher: AudioPublisher | None = None, + lawful_basis: LawfulBasisConfig | None = None, + ): + self.state = PipecatConversationState( + conversation_id=conversation_id, + started_at=datetime.now(UTC), + user_party=user_party, + agent_party=agent_party, + ) + self.on_vcon = on_vcon + self.builder = builder or PipecatVconBuilder(publisher=publisher, lawful_basis=lawful_basis) + self._pending_assistant_text = "" + + # ------------------------------------------------------------------ + # Public state-mutating hooks (call from a FrameProcessor wrapper) + # ------------------------------------------------------------------ + def record_user_transcript(self, text: str, *, when: datetime | None = None) -> None: + self.state.turns.append( + PipecatTurn(role="user", text=text, start_time=when or datetime.now(UTC)) + ) + + def record_assistant_text_chunk(self, chunk: str) -> None: + """LLMs stream tokens; buffer until end-of-response.""" + self._pending_assistant_text += chunk + + def finalize_assistant_turn(self, *, when: datetime | None = None) -> None: + if not self._pending_assistant_text: + return + self.state.turns.append( + PipecatTurn( + role="assistant", + text=self._pending_assistant_text, + start_time=when or datetime.now(UTC), + ) + ) + self._pending_assistant_text = "" + + def attach_audio(self, audio_bytes: bytes, *, mediatype: str = "audio/wav") -> None: + self.state.audio_bytes = audio_bytes + self.state.audio_mediatype = mediatype + + def end(self) -> Vcon: + """Mark conversation end, build vCon, invoke callback, return it.""" + if self._pending_assistant_text: + self.finalize_assistant_turn() + self.state.ended_at = datetime.now(UTC) + vcon = self.builder.build(self.state) + try: + self.on_vcon(vcon) + except Exception: + logger.exception("on_vcon callback raised") + return vcon + + # ------------------------------------------------------------------ + # Pipecat integration (lazy: only imports pipecat when called) + # ------------------------------------------------------------------ + def as_frame_processor(self) -> Any: + """Return a real `pipecat.processors.frame_processor.FrameProcessor` + subclass that delegates frame events to this observer. + + Requires `pipecat-ai` installed. + """ + try: + from pipecat.frames.frames import ( # type: ignore + EndFrame, + Frame, + LLMFullResponseEndFrame, + LLMTextFrame, + TranscriptionFrame, + ) + from pipecat.processors.frame_processor import ( # type: ignore + FrameDirection, + FrameProcessor, + ) + except ImportError as e: + raise RuntimeError( + "pipecat-ai is not installed. `pip install pipecat-ai` to use this adapter." + ) from e + + observer = self + + class _VconFrameProcessor(FrameProcessor): + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, TranscriptionFrame): + observer.record_user_transcript(frame.text) + elif isinstance(frame, LLMTextFrame): + observer.record_assistant_text_chunk(frame.text) + elif isinstance(frame, LLMFullResponseEndFrame): + observer.finalize_assistant_turn() + elif isinstance(frame, EndFrame): + observer.end() + await self.push_frame(frame, direction) + + return _VconFrameProcessor() diff --git a/adapters/pipecat/webhook.py b/adapters/pipecat/webhook.py new file mode 100644 index 0000000..c133fd2 --- /dev/null +++ b/adapters/pipecat/webhook.py @@ -0,0 +1,33 @@ +"""Minimal FastAPI app for the Pipecat adapter. + +Pipecat is not a webhook sender: there is no inbound event to receive (see +`config.py`). This module exists so `python main.py pipecat` and the Docker +image behave like every other adapter — health check included, no +credentials required — while the real integration point is importing +`VconConversationObserver` (see `observer.py`) into a Pipecat pipeline +running elsewhere. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from .config import PipecatConfig + + +def create_app(config: PipecatConfig) -> FastAPI: + app = FastAPI( + title="vCon Pipecat Adapter", + description=( + "Pipecat has no inbound webhook. Import " + "adapters.pipecat.VconConversationObserver into your Pipecat " + "pipeline to emit vCons; this process only exposes a health check." + ), + version="0.1.0", + ) + + @app.get("/health") + async def health_check(): + return {"status": "healthy", "service": "vcon-pipecat-adapter"} + + return app From d19c44564bad809258f5519483aae7a62341ede0 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:42:13 -0400 Subject: [PATCH 4/9] feat(elevenlabs): add ElevenLabs adapter Webhook-based, not a poller: ElevenLabs Conversational AI sends post_call_transcription and post_call_audio as independent webhook calls, authenticated with an HMAC signature in elevenlabs-signature. The earlier rescue/cursor-multi-mode-wip draft polled GET /convai/conversations instead; the documented webhook is the more direct integration (no poll lag, richer payload), so this version receives it directly like every other webhook adapter here, and fails closed the same way (config raises at startup unless the secret is set or ALLOW_UNSIGNED_WEBHOOKS=true). Signature format caveat: ElevenLabs' public docs confirm the header name and that it's an HMAC signature with a timestamp, but not the exact signed-string byte format. This implements the widely-used t=,v0= convention the header shape resembles; flagged in the README as unverified against a real signed request. A mismatch fails closed (rejects), it does not silently accept. Because the two webhook types arrive independently, this builds one vCon per webhook rather than merging them, tagged with a shared conversation_id for downstream correlation. Wired to core.lawful_basis and core.media_publisher; written directly against vcon-lib rather than core.base_builder.BaseVconBuilder for the same reason as the VAPI/Pipecat builders. Co-Authored-By: Claude Opus 5.5 --- adapters/elevenlabs/__init__.py | 11 ++ adapters/elevenlabs/builder.py | 278 ++++++++++++++++++++++++++++++++ adapters/elevenlabs/config.py | 69 ++++++++ adapters/elevenlabs/webhook.py | 150 +++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 adapters/elevenlabs/__init__.py create mode 100644 adapters/elevenlabs/builder.py create mode 100644 adapters/elevenlabs/config.py create mode 100644 adapters/elevenlabs/webhook.py diff --git a/adapters/elevenlabs/__init__.py b/adapters/elevenlabs/__init__.py new file mode 100644 index 0000000..a583e40 --- /dev/null +++ b/adapters/elevenlabs/__init__.py @@ -0,0 +1,11 @@ +"""ElevenLabs adapter for vCon telephony adapters.""" + +from .builder import ElevenLabsVconBuilder +from .config import ElevenLabsConfig +from .webhook import create_app + +__all__ = [ + "ElevenLabsConfig", + "ElevenLabsVconBuilder", + "create_app", +] diff --git a/adapters/elevenlabs/builder.py b/adapters/elevenlabs/builder.py new file mode 100644 index 0000000..38cc69f --- /dev/null +++ b/adapters/elevenlabs/builder.py @@ -0,0 +1,278 @@ +"""vCon builder for ElevenLabs Conversational AI post-call webhooks. + +ElevenLabs sends two independent webhook types for a finished conversation +(https://elevenlabs.io/docs/conversational-ai/workflows/post-call-webhooks): + +* `post_call_transcription` — `data.transcript[]` (per-turn role/message), + `data.metadata` (start time, duration, cost), `data.analysis` (summary, + call_successful). No audio. +* `post_call_audio` — `data.full_audio`, a base64-encoded MP3, plus only + `agent_id`/`conversation_id`. No transcript or analysis. Delivered as a + separate webhook call, not merged with the transcription payload. + +Because the two arrive independently, this builder emits one vCon per +webhook rather than trying to merge them into a single vCon keyed on +`conversation_id`; both vCons carry a `conversation_id` tag so they can be +correlated downstream. This mirrors the rest of the monorepo (one inbound +event -> one vCon) rather than the polled, single-vCon-per-call shape the +earlier `rescue/cursor-multi-mode-wip` draft used. + +Like the VAPI and Pipecat builders, this is written directly against +vcon-lib rather than `core.base_builder.BaseVconBuilder`, but follows the +same conventions: `LawfulBasisConfig`, the configured `AudioPublisher`, +`mediatype`, and the draft-04 attachment-field backfill. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import logging +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from vcon import Vcon +from vcon.dialog import Dialog +from vcon.party import Party + +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import AudioPublisher, PublishingError + +logger = logging.getLogger(__name__) + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sha512_content_hash(data: bytes) -> str: + return "sha512-" + _base64url(hashlib.sha512(data).digest()) + + +def _epoch_to_iso(epoch_seconds: Any) -> str | None: + if epoch_seconds is None: + return None + try: + return datetime.fromtimestamp(float(epoch_seconds), tz=timezone.utc).isoformat() + except (ValueError, TypeError, OSError): + return None + + +def _strip_empty_placeholders(d: dict, keys: tuple = ("meta", "metadata")) -> None: + for k in keys: + if k in d and d[k] == {}: + del d[k] + + +class ElevenLabsVconBuilder: + """Builds vCons from ElevenLabs post-call webhook payloads.""" + + ADAPTER_SOURCE = "elevenlabs" + + def __init__( + self, + *, + publisher: AudioPublisher | None = None, + lawful_basis: LawfulBasisConfig | None = None, + ): + self.publisher = publisher + self.lawful_basis = lawful_basis + + def build(self, event: dict[str, Any]) -> Vcon | None: + """Build a vCon from one `post_call_transcription` or `post_call_audio` + webhook event. Returns None for any other event type.""" + event_type = event.get("type") + data = event.get("data") or {} + + if event_type == "post_call_transcription": + return self._build_transcription(event, data) + if event_type == "post_call_audio": + return self._build_audio(event, data) + + logger.debug(f"Ignoring ElevenLabs event type: {event_type}") + return None + + # ------------------------------------------------------------------ + def _build_transcription(self, event: dict[str, Any], data: dict[str, Any]) -> Vcon | None: + try: + vcon = Vcon.build_new() + + metadata = data.get("metadata") or {} + start_iso = _epoch_to_iso( + metadata.get("start_time_unix_secs") or event.get("event_timestamp") + ) + if start_iso: + vcon.vcon_dict["created_at"] = start_iso + + vcon.add_party(Party(role="user")) + vcon.add_party(Party(name="Agent", role="agent")) + + for turn in data.get("transcript", []) or []: + role = turn.get("role") + if role not in ("user", "agent"): + continue + turn_offset = turn.get("time_in_call_secs") + turn_start = start_iso + if start_iso is not None and turn_offset is not None: + try: + base = _epoch_to_iso(metadata.get("start_time_unix_secs")) + if base: + base_epoch = float(metadata["start_time_unix_secs"]) + turn_start = _epoch_to_iso(base_epoch + float(turn_offset)) + except (TypeError, ValueError): + pass + vcon.add_dialog( + Dialog( + type="text", + start=turn_start, + parties=[0, 1], + originator=0 if role == "user" else 1, + body=turn.get("message", "") or "", + encoding="none", + mediatype="text/plain", + ) + ) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + analysis = data.get("analysis") or {} + if analysis.get("transcript_summary"): + vcon.add_analysis( + type="summary", + dialog=0, + vendor="elevenlabs", + product="elevenlabs-conversational-ai", + body=analysis["transcript_summary"], + encoding="none", + ) + if "call_successful" in analysis: + vcon.add_analysis( + type="success_evaluation", + dialog=0, + vendor="elevenlabs", + body=json.dumps({"value": analysis["call_successful"]}), + encoding="json", + ) + + call_record = { + k: v + for k, v in { + "durationSeconds": metadata.get("call_duration_secs"), + "cost": metadata.get("cost"), + "status": data.get("status"), + "userId": data.get("user_id"), + }.items() + if v is not None + } + if call_record: + vcon.add_attachment( + purpose="call_record", + body=json.dumps(call_record), + encoding="json", + party=0, + dialog=0, + ) + + self._finish(vcon, data) + return vcon + + except Exception as e: # noqa: BLE001 + logger.error(f"Error building ElevenLabs transcription vCon: {e}") + return None + + def _build_audio(self, event: dict[str, Any], data: dict[str, Any]) -> Vcon | None: + try: + audio_b64 = data.get("full_audio") + if not audio_b64: + logger.warning("ElevenLabs post_call_audio event with no full_audio field") + return None + try: + audio_bytes = base64.b64decode(audio_b64) + except (binascii.Error, ValueError) as exc: + logger.error(f"Could not decode ElevenLabs full_audio: {exc}") + return None + + vcon = Vcon.build_new() + start_iso = _epoch_to_iso(event.get("event_timestamp")) + if start_iso: + vcon.vcon_dict["created_at"] = start_iso + + vcon.add_party(Party(role="user")) + vcon.add_party(Party(name="Agent", role="agent")) + + conversation_id = data.get("conversation_id") or "elevenlabs-conversation" + filename = f"{conversation_id}.mp3" + dialog_kwargs: dict[str, Any] = { + "type": "recording", + "start": start_iso, + "parties": [0, 1], + "mediatype": "audio/mpeg", + } + + if self.publisher is not None: + published = self._publish(audio_bytes, filename) + if published is not None: + dialog_kwargs["url"] = published[0] + dialog_kwargs["content_hash"] = published[1] + dialog_kwargs["filename"] = filename + else: + logger.error( + "Publishing failed for ElevenLabs conversation %s; emitting no " + "recording dialog rather than a link that will not resolve", + conversation_id, + ) + return None + else: + dialog_kwargs["body"] = _base64url(audio_bytes) + dialog_kwargs["encoding"] = "base64url" + dialog_kwargs["content_hash"] = _sha512_content_hash(audio_bytes) + dialog_kwargs["filename"] = filename + + vcon.add_dialog(Dialog(**dialog_kwargs)) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + self._finish(vcon, data) + return vcon + + except Exception as e: # noqa: BLE001 + logger.error(f"Error building ElevenLabs audio vCon: {e}") + return None + + # ------------------------------------------------------------------ + def _finish(self, vcon: Vcon, data: dict[str, Any]) -> None: + vcon.add_tag("source", self.ADAPTER_SOURCE) + if data.get("conversation_id"): + vcon.add_tag("conversation_id", data["conversation_id"]) + if data.get("agent_id"): + vcon.add_tag("agent_id", data["agent_id"]) + + if self.lawful_basis is None or not self.lawful_basis.apply(vcon): + logger.warning( + "vCon %s carries no lawful_basis attachment. Set LAWFUL_BASIS " + "before handling real conversations.", + vcon.uuid, + ) + + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + if attachment.get("body") is not None: + attachment.setdefault("mediatype", "application/json") + + def _publish(self, audio_bytes: bytes, filename: str) -> tuple[str, str] | None: + tmp_dir = Path(tempfile.mkdtemp(prefix="vcon-media-")) + tmp_path = tmp_dir / filename + try: + tmp_path.write_bytes(audio_bytes) + published = self.publisher.publish(tmp_path, filename) + return published.url, published.content_hash + except PublishingError as exc: + logger.error(f"Publishing ElevenLabs audio failed: {exc}") + return None + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/adapters/elevenlabs/config.py b/adapters/elevenlabs/config.py new file mode 100644 index 0000000..ccbae5e --- /dev/null +++ b/adapters/elevenlabs/config.py @@ -0,0 +1,69 @@ +"""Configuration management for the ElevenLabs adapter. + +ElevenLabs Conversational AI supports post-call webhooks +(https://elevenlabs.io/docs/conversational-ai/guides/webhooks), authenticated +with an HMAC signature in the `elevenlabs-signature` header. This adapter +receives that webhook directly rather than polling the conversations API, the +same shape as every other webhook-based adapter in this monorepo. + +Note: the earlier `rescue/cursor-multi-mode-wip` draft of this adapter polled +`GET /v1/convai/conversations` on an interval instead. ElevenLabs' own +webhook is the more direct integration (no poll lag, richer payload, and it +is the platform's documented mechanism for this), so this version uses it; +`ELEVENLABS_API_KEY` is kept as an optional setting in case a future version +needs to fetch full conversation detail the webhook payload doesn't carry. +""" + +import logging +import os + +from core.base_config import BaseConfig + +logger = logging.getLogger(__name__) + + +class ElevenLabsConfig(BaseConfig): + """ElevenLabs-specific configuration.""" + + def __init__(self, env_file: str | None = None): + super().__init__(env_file) + + self.state_file = os.getenv("STATE_FILE", ".elevenlabs_adapter_state.json") + + # Optional: only needed if a deployment wants to fetch additional + # conversation detail from the API beyond what the webhook carries. + self.api_key = os.getenv("ELEVENLABS_API_KEY") + self.api_base = os.getenv("ELEVENLABS_API_BASE", "https://api.elevenlabs.io/v1") + + # HMAC secret configured in the ElevenLabs dashboard for this webhook. + self.webhook_secret = os.getenv("ELEVENLABS_WEBHOOK_SECRET") + + self.validate_webhook = os.getenv("VALIDATE_ELEVENLABS_WEBHOOK", "true").lower() in ( + "true", + "1", + "yes", + ) + + # Reject webhooks whose timestamp is older than this, to bound replay + # exposure. ElevenLabs' own SDK helpers validate the timestamp too; + # since this adapter verifies the signature by hand (see webhook.py), + # it re-implements that check. + self.webhook_tolerance_seconds = int( + os.getenv("ELEVENLABS_WEBHOOK_TOLERANCE_SECONDS", "1800") + ) + + if self.validate_webhook and not self.webhook_secret: + if self.allow_unsigned_webhooks: + logger.warning( + "VALIDATE_ELEVENLABS_WEBHOOK is enabled but ELEVENLABS_WEBHOOK_SECRET " + "is not set; accepting unsigned webhooks because " + "ALLOW_UNSIGNED_WEBHOOKS=true" + ) + else: + raise ValueError( + "ELEVENLABS_WEBHOOK_SECRET is required when VALIDATE_ELEVENLABS_WEBHOOK " + "is true. Set it to the signing secret shown when the post-call webhook " + "is configured in the ElevenLabs dashboard, or set " + "ALLOW_UNSIGNED_WEBHOOKS=true to accept unsigned webhooks (not " + "recommended)." + ) diff --git a/adapters/elevenlabs/webhook.py b/adapters/elevenlabs/webhook.py new file mode 100644 index 0000000..35f60bf --- /dev/null +++ b/adapters/elevenlabs/webhook.py @@ -0,0 +1,150 @@ +"""FastAPI webhook receiver for ElevenLabs post-call events. + +Signature scheme: UNVERIFIED exact byte format. ElevenLabs' docs +(https://elevenlabs.io/docs/conversational-ai/guides/webhooks) confirm the +header name (`elevenlabs-signature`) and that it is an HMAC signature with a +timestamp, verified in their SDKs via +`elevenlabs.webhooks.construct_event(raw_body, sig_header, secret)`, but the +public docs available to this change do not spell out the signed-string +format, separator, or hash encoding. This implementation follows the +widely-used Stripe-style convention their header shape (`t=,v0=`) +strongly resembles: HMAC-SHA256 over `f"{timestamp}.{body}"`, hex-encoded, +constant-time compared. Verify this against a real signed request (or the +`elevenlabs` Python SDK's `webhooks.construct_event`) before relying on it in +production; if it's wrong, this fails closed (rejects), it does not silently +accept. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import time + +from fastapi import FastAPI, Header, HTTPException, Request + +from core.poster import HttpPoster +from core.tracker import StateTracker + +from .builder import ElevenLabsVconBuilder +from .config import ElevenLabsConfig + +logger = logging.getLogger(__name__) + + +def _parse_signature_header(header_value: str) -> tuple[str | None, str | None]: + """Parse a `t=,v0=` style header.""" + timestamp = None + signature = None + for part in header_value.split(","): + part = part.strip() + if part.startswith("t="): + timestamp = part[2:] + elif part.startswith("v0="): + signature = part[3:] + return timestamp, signature + + +def create_app(config: ElevenLabsConfig) -> FastAPI: + """Create and configure the FastAPI application for the ElevenLabs adapter.""" + app = FastAPI( + title="vCon ElevenLabs Adapter", + description="Receives ElevenLabs post-call webhooks and creates vCons", + version="0.1.0", + ) + + publisher = config.build_publisher() + if publisher is None: + logger.warning( + "MEDIA_BACKEND=embed: audio will be inlined as base64, making each " + "vCon roughly 1.3x the size of the recording. Set MEDIA_BACKEND=s3 " + "for anything beyond a lab." + ) + lawful_basis = config.build_lawful_basis() + if not lawful_basis.enabled: + logger.warning( + "LAWFUL_BASIS is unset, so vCons will carry no record of why this " + "deployment may hold the recording. Fine for a lab; not for real " + "conversations." + ) + + builder = ElevenLabsVconBuilder(publisher=publisher, lawful_basis=lawful_basis) + poster = HttpPoster(config.conserver_url, config.get_headers(), config.ingress_lists) + tracker = StateTracker(config.state_file) + + def validate_signature(body: bytes, signature_header: str | None) -> bool: + if not config.validate_webhook: + return True + if not config.webhook_secret: + # Only reachable with ALLOW_UNSIGNED_WEBHOOKS=true. + return True + if not signature_header: + return False + + timestamp, signature = _parse_signature_header(signature_header) + if not timestamp or not signature: + return False + + try: + timestamp_int = int(timestamp) + except ValueError: + return False + + if abs(time.time() - timestamp_int) > config.webhook_tolerance_seconds: + logger.warning("ElevenLabs webhook timestamp outside tolerance window") + return False + + signed_payload = f"{timestamp}.".encode() + body + expected = hmac.new( + config.webhook_secret.encode("utf-8"), signed_payload, hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, signature) + + @app.get("/health") + async def health_check(): + return {"status": "healthy", "service": "vcon-elevenlabs-adapter"} + + @app.post("/webhook/post-call") + async def post_call_webhook( + request: Request, + elevenlabs_signature: str | None = Header(default=None), + ): + body = await request.body() + + if not validate_signature(body, elevenlabs_signature): + logger.warning("Invalid ElevenLabs webhook signature") + raise HTTPException(status_code=401, detail="Invalid signature") + + try: + event = await request.json() + except Exception: + logger.error("Failed to parse JSON body") + raise HTTPException(status_code=400, detail="Invalid JSON") from None + + event_type = event.get("type") + data = event.get("data") or {} + conversation_id = data.get("conversation_id") + + dedupe_key = f"{conversation_id}:{event_type}" if conversation_id else None + if dedupe_key and tracker.is_processed(dedupe_key): + logger.info(f"ElevenLabs event {dedupe_key} already processed, skipping") + return {"status": "already_processed"} + + vcon = builder.build(event) + if vcon is None: + return {"status": "ignored"} + + success = poster.post(vcon) + if dedupe_key: + tracker.mark_processed( + dedupe_key, + vcon.uuid, + status="success" if success else "post_failed", + ) + + if success: + return {"status": "ok", "uuid": vcon.uuid} + return {"status": "post_failed", "uuid": vcon.uuid} + + return app From 44d4735b3657dc89b3b8326fecf89e3556d5ca14 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:42:21 -0400 Subject: [PATCH 5/9] feat(signalwire): add SignalWire adapter Poller, not a webhook receiver: SignalWire's Compatibility API has no "recording complete" push, so this polls GET /Recordings.json on an interval. SIGNALWIRE_AUTH_TOKEN is outbound Basic Auth to SignalWire's own API, not a webhook secret, so there is no VALIDATE_*_WEBHOOK setting here. Fixes the dedupe/watermark bug an audit found in the standalone rescue/cursor-multi-mode-wip draft: that poller marked a call processed unconditionally after attempting delivery, including when the POST to the conserver failed, and separately advanced its poll watermark to now() every cycle regardless of outcome -- so a failed delivery was dropped silently, and even if that weren't true, the failed call would fall out of the query window on the very next poll and never be retried at all. This version only marks a call processed (via core.tracker.StateTracker) once HttpPoster.post() returns True, and persists a watermark (.watermark) that never advances past a call still pending, so a failed build or post leaves that call inside the next poll's fetch window until it succeeds. One vCon per call with one dialog per recording (SignalWire can return several recordings per call in one poll), wired to core.lawful_basis and core.media_publisher, written directly against vcon-lib rather than core.base_builder.BaseVconBuilder for the same multi-dialog reason as the other three new adapters. `python main.py signalwire` runs the poller on a background thread behind a /health endpoint, matching the container healthcheck the other adapters get for free from being webhook receivers. Co-Authored-By: Claude Opus 5.5 --- adapters/signalwire/__init__.py | 14 ++ adapters/signalwire/builder.py | 220 ++++++++++++++++++++++++++++++++ adapters/signalwire/config.py | 41 ++++++ adapters/signalwire/poller.py | 214 +++++++++++++++++++++++++++++++ adapters/signalwire/webhook.py | 43 +++++++ 5 files changed, 532 insertions(+) create mode 100644 adapters/signalwire/__init__.py create mode 100644 adapters/signalwire/builder.py create mode 100644 adapters/signalwire/config.py create mode 100644 adapters/signalwire/poller.py create mode 100644 adapters/signalwire/webhook.py diff --git a/adapters/signalwire/__init__.py b/adapters/signalwire/__init__.py new file mode 100644 index 0000000..2edb80c --- /dev/null +++ b/adapters/signalwire/__init__.py @@ -0,0 +1,14 @@ +"""SignalWire adapter: polls the SignalWire Compatibility API for new recordings.""" + +from .builder import SignalWireRecordingData, SignalWireVconBuilder +from .config import SignalWireConfig +from .poller import SignalWirePoller +from .webhook import create_app + +__all__ = [ + "SignalWireConfig", + "SignalWirePoller", + "SignalWireRecordingData", + "SignalWireVconBuilder", + "create_app", +] diff --git a/adapters/signalwire/builder.py b/adapters/signalwire/builder.py new file mode 100644 index 0000000..9dbf66f --- /dev/null +++ b/adapters/signalwire/builder.py @@ -0,0 +1,220 @@ +"""SignalWire vCon builder. + +SignalWire is polled (see `poller.py`), and one poll cycle can return +multiple Recording objects for the same call, so this builder produces a +single vCon with multiple dialogs rather than one vCon per recording. That +shape doesn't fit `core.base_builder.BaseVconBuilder` (single recording, +single download), so, like the VAPI/Pipecat/ElevenLabs builders, it is +written directly against vcon-lib but follows the same conventions: +`LawfulBasisConfig` for the lawful-basis attachment, the configured +`AudioPublisher` for re-hosting audio instead of always embedding it, +`mediatype` throughout, and the draft-04 attachment-field backfill. +""" + +from __future__ import annotations + +import email.utils +import json +import logging +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests +from vcon import Vcon +from vcon.dialog import Dialog +from vcon.party import Party + +from core.lawful_basis import LawfulBasisConfig +from core.media_publisher import AudioPublisher, PublishingError + +logger = logging.getLogger(__name__) + + +@dataclass +class SignalWireRecordingData: + """Normalized representation of a SignalWire poller result for one call.""" + + call_meta: dict[str, Any] + recordings: list[dict[str, Any]] + transcriptions_by_recording_sid: dict[str, list[dict[str, Any]]] + + @property + def call_sid(self) -> str: + return self.call_meta.get("sid", "") + + +def _format_to_e164(phone_number: str | None) -> str | None: + """Strip non-digits and prepend '+' (US-biased, matching SignalWire's + Twilio-compatible number formatting).""" + if not phone_number: + return None + digits = "".join(c for c in str(phone_number) if c.isdigit()) + if not digits: + return None + if len(digits) == 10: + digits = "1" + digits + return f"+{digits}" + + +def _parse_rfc2822_to_iso(value: str | None) -> str | None: + if not value: + return None + try: + return email.utils.parsedate_to_datetime(value).isoformat() + except (TypeError, ValueError): + logger.warning(f"Failed to parse SignalWire date_created: {value!r}") + return None + + +def _strip_empty_placeholders(d: dict, keys: tuple = ("meta", "metadata")) -> None: + for k in keys: + if k in d and d[k] == {}: + del d[k] + + +class SignalWireVconBuilder: + """Builds one vCon per SignalWire call, with one dialog per recording.""" + + ADAPTER_SOURCE = "signalwire" + TRANSCRIPT_SCHEMA = "https://docs.signalwire.com/reference/compatibility-api/v1/transcriptions" + + def __init__( + self, + *, + download_recordings: bool = True, + api_auth: tuple[str, str] | None = None, + publisher: AudioPublisher | None = None, + lawful_basis: LawfulBasisConfig | None = None, + ): + self.download_recordings = download_recordings + self.api_auth = api_auth + self.publisher = publisher + self.lawful_basis = lawful_basis + + def build(self, data: SignalWireRecordingData) -> Vcon | None: + """Produce one vCon containing all of the call's recordings as dialogs.""" + try: + call_meta = data.call_meta + + vcon = Vcon.build_new() + vcon.add_party(Party(tel=_format_to_e164(call_meta.get("to_formatted")))) + vcon.add_party(Party(tel=_format_to_e164(call_meta.get("from_formatted")))) + + for dialog_idx, recording in enumerate(data.recordings): + self._add_recording(vcon, recording, dialog_idx) + + for transcription in data.transcriptions_by_recording_sid.get( + recording.get("sid", ""), [] + ): + if "text" in transcription: + vcon.add_analysis( + type="transcript", + dialog=dialog_idx, + vendor="signalwire", + product="signalwire-transcription", + schema=self.TRANSCRIPT_SCHEMA, + body=json.dumps(transcription), + encoding="json", + ) + + vcon.add_tag("source", self.ADAPTER_SOURCE) + if data.call_sid: + vcon.add_tag("call_sid", data.call_sid) + + if self.lawful_basis is None or not self.lawful_basis.apply(vcon): + logger.warning( + "vCon %s carries no lawful_basis attachment. Set LAWFUL_BASIS " + "before handling real conversations.", + vcon.uuid, + ) + + for attachment in vcon.vcon_dict.get("attachments", []): + attachment.setdefault("start", vcon.created_at) + attachment.setdefault("party", 0) + attachment.setdefault("dialog", 0) + if attachment.get("body") is not None: + attachment.setdefault("mediatype", "application/json") + + return vcon + + except Exception as e: # noqa: BLE001 + logger.error(f"Error building SignalWire vCon: {e}") + return None + + def _add_recording(self, vcon: Vcon, recording: dict[str, Any], dialog_idx: int) -> None: + start_iso = _parse_rfc2822_to_iso(recording.get("date_created")) + recording_url = recording.get("recording_url") or recording.get("uri") + recording_sid = recording.get("sid", f"recording-{dialog_idx}") + filename = f"{recording_sid}.wav" + + dialog_kwargs: dict[str, Any] = { + "start": start_iso, + "parties": [0, 1], + "type": "recording", + "duration": recording.get("duration"), + "mediatype": "audio/wav", + } + + if recording_url and self.publisher is not None: + published = self._download_and_publish(recording_url, filename) + if published is not None: + dialog_kwargs["url"] = published[0] + dialog_kwargs["content_hash"] = published[1] + dialog_kwargs["filename"] = filename + else: + logger.error( + "Publishing failed for SignalWire recording %s; emitting no url " + "rather than a link that will not resolve", + recording_sid, + ) + elif recording_url: + dialog_kwargs["url"] = recording_url + if self.download_recordings: + logger.debug( + "DOWNLOAD_RECORDINGS is set but no publisher is configured; " + "SignalWire dialogs reference the recording URL directly " + "(the recording metadata attachment records the source)." + ) + + vcon.add_dialog(Dialog(**dialog_kwargs)) + _strip_empty_placeholders(vcon.vcon_dict["dialog"][-1]) + + vcon.add_attachment( + purpose="recording_metadata", + body=json.dumps( + { + "sid": recording.get("sid"), + "account_sid": recording.get("account_sid"), + "call_sid": recording.get("call_sid"), + "channels": recording.get("channels"), + "source": "SignalWire", + } + ), + encoding="json", + party=0, + dialog=dialog_idx, + ) + + def _download_and_publish(self, recording_url: str, filename: str) -> tuple[str, str] | None: + try: + response = requests.get(recording_url, auth=self.api_auth, timeout=60) + response.raise_for_status() + audio_bytes = response.content + except requests.RequestException as e: + logger.error(f"Failed to download SignalWire recording: {e}") + return None + + tmp_dir = Path(tempfile.mkdtemp(prefix="vcon-media-")) + tmp_path = tmp_dir / filename + try: + tmp_path.write_bytes(audio_bytes) + published = self.publisher.publish(tmp_path, filename) + return published.url, published.content_hash + except PublishingError as exc: + logger.error(f"Publishing SignalWire recording failed: {exc}") + return None + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) diff --git a/adapters/signalwire/config.py b/adapters/signalwire/config.py new file mode 100644 index 0000000..43f493a --- /dev/null +++ b/adapters/signalwire/config.py @@ -0,0 +1,41 @@ +"""Configuration for the SignalWire adapter. + +SignalWire's Compatibility API doesn't push a recording-complete webhook the +way Twilio does (there is a status-callback per call, but no equivalent for +"a new recording exists" across a poll window), so this adapter polls +`GET /Recordings.json` on an interval instead of receiving a webhook. There +is therefore no inbound request to authenticate; `SIGNALWIRE_AUTH_TOKEN` is +outbound Basic Auth to SignalWire's own API, not a webhook secret. +""" + +import os + +from core.base_config import BaseConfig + + +class SignalWireConfig(BaseConfig): + """SignalWire-specific configuration.""" + + def __init__(self, env_file: str | None = None): + super().__init__(env_file) + + self.state_file = os.getenv("STATE_FILE", ".signalwire_adapter_state.json") + + self.project_id = os.getenv("SIGNALWIRE_PROJECT_ID") + self.auth_token = os.getenv("SIGNALWIRE_AUTH_TOKEN") + self.space_url = os.getenv("SIGNALWIRE_SPACE_URL") + + if not self.project_id or not self.auth_token or not self.space_url: + raise ValueError( + "SIGNALWIRE_PROJECT_ID, SIGNALWIRE_AUTH_TOKEN and SIGNALWIRE_SPACE_URL " + "are all required" + ) + + self.poll_interval_seconds = int(os.getenv("SIGNALWIRE_POLL_INTERVAL_SECONDS", "300")) + self.retention_days = int(os.getenv("SIGNALWIRE_RETENTION_DAYS", "30")) + + def api_base(self) -> str: + return f"{self.space_url.rstrip('/')}/api/laml/2010-04-01/Accounts/{self.project_id}" + + def api_auth(self) -> tuple[str, str]: + return (self.project_id, self.auth_token) diff --git a/adapters/signalwire/poller.py b/adapters/signalwire/poller.py new file mode 100644 index 0000000..4a07929 --- /dev/null +++ b/adapters/signalwire/poller.py @@ -0,0 +1,214 @@ +"""SignalWire poller: fetches new recordings on a schedule and ships vCons. + +SignalWire doesn't push a webhook for "a recording now exists" the way +Twilio does, so this polls the Compatibility API on an interval. + +Dedupe/watermark safety (CON-703): the standalone `rescue/cursor-multi-mode-wip` +draft of this poller had two related bugs an audit flagged: + +1. It marked a call's recordings as processed (`processed[call_sid] = ...`) + unconditionally after attempting to post, including when the POST to the + conserver failed — so a failed delivery was silently dropped forever + instead of being retried. +2. Its poll watermark (`last_check_time`) advanced to `now()` every cycle + regardless of outcome, so even if (1) were fixed, a failed call's + recording would fall out of the `DateCreated>` query window on the very + next poll and never be retried at all. + +This version fixes both: `core.tracker.StateTracker` only marks a call +processed once `HttpPoster.post()` returns True (a build failure or a post +failure leaves it unmarked, logged as a warning, so the next cycle retries +it), and the watermark persisted to `.watermark` only advances +past calls that were *successfully* shipped or exhausted — it never jumps +ahead of a call still pending, so that call stays inside the poll window +until it either succeeds or is dropped by the operator's own investigation. +""" + +from __future__ import annotations + +import json +import logging +import signal +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import requests + +from core.poster import HttpPoster +from core.tracker import StateTracker + +from .builder import SignalWireRecordingData, SignalWireVconBuilder +from .config import SignalWireConfig + +logger = logging.getLogger(__name__) + + +class SignalWirePoller: + """Long-running poller that fetches new SignalWire recordings and ships vCons.""" + + def __init__( + self, + config: SignalWireConfig, + builder: SignalWireVconBuilder | None = None, + poster: HttpPoster | None = None, + tracker: StateTracker | None = None, + ): + self.config = config + self.builder = builder or SignalWireVconBuilder( + download_recordings=config.download_recordings, + api_auth=config.api_auth(), + publisher=config.build_publisher(), + lawful_basis=config.build_lawful_basis(), + ) + self.poster = poster or HttpPoster( + config.conserver_url, config.get_headers(), config.ingress_lists + ) + self.tracker = tracker or StateTracker(config.state_file) + self._watermark_file = Path(f"{config.state_file}.watermark") + self._running = True + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + def run(self) -> None: + """Poll until SIGINT / SIGTERM.""" + signal.signal(signal.SIGINT, self._stop) + signal.signal(signal.SIGTERM, self._stop) + + while self._running: + try: + self.process() + except Exception: + logger.exception("SignalWire poll cycle failed") + self._sleep(self.config.poll_interval_seconds) + + # ------------------------------------------------------------------ + # One poll cycle (extracted so tests can drive a single iteration) + # ------------------------------------------------------------------ + def process(self) -> int: + """Fetch + ship vCons for calls not yet processed. Returns count shipped.""" + since = self._load_watermark() + recordings = self._fetch_recordings_since(since) + + by_call: dict[str, list[dict[str, Any]]] = {} + for r in recordings: + sid = r.get("call_sid") + if not sid or self.tracker.is_processed(sid): + continue + by_call.setdefault(sid, []).append(r) + + shipped = 0 + earliest_unresolved: datetime | None = None + now = datetime.now(UTC) + + for call_sid, group in by_call.items(): + call_meta = self._fetch_call_meta(call_sid) + if not call_meta: + earliest_unresolved = self._earlier(earliest_unresolved, since) + continue + + transcripts = {r.get("sid", ""): self._fetch_transcriptions(r) for r in group} + data = SignalWireRecordingData( + call_meta=call_meta, + recordings=group, + transcriptions_by_recording_sid=transcripts, + ) + vcon = self.builder.build(data) + if vcon is None: + logger.warning(f"Failed to build vCon for SignalWire call {call_sid}; will retry") + earliest_unresolved = self._earlier(earliest_unresolved, since) + continue + + if self.poster.post(vcon): + self.tracker.mark_processed(call_sid, vcon.uuid, status="success") + shipped += 1 + else: + logger.warning( + f"Failed to post vCon {vcon.uuid} for SignalWire call {call_sid}; " + "not marking processed, will retry" + ) + earliest_unresolved = self._earlier(earliest_unresolved, since) + + new_watermark = earliest_unresolved if earliest_unresolved is not None else now + self._save_watermark(new_watermark) + return shipped + + # ------------------------------------------------------------------ + # SignalWire API + # ------------------------------------------------------------------ + def _fetch_recordings_since(self, since: datetime) -> list[dict]: + url = f"{self.config.api_base()}/Recordings.json" + params = {"DateCreated>": since.strftime("%Y-%m-%d")} + try: + r = requests.get(url, auth=self.config.api_auth(), params=params, timeout=30) + r.raise_for_status() + return r.json().get("recordings", []) + except requests.RequestException as e: + logger.error(f"SignalWire recordings fetch failed: {e}") + return [] + + def _fetch_call_meta(self, call_sid: str) -> dict | None: + url = f"{self.config.api_base()}/Calls/{call_sid}.json" + try: + r = requests.get(url, auth=self.config.api_auth(), timeout=30) + except requests.RequestException as e: + logger.warning(f"SignalWire call meta fetch failed for {call_sid}: {e}") + return None + if r.status_code != 200: + logger.warning(f"SignalWire call meta fetch failed for {call_sid}: {r.status_code}") + return None + return r.json() + + def _fetch_transcriptions(self, recording: dict) -> list[dict]: + sub = recording.get("subresource_uris") or {} + if "transcriptions" not in sub: + return [] + url = f"{self.config.space_url.rstrip('/')}{sub['transcriptions']}" + try: + r = requests.get(url, auth=self.config.api_auth(), timeout=30) + except requests.RequestException as e: + logger.warning(f"SignalWire transcription fetch failed: {e}") + return [] + if r.status_code != 200: + return [] + return r.json().get("transcriptions", []) + + # ------------------------------------------------------------------ + # Watermark persistence + # ------------------------------------------------------------------ + def _load_watermark(self) -> datetime: + if self._watermark_file.exists(): + try: + raw = json.loads(self._watermark_file.read_text()) + return datetime.fromisoformat(raw["watermark"]) + except (json.JSONDecodeError, KeyError, ValueError, OSError) as e: + logger.warning(f"Could not read SignalWire watermark file: {e}; starting fresh") + return datetime.now(UTC) - timedelta(seconds=self.config.poll_interval_seconds) + + def _save_watermark(self, watermark: datetime) -> None: + try: + self._watermark_file.write_text(json.dumps({"watermark": watermark.isoformat()})) + except OSError as e: + logger.error(f"Could not save SignalWire watermark file: {e}") + + @staticmethod + def _earlier(a: datetime | None, b: datetime) -> datetime: + if a is None or b < a: + return b + return a + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def _stop(self, *_args) -> None: + logger.info("Received stop signal") + self._running = False + + def _sleep(self, seconds: int) -> None: + # Broken into 1s ticks so SIGTERM is responsive. + for _ in range(seconds): + if not self._running: + return + time.sleep(1) diff --git a/adapters/signalwire/webhook.py b/adapters/signalwire/webhook.py new file mode 100644 index 0000000..dc0b28d --- /dev/null +++ b/adapters/signalwire/webhook.py @@ -0,0 +1,43 @@ +"""FastAPI app for the SignalWire adapter. + +SignalWire is polled, not pushed to (see `config.py` and `poller.py`), so +there is no webhook endpoint here. This module exists so `python main.py +signalwire` and the Docker image behave like every other adapter: a +`/health` endpoint the container HEALTHCHECK can probe, with the poller +itself running on a background thread started at app startup. +""" + +from __future__ import annotations + +import logging +import threading + +from fastapi import FastAPI + +from .config import SignalWireConfig +from .poller import SignalWirePoller + +logger = logging.getLogger(__name__) + + +def create_app(config: SignalWireConfig) -> FastAPI: + app = FastAPI( + title="vCon SignalWire Adapter", + description="Polls SignalWire for new recordings and creates vCons", + version="0.1.0", + ) + + poller = SignalWirePoller(config) + app.state.poller = poller + + @app.on_event("startup") + def _start_poller() -> None: + thread = threading.Thread(target=poller.run, name="signalwire-poller", daemon=True) + thread.start() + logger.info(f"SignalWire poller started (interval={config.poll_interval_seconds}s)") + + @app.get("/health") + async def health_check(): + return {"status": "healthy", "service": "vcon-signalwire-adapter"} + + return app From 13089f561e66d0761a41d44cd085ea4655a7dec9 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:42:28 -0400 Subject: [PATCH 6/9] docs: register vapi/pipecat/elevenlabs/signalwire in main.py and README Adds each adapter's run_*_adapter() to main.py's ADAPTERS registry so `python main.py ` and the Docker image (ENTRYPOINT/CMD unchanged) work for all four. Documents each in the README's platform list, adds a config-table section per adapter (required vars, validation defaults, signature-scheme caveats), updates the source tree, notes the webhook vs. poller vs. in-process split in the "Webhook authentication" section, and adds their env vars to .env.example. Co-Authored-By: Claude Opus 5.5 --- .env.example | 58 ++++++++++++++ README.md | 213 +++++++++++++++++++++++++++++++++++++++++++++++++-- main.py | 106 +++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 36f3220..3666b6f 100644 --- a/.env.example +++ b/.env.example @@ -73,3 +73,61 @@ RECORDING_FORMAT=wav # Path to the state file for tracking processed recordings # This prevents duplicate processing of the same recording STATE_FILE=.twilio_adapter_state.json + +# ----------------------------------------------------------------------------- +# VAPI CONFIGURATION (adapter: vapi) +# ----------------------------------------------------------------------------- + +# Shared secret VAPI echoes back in the x-vapi-secret header on every +# server-URL request. Required unless ALLOW_UNSIGNED_WEBHOOKS=true. +VAPI_WEBHOOK_SECRET= + +# Whether to validate the VAPI webhook secret (default: true) +VALIDATE_VAPI_WEBHOOK=true + +# Display names for the two parties VAPI doesn't name explicitly +VAPI_CUSTOMER_NAME=Customer +VAPI_AGENT_NAME=AI Agent + +# ----------------------------------------------------------------------------- +# PIPECAT CONFIGURATION (adapter: pipecat) +# ----------------------------------------------------------------------------- +# Pipecat has no inbound webhook; adapters.pipecat.VconConversationObserver is +# imported directly into a Pipecat pipeline. `python main.py pipecat` only +# starts a health-check server. No adapter-specific variables beyond the +# shared server/conserver/media/lawful-basis settings above. + +# ----------------------------------------------------------------------------- +# ELEVENLABS CONFIGURATION (adapter: elevenlabs) +# ----------------------------------------------------------------------------- + +# HMAC signing secret shown when the post-call webhook is configured in the +# ElevenLabs dashboard. Required unless ALLOW_UNSIGNED_WEBHOOKS=true. +ELEVENLABS_WEBHOOK_SECRET= + +# Whether to validate the elevenlabs-signature header (default: true) +VALIDATE_ELEVENLABS_WEBHOOK=true + +# How old a webhook's timestamp may be before it's rejected as a possible +# replay, in seconds (default: 1800) +ELEVENLABS_WEBHOOK_TOLERANCE_SECONDS=1800 + +# Optional: only needed to fetch additional conversation detail from the API +ELEVENLABS_API_KEY= +ELEVENLABS_API_BASE=https://api.elevenlabs.io/v1 + +# ----------------------------------------------------------------------------- +# SIGNALWIRE CONFIGURATION (adapter: signalwire) +# ----------------------------------------------------------------------------- +# SignalWire is polled, not pushed to: these are outbound API credentials, +# not a webhook secret. All three are required. + +SIGNALWIRE_PROJECT_ID= +SIGNALWIRE_AUTH_TOKEN= +SIGNALWIRE_SPACE_URL=https://your-space.signalwire.com + +# How often to poll for new recordings, in seconds (default: 300) +SIGNALWIRE_POLL_INTERVAL_SECONDS=300 + +# How long to retain processed-call dedupe state, in days (default: 30) +SIGNALWIRE_RETENTION_DAYS=30 diff --git a/README.md b/README.md index 32e4d91..b18a3a0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ This project provides a unified framework for converting call recordings from va - **Asterisk** - Convert Asterisk recordings via ARI events - **Telnyx** - Convert Telnyx call recordings via webhooks - **Bandwidth** - Convert Bandwidth call recordings via webhooks +- **VAPI** - Convert VAPI voice-AI end-of-call reports via webhooks +- **Pipecat** - Emit vCons from a running Pipecat voice-AI pipeline (in-process observer, no webhook) +- **ElevenLabs** - Convert ElevenLabs Conversational AI post-call events via webhooks +- **SignalWire** - Convert SignalWire call recordings by polling the Compatibility API ## Architecture @@ -30,8 +34,11 @@ vcon-telephony-adapters/ │ ├── freeswitch/ # FreeSWITCH adapter │ ├── asterisk/ # Asterisk adapter │ ├── telnyx/ # Telnyx adapter -│ └── bandwidth/ # Bandwidth adapter -├── twilio_adapter/ # Backwards compatibility layer +│ ├── bandwidth/ # Bandwidth adapter +│ ├── vapi/ # VAPI adapter (webhook) +│ ├── pipecat/ # Pipecat adapter (in-process observer, no webhook) +│ ├── elevenlabs/ # ElevenLabs adapter (webhook) +│ └── signalwire/ # SignalWire adapter (poller) ├── tests/ # Test suite └── main.py # CLI entry point ``` @@ -91,6 +98,18 @@ vcon-adapter telnyx # Bandwidth vcon-adapter bandwidth + +# VAPI +vcon-adapter vapi + +# Pipecat (health check only; see the Pipecat Adapter section below) +vcon-adapter pipecat + +# ElevenLabs +vcon-adapter elevenlabs + +# SignalWire (poller) +vcon-adapter signalwire ``` --- @@ -366,6 +385,185 @@ refuses to start otherwise. See [Webhook authentication](#webhook-authentication --- +## VAPI Adapter + +### Configuration + +```bash +# Required +CONSERVER_URL=https://your-conserver.example.com/api/vcons +VAPI_WEBHOOK_SECRET=your_webhook_secret + +# Optional +PORT=8086 +VALIDATE_VAPI_WEBHOOK=true +VAPI_CUSTOMER_NAME=Customer +VAPI_AGENT_NAME=AI Agent +DOWNLOAD_RECORDINGS=true +``` + +### VAPI Configuration Options + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `VAPI_WEBHOOK_SECRET` | Yes* | - | Shared secret VAPI echoes back in the `x-vapi-secret` header | +| `VALIDATE_VAPI_WEBHOOK` | No | `true` | Enable webhook secret validation | +| `VAPI_CUSTOMER_NAME` | No | `Customer` | Display name for the customer party | +| `VAPI_AGENT_NAME` | No | `AI Agent` | Display name for the agent party | + +\* Required when `VALIDATE_VAPI_WEBHOOK=true` (the default): the adapter refuses to +start otherwise. See [Webhook authentication](#webhook-authentication) below. + +VAPI also offers HMAC-signature and OAuth authentication with a configurable header +name (https://docs.vapi.ai/server-url/server-authentication); this adapter implements +the simpler, documented shared-secret header, not the HMAC variant. + +### Configuring VAPI + +1. In the VAPI dashboard, set your assistant's Server URL to your adapter endpoint: + ``` + https://your-domain.com/vapi + ``` + +2. Configure the same value as `VAPI_WEBHOOK_SECRET` on the server URL's credential. + +--- + +## Pipecat Adapter + +Pipecat (https://github.com/pipecat-ai/pipecat) is a voice-AI pipeline framework, not +a service that calls a webhook: the integration runs inside your own pipeline process. +`python main.py pipecat` / `vcon-adapter pipecat` only starts a `/health` endpoint so +the adapter registers and containerizes like the others. + +### Integration + +```python +from adapters.pipecat import VconConversationObserver + +observer = VconConversationObserver( + conversation_id="call-123", + on_vcon=lambda v: my_poster.post(v), + publisher=config.build_publisher(), + lawful_basis=config.build_lawful_basis(), +) + +pipeline = Pipeline([ + transport.input(), + stt, + observer, # intercepts user transcripts + llm, + observer, # same instance also intercepts LLM output + tts, + transport.output(), +]) +``` + +See `adapters/pipecat/observer.py` for the full frame-processor wiring, including +`as_frame_processor()` for plugging directly into a Pipecat `Pipeline`. + +### Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PIPECAT_USER_NAME` | No | - | Display name for the user party | +| `PIPECAT_AGENT_NAME` | No | `Agent` | Display name for the agent party | + +No webhook-validation variables apply; there is no inbound request to authenticate. + +--- + +## ElevenLabs Adapter + +### Configuration + +```bash +# Required +CONSERVER_URL=https://your-conserver.example.com/api/vcons +ELEVENLABS_WEBHOOK_SECRET=your_webhook_signing_secret + +# Optional +PORT=8087 +VALIDATE_ELEVENLABS_WEBHOOK=true +ELEVENLABS_WEBHOOK_TOLERANCE_SECONDS=1800 +DOWNLOAD_RECORDINGS=true +``` + +### ElevenLabs Configuration Options + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `ELEVENLABS_WEBHOOK_SECRET` | Yes* | - | HMAC signing secret from the ElevenLabs post-call webhook config | +| `VALIDATE_ELEVENLABS_WEBHOOK` | No | `true` | Enable `elevenlabs-signature` HMAC validation | +| `ELEVENLABS_WEBHOOK_TOLERANCE_SECONDS` | No | `1800` | Reject webhooks whose timestamp is older than this | +| `ELEVENLABS_API_KEY` | No | - | Only needed to fetch additional conversation detail from the API | + +\* Required when `VALIDATE_ELEVENLABS_WEBHOOK=true` (the default): the adapter +refuses to start otherwise. See [Webhook authentication](#webhook-authentication) below. + +**Signature verification caveat:** ElevenLabs' public docs confirm the header name +(`elevenlabs-signature`) and that it carries an HMAC signature with a timestamp, but do +not spell out the exact signed-string format. This adapter implements the widely-used +`t=,v0=` convention the header shape resembles. Verify +against a real signed request (or the `elevenlabs` Python SDK's +`webhooks.construct_event`) before relying on it in production; a mismatch fails +closed (rejects the webhook), it does not silently accept. + +### Configuring ElevenLabs + +1. In the ElevenLabs dashboard, configure a post-call webhook for your agent, pointed at: + ``` + https://your-domain.com/webhook/post-call + ``` + +2. Copy the signing secret shown into `ELEVENLABS_WEBHOOK_SECRET`. + +3. ElevenLabs sends `post_call_transcription` and `post_call_audio` as separate + webhook calls; this adapter emits one vCon per call, tagged with the same + `conversation_id` so they can be correlated downstream. + +--- + +## SignalWire Adapter + +SignalWire's Compatibility API doesn't push a "recording complete" webhook, so this +adapter polls `GET /Recordings.json` on an interval instead. + +### Configuration + +```bash +# Required +CONSERVER_URL=https://your-conserver.example.com/api/vcons +SIGNALWIRE_PROJECT_ID=your_project_id +SIGNALWIRE_AUTH_TOKEN=your_auth_token +SIGNALWIRE_SPACE_URL=https://your-space.signalwire.com + +# Optional +PORT=8088 +SIGNALWIRE_POLL_INTERVAL_SECONDS=300 +SIGNALWIRE_RETENTION_DAYS=30 +DOWNLOAD_RECORDINGS=true +``` + +### SignalWire Configuration Options + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `SIGNALWIRE_PROJECT_ID` | Yes | - | SignalWire project ID | +| `SIGNALWIRE_AUTH_TOKEN` | Yes | - | SignalWire API token (outbound API auth, not a webhook secret) | +| `SIGNALWIRE_SPACE_URL` | Yes | - | Your SignalWire space URL | +| `SIGNALWIRE_POLL_INTERVAL_SECONDS` | No | `300` | How often to poll for new recordings | +| `SIGNALWIRE_RETENTION_DAYS` | No | `30` | How long to retain processed-call dedupe state | + +There is no `VALIDATE_*_WEBHOOK` variable for SignalWire: it is a poller, not a +webhook receiver, so there is no inbound request to authenticate. Dedupe state (which +calls have already been shipped) only advances past a call once its vCon has been +built **and** successfully posted to the conserver; a build or post failure leaves the +call unmarked and inside the next poll's fetch window, so it is retried rather than +silently dropped. + +--- + ## Common Configuration All adapters share these common configuration options: @@ -404,10 +602,13 @@ raw JSON value itself (an object or array), not a `json.dumps` string. ## Webhook authentication -Every adapter validates its incoming webhooks by default (`VALIDATE_*_WEBHOOK=true`): -an HMAC secret for Asterisk/FreeSWITCH, Twilio's own signature scheme, Telnyx's Ed25519 -Call Control signature plus a token on the TeXML callback, or HTTP Basic Auth for -Bandwidth, depending on the platform. +Every webhook-based adapter validates its incoming webhooks by default +(`VALIDATE_*_WEBHOOK=true`): an HMAC secret for Asterisk/FreeSWITCH, Twilio's own +signature scheme, Telnyx's Ed25519 Call Control signature plus a token on the TeXML +callback, HTTP Basic Auth for Bandwidth, a shared secret header for VAPI, or an HMAC +signature for ElevenLabs, depending on the platform. SignalWire is a poller, not a +webhook receiver, so it has no `VALIDATE_*_WEBHOOK` setting; Pipecat has no inbound +request at all (the integration runs inside your own process). If validation is enabled but the corresponding secret/key/credentials are not configured, **the adapter refuses to start** rather than silently accepting diff --git a/main.py b/main.py index 174b9ee..a104159 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,10 @@ python main.py asterisk # Run Asterisk adapter python main.py telnyx # Run Telnyx adapter python main.py bandwidth # Run Bandwidth adapter + python main.py vapi # Run VAPI adapter + python main.py pipecat # Run Pipecat adapter (health check only; see adapters/pipecat) + python main.py elevenlabs # Run ElevenLabs adapter + python main.py signalwire # Run SignalWire adapter (poller) python main.py # Default: Run Twilio adapter """ @@ -158,6 +162,104 @@ def run_bandwidth_adapter(): uvicorn.run(app, host=config.host, port=config.port, log_level=config.log_level.lower()) +def run_vapi_adapter(): + """Run the VAPI adapter.""" + from adapters.vapi import VapiConfig, create_app + + # Load configuration + config = VapiConfig() + + # Setup logging + setup_logging(config.log_level) + logger = logging.getLogger(__name__) + + logger.info("Starting vCon VAPI Adapter...") + logger.info(f"Conserver URL: {config.conserver_url}") + logger.info(f"Webhook validation: {config.validate_webhook}") + logger.info(f"Download recordings: {config.download_recordings}") + + # Create FastAPI app + app = create_app(config) + + # Run server + logger.info(f"Starting server on {config.host}:{config.port}") + uvicorn.run(app, host=config.host, port=config.port, log_level=config.log_level.lower()) + + +def run_pipecat_adapter(): + """Run the Pipecat adapter. + + Pipecat has no inbound webhook (see adapters/pipecat/config.py): this + only starts a health-check server so the adapter registers and + containerizes consistently. The real integration is importing + adapters.pipecat.VconConversationObserver into a Pipecat pipeline. + """ + from adapters.pipecat import PipecatConfig, create_app + + # Load configuration + config = PipecatConfig() + + # Setup logging + setup_logging(config.log_level) + logger = logging.getLogger(__name__) + + logger.info("Starting vCon Pipecat Adapter (health check only; see adapters/pipecat)...") + logger.info(f"Conserver URL: {config.conserver_url}") + + # Create FastAPI app + app = create_app(config) + + # Run server + logger.info(f"Starting server on {config.host}:{config.port}") + uvicorn.run(app, host=config.host, port=config.port, log_level=config.log_level.lower()) + + +def run_elevenlabs_adapter(): + """Run the ElevenLabs adapter.""" + from adapters.elevenlabs import ElevenLabsConfig, create_app + + # Load configuration + config = ElevenLabsConfig() + + # Setup logging + setup_logging(config.log_level) + logger = logging.getLogger(__name__) + + logger.info("Starting vCon ElevenLabs Adapter...") + logger.info(f"Conserver URL: {config.conserver_url}") + logger.info(f"Webhook validation: {config.validate_webhook}") + + # Create FastAPI app + app = create_app(config) + + # Run server + logger.info(f"Starting server on {config.host}:{config.port}") + uvicorn.run(app, host=config.host, port=config.port, log_level=config.log_level.lower()) + + +def run_signalwire_adapter(): + """Run the SignalWire adapter (a poller, not a webhook receiver).""" + from adapters.signalwire import SignalWireConfig, create_app + + # Load configuration + config = SignalWireConfig() + + # Setup logging + setup_logging(config.log_level) + logger = logging.getLogger(__name__) + + logger.info("Starting vCon SignalWire Adapter (polling)...") + logger.info(f"Conserver URL: {config.conserver_url}") + logger.info(f"Poll interval: {config.poll_interval_seconds}s") + + # Create FastAPI app + app = create_app(config) + + # Run server + logger.info(f"Starting server on {config.host}:{config.port}") + uvicorn.run(app, host=config.host, port=config.port, log_level=config.log_level.lower()) + + # Registry of available adapters ADAPTERS = { "twilio": run_twilio_adapter, @@ -165,6 +267,10 @@ def run_bandwidth_adapter(): "asterisk": run_asterisk_adapter, "telnyx": run_telnyx_adapter, "bandwidth": run_bandwidth_adapter, + "vapi": run_vapi_adapter, + "pipecat": run_pipecat_adapter, + "elevenlabs": run_elevenlabs_adapter, + "signalwire": run_signalwire_adapter, } From bb75dec20a84444740293eb4234a215c96929755 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:46:45 -0400 Subject: [PATCH 7/9] test: add coverage for vapi/pipecat/elevenlabs/signalwire, fix stale patch targets Adds config/builder/webhook (or observer/poller) tests for the four new adapters: signature valid/bad/missing-header cases and missing-secret refusal for the two webhook adapters (vapi, elevenlabs), lawful-basis set/unset for every new builder, and the SignalWire poller's dedupe and watermark behavior -- specifically that a failed build or a failed POST to the conserver leaves the call unmarked and inside the next poll's fetch window rather than being silently dropped (the CON-703 audit finding). Also fixes six tests in tests/test_builder.py and one in tests/test_webhook.py that patched twilio_adapter.builder.requests.get / twilio_adapter.webhook.VconBuilder -- the deleted shim module, not adapters.twilio (which is what create_app() and TwilioVconBuilder.build() actually use). Deleting the shim surfaced this: those patches had no effect on the code under test, so the tests were passing without actually exercising the mocked HTTP call. Retargeted to adapters.twilio.builder.requests.get and adapters.twilio.webhook.TwilioVconBuilder. Baseline (origin/main): 539 passed. After this change: 619 passed. Co-Authored-By: Claude Opus 5.5 --- tests/adapters/elevenlabs/__init__.py | 0 tests/adapters/elevenlabs/test_builder.py | 115 +++++++++++++ tests/adapters/elevenlabs/test_config.py | 46 ++++++ tests/adapters/elevenlabs/test_webhook.py | 156 +++++++++++++++++ tests/adapters/pipecat/__init__.py | 0 tests/adapters/pipecat/test_builder.py | 77 +++++++++ tests/adapters/pipecat/test_observer.py | 51 ++++++ tests/adapters/signalwire/__init__.py | 0 tests/adapters/signalwire/test_builder.py | 86 ++++++++++ tests/adapters/signalwire/test_config.py | 61 +++++++ tests/adapters/signalwire/test_poller.py | 193 ++++++++++++++++++++++ tests/adapters/vapi/__init__.py | 0 tests/adapters/vapi/test_builder.py | 99 +++++++++++ tests/adapters/vapi/test_config.py | 50 ++++++ tests/adapters/vapi/test_webhook.py | 122 ++++++++++++++ tests/test_builder.py | 12 +- tests/test_webhook.py | 2 +- 17 files changed, 1063 insertions(+), 7 deletions(-) create mode 100644 tests/adapters/elevenlabs/__init__.py create mode 100644 tests/adapters/elevenlabs/test_builder.py create mode 100644 tests/adapters/elevenlabs/test_config.py create mode 100644 tests/adapters/elevenlabs/test_webhook.py create mode 100644 tests/adapters/pipecat/__init__.py create mode 100644 tests/adapters/pipecat/test_builder.py create mode 100644 tests/adapters/pipecat/test_observer.py create mode 100644 tests/adapters/signalwire/__init__.py create mode 100644 tests/adapters/signalwire/test_builder.py create mode 100644 tests/adapters/signalwire/test_config.py create mode 100644 tests/adapters/signalwire/test_poller.py create mode 100644 tests/adapters/vapi/__init__.py create mode 100644 tests/adapters/vapi/test_builder.py create mode 100644 tests/adapters/vapi/test_config.py create mode 100644 tests/adapters/vapi/test_webhook.py diff --git a/tests/adapters/elevenlabs/__init__.py b/tests/adapters/elevenlabs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/elevenlabs/test_builder.py b/tests/adapters/elevenlabs/test_builder.py new file mode 100644 index 0000000..8bfb637 --- /dev/null +++ b/tests/adapters/elevenlabs/test_builder.py @@ -0,0 +1,115 @@ +"""Tests for the ElevenLabs vCon builder.""" + +import base64 +import json + +from adapters.elevenlabs.builder import ElevenLabsVconBuilder +from core.lawful_basis import LawfulBasisConfig + + +def make_transcription_event(**data_overrides): + data = { + "agent_id": "agent-1", + "conversation_id": "conv-abc", + "status": "done", + "user_id": "user-1", + "transcript": [ + {"role": "agent", "message": "Hi, how can I help?", "time_in_call_secs": 0}, + {"role": "user", "message": "I need support", "time_in_call_secs": 2}, + ], + "metadata": { + "start_time_unix_secs": 1739537297, + "call_duration_secs": 22, + "cost": 296, + }, + "analysis": {"call_successful": "success", "transcript_summary": "Support request."}, + } + data.update(data_overrides) + return {"type": "post_call_transcription", "event_timestamp": 1739537297, "data": data} + + +def make_audio_event(**data_overrides): + data = { + "agent_id": "agent-1", + "conversation_id": "conv-abc", + "full_audio": base64.b64encode(b"fake-mp3-bytes").decode("ascii"), + } + data.update(data_overrides) + return {"type": "post_call_audio", "event_timestamp": 1739537297, "data": data} + + +class TestElevenLabsBuilderTranscription: + def test_ignores_unknown_event_type(self): + builder = ElevenLabsVconBuilder() + assert builder.build({"type": "call_initiation_failure", "data": {}}) is None + + def test_builds_text_dialogs(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_transcription_event()) + + text_dialogs = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "text"] + assert len(text_dialogs) == 2 + assert text_dialogs[0]["originator"] == 1 # agent + assert text_dialogs[1]["originator"] == 0 # user + + def test_analysis_entries(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_transcription_event()) + analysis_types = {a["type"] for a in vcon.vcon_dict.get("analysis", [])} + assert "summary" in analysis_types + assert "success_evaluation" in analysis_types + + def test_call_record_attachment(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_transcription_event()) + records = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "call_record" + ] + assert len(records) == 1 + body = json.loads(records[0]["body"]) + assert body["durationSeconds"] == 22 + + def test_tags_include_conversation_and_agent_id(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_transcription_event()) + tags_attachment = next(a for a in vcon.vcon_dict["attachments"] if a["purpose"] == "tags") + assert "conversation_id:conv-abc" in tags_attachment["body"] + assert "agent_id:agent-1" in tags_attachment["body"] + + def test_no_lawful_basis_by_default(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_transcription_event()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert lawful == [] + + def test_lawful_basis_applied_when_configured(self): + builder = ElevenLabsVconBuilder(lawful_basis=LawfulBasisConfig(lawful_basis="consent")) + vcon = builder.build(make_transcription_event()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert len(lawful) == 1 + + +class TestElevenLabsBuilderAudio: + def test_builds_recording_dialog_embedded(self): + builder = ElevenLabsVconBuilder() + vcon = builder.build(make_audio_event()) + recordings = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert len(recordings) == 1 + assert recordings[0]["encoding"] == "base64url" + assert recordings[0]["mediatype"] == "audio/mpeg" + assert "content_hash" in recordings[0] + + def test_missing_audio_returns_none(self): + builder = ElevenLabsVconBuilder() + event = make_audio_event() + del event["data"]["full_audio"] + assert builder.build(event) is None + + def test_bad_base64_returns_none(self): + builder = ElevenLabsVconBuilder() + event = make_audio_event(full_audio="not-valid-base64!!!") + assert builder.build(event) is None diff --git a/tests/adapters/elevenlabs/test_config.py b/tests/adapters/elevenlabs/test_config.py new file mode 100644 index 0000000..3aa36ba --- /dev/null +++ b/tests/adapters/elevenlabs/test_config.py @@ -0,0 +1,46 @@ +"""Tests for ElevenLabs configuration.""" + +import pytest + +from adapters.elevenlabs.config import ElevenLabsConfig + + +class TestElevenLabsConfig: + def test_minimal_config(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("VALIDATE_ELEVENLABS_WEBHOOK", "false") + config = ElevenLabsConfig() + assert config.conserver_url == "https://conserver.example.com/vcon" + assert config.webhook_tolerance_seconds == 1800 + + def test_missing_conserver_url(self, monkeypatch): + monkeypatch.delenv("CONSERVER_URL", raising=False) + with pytest.raises(ValueError, match="CONSERVER_URL"): + ElevenLabsConfig() + + def test_webhook_validation_enabled_by_default(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("ELEVENLABS_WEBHOOK_SECRET", "shh") + config = ElevenLabsConfig() + assert config.validate_webhook is True + + def test_missing_secret_refuses_to_start(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.delenv("ELEVENLABS_WEBHOOK_SECRET", raising=False) + monkeypatch.delenv("ALLOW_UNSIGNED_WEBHOOKS", raising=False) + with pytest.raises(ValueError, match="ELEVENLABS_WEBHOOK_SECRET"): + ElevenLabsConfig() + + def test_missing_secret_allowed_with_opt_out(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.delenv("ELEVENLABS_WEBHOOK_SECRET", raising=False) + monkeypatch.setenv("ALLOW_UNSIGNED_WEBHOOKS", "true") + config = ElevenLabsConfig() + assert config.webhook_secret is None + + def test_custom_tolerance(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("VALIDATE_ELEVENLABS_WEBHOOK", "false") + monkeypatch.setenv("ELEVENLABS_WEBHOOK_TOLERANCE_SECONDS", "60") + config = ElevenLabsConfig() + assert config.webhook_tolerance_seconds == 60 diff --git a/tests/adapters/elevenlabs/test_webhook.py b/tests/adapters/elevenlabs/test_webhook.py new file mode 100644 index 0000000..e14d22a --- /dev/null +++ b/tests/adapters/elevenlabs/test_webhook.py @@ -0,0 +1,156 @@ +"""Tests for the ElevenLabs webhook endpoint, including signature validation.""" + +import hashlib +import hmac +import json +import time +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from adapters.elevenlabs.config import ElevenLabsConfig +from adapters.elevenlabs.webhook import create_app + +SECRET = "topsecret" + + +def sign(body: bytes, secret: str = SECRET, timestamp: int | None = None) -> str: + ts = str(timestamp if timestamp is not None else int(time.time())) + signed_payload = f"{ts}.".encode() + body + signature = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest() + return f"t={ts},v0={signature}" + + +def make_event(): + return { + "type": "post_call_transcription", + "event_timestamp": int(time.time()), + "data": { + "agent_id": "agent-1", + "conversation_id": "conv-abc", + "transcript": [{"role": "user", "message": "hi", "time_in_call_secs": 0}], + }, + } + + +class TestElevenLabsWebhook: + @pytest.fixture + def config(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state.json")) + monkeypatch.setenv("ELEVENLABS_WEBHOOK_SECRET", SECRET) + return ElevenLabsConfig() + + @pytest.fixture + def unvalidated_config(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state2.json")) + monkeypatch.setenv("VALIDATE_ELEVENLABS_WEBHOOK", "false") + return ElevenLabsConfig() + + def test_health_check(self, config): + app = create_app(config) + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["service"] == "vcon-elevenlabs-adapter" + + def test_valid_signature_accepted(self, config): + with ( + patch("adapters.elevenlabs.webhook.HttpPoster") as mock_poster_class, + patch("adapters.elevenlabs.webhook.ElevenLabsVconBuilder") as mock_builder_class, + ): + mock_vcon = MagicMock() + mock_vcon.uuid = "vcon-uuid-1" + mock_builder = MagicMock() + mock_builder.build.return_value = mock_vcon + mock_builder_class.return_value = mock_builder + + mock_poster = MagicMock() + mock_poster.post.return_value = True + mock_poster_class.return_value = mock_poster + + app = create_app(config) + client = TestClient(app) + + body = json.dumps(make_event()).encode() + response = client.post( + "/webhook/post-call", + content=body, + headers={ + "content-type": "application/json", + "elevenlabs-signature": sign(body), + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_bad_signature_rejected(self, config): + app = create_app(config) + client = TestClient(app) + body = json.dumps(make_event()).encode() + response = client.post( + "/webhook/post-call", + content=body, + headers={ + "content-type": "application/json", + "elevenlabs-signature": sign(body, secret="wrong-secret"), + }, + ) + assert response.status_code == 401 + + def test_missing_signature_rejected(self, config): + app = create_app(config) + client = TestClient(app) + body = json.dumps(make_event()).encode() + response = client.post( + "/webhook/post-call", content=body, headers={"content-type": "application/json"} + ) + assert response.status_code == 401 + + def test_stale_timestamp_rejected(self, config): + app = create_app(config) + client = TestClient(app) + body = json.dumps(make_event()).encode() + old_timestamp = int(time.time()) - 999999 + response = client.post( + "/webhook/post-call", + content=body, + headers={ + "content-type": "application/json", + "elevenlabs-signature": sign(body, timestamp=old_timestamp), + }, + ) + assert response.status_code == 401 + + def test_missing_secret_refuses_to_start(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state3.json")) + monkeypatch.delenv("ELEVENLABS_WEBHOOK_SECRET", raising=False) + monkeypatch.delenv("ALLOW_UNSIGNED_WEBHOOKS", raising=False) + with pytest.raises(ValueError, match="ELEVENLABS_WEBHOOK_SECRET"): + ElevenLabsConfig() + + def test_opt_out_accepts_unsigned(self, unvalidated_config): + with ( + patch("adapters.elevenlabs.webhook.HttpPoster") as mock_poster_class, + patch("adapters.elevenlabs.webhook.ElevenLabsVconBuilder") as mock_builder_class, + ): + mock_vcon = MagicMock() + mock_vcon.uuid = "vcon-uuid-2" + mock_builder = MagicMock() + mock_builder.build.return_value = mock_vcon + mock_builder_class.return_value = mock_builder + + mock_poster = MagicMock() + mock_poster.post.return_value = True + mock_poster_class.return_value = mock_poster + + app = create_app(unvalidated_config) + client = TestClient(app) + body = json.dumps(make_event()).encode() + response = client.post( + "/webhook/post-call", content=body, headers={"content-type": "application/json"} + ) + assert response.status_code == 200 diff --git a/tests/adapters/pipecat/__init__.py b/tests/adapters/pipecat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/pipecat/test_builder.py b/tests/adapters/pipecat/test_builder.py new file mode 100644 index 0000000..abd624d --- /dev/null +++ b/tests/adapters/pipecat/test_builder.py @@ -0,0 +1,77 @@ +"""Tests for the Pipecat vCon builder.""" + +import json +from datetime import UTC, datetime, timedelta + +from adapters.pipecat.builder import ( + PipecatConversationState, + PipecatTurn, + PipecatVconBuilder, +) +from core.lawful_basis import LawfulBasisConfig + + +def make_state(**overrides): + started = datetime.now(UTC) + state = PipecatConversationState( + conversation_id="conv-1", + started_at=started, + ended_at=started + timedelta(seconds=30), + turns=[ + PipecatTurn(role="user", text="Hello", start_time=started), + PipecatTurn(role="assistant", text="Hi!", start_time=started + timedelta(seconds=1)), + ], + ) + for k, v in overrides.items(): + setattr(state, k, v) + return state + + +class TestPipecatVconBuilder: + def test_builds_text_dialogs(self): + builder = PipecatVconBuilder() + vcon = builder.build(make_state()) + + text_dialogs = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "text"] + assert len(text_dialogs) == 2 + assert text_dialogs[0]["originator"] == 0 + assert text_dialogs[1]["originator"] == 1 + + def test_call_record_attachment_has_conversation_id(self): + builder = PipecatVconBuilder() + vcon = builder.build(make_state()) + call_records = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "call_record" + ] + assert len(call_records) == 1 + assert json.loads(call_records[0]["body"])["conversation_id"] == "conv-1" + + def test_no_lawful_basis_by_default(self): + builder = PipecatVconBuilder() + vcon = builder.build(make_state()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert lawful == [] + + def test_lawful_basis_applied_when_configured(self): + builder = PipecatVconBuilder(lawful_basis=LawfulBasisConfig(lawful_basis="consent")) + vcon = builder.build(make_state()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert len(lawful) == 1 + + def test_audio_embedded_without_publisher(self): + builder = PipecatVconBuilder() + vcon = builder.build(make_state(audio_bytes=b"fake-wav-bytes")) + recordings = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert len(recordings) == 1 + assert recordings[0]["encoding"] == "base64url" + assert "content_hash" in recordings[0] + + def test_no_audio_dialog_when_no_bytes(self): + builder = PipecatVconBuilder() + vcon = builder.build(make_state()) + recordings = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert recordings == [] diff --git a/tests/adapters/pipecat/test_observer.py b/tests/adapters/pipecat/test_observer.py new file mode 100644 index 0000000..6f1abd9 --- /dev/null +++ b/tests/adapters/pipecat/test_observer.py @@ -0,0 +1,51 @@ +"""Tests for the Pipecat conversation observer (no webhook involved: this +adapter has no inbound request, see adapters/pipecat/config.py).""" + +from adapters.pipecat.observer import VconConversationObserver + + +class TestVconConversationObserver: + def test_records_turns_and_ends(self): + captured = [] + observer = VconConversationObserver( + conversation_id="conv-2", on_vcon=lambda v: captured.append(v) + ) + + observer.record_user_transcript("Hello") + observer.record_assistant_text_chunk("Hi") + observer.record_assistant_text_chunk(" there") + observer.finalize_assistant_turn() + + vcon = observer.end() + + assert len(captured) == 1 + assert captured[0] is vcon + text_dialogs = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "text"] + assert len(text_dialogs) == 2 + assert text_dialogs[1]["body"] == "Hi there" + + def test_attach_audio_produces_recording_dialog(self): + observer = VconConversationObserver(conversation_id="conv-3", on_vcon=lambda v: None) + observer.attach_audio(b"fake-audio", mediatype="audio/wav") + vcon = observer.end() + recordings = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert len(recordings) == 1 + + def test_on_vcon_exception_does_not_propagate(self): + def boom(_vcon): + raise RuntimeError("callback exploded") + + observer = VconConversationObserver(conversation_id="conv-4", on_vcon=boom) + # Must not raise even though the callback does. + vcon = observer.end() + assert vcon is not None + + def test_as_frame_processor_without_pipecat_raises(self): + observer = VconConversationObserver(conversation_id="conv-5", on_vcon=lambda v: None) + try: + observer.as_frame_processor() + except RuntimeError as e: + assert "pipecat-ai" in str(e) + else: + # pipecat-ai happens to be installed in this environment; nothing to assert. + pass diff --git a/tests/adapters/signalwire/__init__.py b/tests/adapters/signalwire/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/signalwire/test_builder.py b/tests/adapters/signalwire/test_builder.py new file mode 100644 index 0000000..b9a8a92 --- /dev/null +++ b/tests/adapters/signalwire/test_builder.py @@ -0,0 +1,86 @@ +"""Tests for the SignalWire vCon builder.""" + +from adapters.signalwire.builder import SignalWireRecordingData, SignalWireVconBuilder +from core.lawful_basis import LawfulBasisConfig + + +def make_data(num_recordings: int = 2): + call_meta = {"sid": "CA123", "to_formatted": "(555) 123-4567", "from_formatted": "5559876543"} + recordings = [ + { + "sid": f"RE{i}", + "account_sid": "AC1", + "call_sid": "CA123", + "channels": 1, + "date_created": "Mon, 15 Jan 2024 10:29:30 +0000", + "duration": "30", + "recording_url": f"https://signalwire.example.com/recordings/RE{i}", + } + for i in range(num_recordings) + ] + transcripts = {f"RE{i}": [{"text": f"transcript {i}"}] for i in range(num_recordings)} + return SignalWireRecordingData( + call_meta=call_meta, recordings=recordings, transcriptions_by_recording_sid=transcripts + ) + + +class TestSignalWireVconBuilder: + def test_one_dialog_per_recording(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data(num_recordings=3)) + recordings = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert len(recordings) == 3 + + def test_parties_formatted_e164(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data()) + parties = vcon.vcon_dict["parties"] + assert parties[0]["tel"] == "+15551234567" + assert parties[1]["tel"] == "+15559876543" + + def test_transcript_analysis_per_dialog(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data(num_recordings=2)) + transcripts = [a for a in vcon.vcon_dict.get("analysis", []) if a["type"] == "transcript"] + assert len(transcripts) == 2 + + def test_recording_metadata_attachment_per_dialog(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data(num_recordings=2)) + metas = [ + a + for a in vcon.vcon_dict.get("attachments", []) + if a.get("purpose") == "recording_metadata" + ] + assert len(metas) == 2 + + def test_call_sid_tag(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data()) + tags_attachment = next(a for a in vcon.vcon_dict["attachments"] if a["purpose"] == "tags") + assert "call_sid:CA123" in tags_attachment["body"] + + def test_no_lawful_basis_by_default(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert lawful == [] + + def test_lawful_basis_applied_when_configured(self): + builder = SignalWireVconBuilder( + download_recordings=False, lawful_basis=LawfulBasisConfig(lawful_basis="consent") + ) + vcon = builder.build(make_data()) + lawful = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert len(lawful) == 1 + + def test_dialogs_use_mediatype_not_mimetype(self): + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data(num_recordings=1)) + recording = next(d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording") + assert recording.get("mediatype") == "audio/wav" + assert "mimetype" not in recording diff --git a/tests/adapters/signalwire/test_config.py b/tests/adapters/signalwire/test_config.py new file mode 100644 index 0000000..aec5958 --- /dev/null +++ b/tests/adapters/signalwire/test_config.py @@ -0,0 +1,61 @@ +"""Tests for SignalWire configuration.""" + +import pytest + +from adapters.signalwire.config import SignalWireConfig + + +class TestSignalWireConfig: + def _set_required(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("SIGNALWIRE_PROJECT_ID", "PJ123") + monkeypatch.setenv("SIGNALWIRE_AUTH_TOKEN", "TOKEN123") + monkeypatch.setenv("SIGNALWIRE_SPACE_URL", "https://example.signalwire.com") + + def test_minimal_config(self, monkeypatch): + self._set_required(monkeypatch) + config = SignalWireConfig() + assert config.project_id == "PJ123" + assert config.poll_interval_seconds == 300 + assert config.retention_days == 30 + + def test_missing_conserver_url(self, monkeypatch): + monkeypatch.delenv("CONSERVER_URL", raising=False) + monkeypatch.setenv("SIGNALWIRE_PROJECT_ID", "PJ123") + monkeypatch.setenv("SIGNALWIRE_AUTH_TOKEN", "TOKEN123") + monkeypatch.setenv("SIGNALWIRE_SPACE_URL", "https://example.signalwire.com") + with pytest.raises(ValueError, match="CONSERVER_URL"): + SignalWireConfig() + + @pytest.mark.parametrize( + "missing_var", + ["SIGNALWIRE_PROJECT_ID", "SIGNALWIRE_AUTH_TOKEN", "SIGNALWIRE_SPACE_URL"], + ) + def test_missing_required_signalwire_vars(self, monkeypatch, missing_var): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("SIGNALWIRE_PROJECT_ID", "PJ123") + monkeypatch.setenv("SIGNALWIRE_AUTH_TOKEN", "TOKEN123") + monkeypatch.setenv("SIGNALWIRE_SPACE_URL", "https://example.signalwire.com") + monkeypatch.delenv(missing_var, raising=False) + with pytest.raises(ValueError, match="SIGNALWIRE_"): + SignalWireConfig() + + def test_no_webhook_validation_setting(self, monkeypatch): + """SignalWire is a poller: there is no VALIDATE_*_WEBHOOK concept.""" + self._set_required(monkeypatch) + config = SignalWireConfig() + assert not hasattr(config, "validate_webhook") + + def test_custom_poll_interval(self, monkeypatch): + self._set_required(monkeypatch) + monkeypatch.setenv("SIGNALWIRE_POLL_INTERVAL_SECONDS", "60") + config = SignalWireConfig() + assert config.poll_interval_seconds == 60 + + def test_api_base_and_auth(self, monkeypatch): + self._set_required(monkeypatch) + config = SignalWireConfig() + assert config.api_base() == ( + "https://example.signalwire.com/api/laml/2010-04-01/Accounts/PJ123" + ) + assert config.api_auth() == ("PJ123", "TOKEN123") diff --git a/tests/adapters/signalwire/test_poller.py b/tests/adapters/signalwire/test_poller.py new file mode 100644 index 0000000..70d884e --- /dev/null +++ b/tests/adapters/signalwire/test_poller.py @@ -0,0 +1,193 @@ +"""Tests for the SignalWire poller: dedupe/watermark safety, API error +handling, and the failed-POST-not-marked-processed fix (CON-703). + +The standalone rescue/cursor-multi-mode-wip draft of this poller had two bugs +an audit flagged: it marked a call processed even when the POST to the +conserver failed, and it advanced its poll watermark to now() every cycle +regardless of outcome, so a failed call would fall out of the fetch window on +the very next poll and never be retried. These tests pin the fixed behavior. +""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock + +import pytest + +from adapters.signalwire.config import SignalWireConfig +from adapters.signalwire.poller import SignalWirePoller +from core.tracker import StateTracker + + +@pytest.fixture +def config(monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("SIGNALWIRE_PROJECT_ID", "PJ123") + monkeypatch.setenv("SIGNALWIRE_AUTH_TOKEN", "TOKEN123") + monkeypatch.setenv("SIGNALWIRE_SPACE_URL", "https://example.signalwire.com") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state.json")) + return SignalWireConfig() + + +def make_poller(config, *, builder=None, poster=None, tracker=None): + builder = builder or MagicMock() + poster = poster or MagicMock() + tracker = tracker or StateTracker(config.state_file) + return SignalWirePoller(config, builder=builder, poster=poster, tracker=tracker) + + +class TestSignalWirePollerSuccessPath: + def test_successful_post_marks_processed_and_advances_watermark(self, config): + builder = MagicMock() + vcon = MagicMock() + vcon.uuid = "vcon-1" + builder.build.return_value = vcon + + poster = MagicMock() + poster.post.return_value = True + + poller = make_poller(config, builder=builder, poster=poster) + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock(return_value={"sid": "CA1"}) + poller._fetch_transcriptions = MagicMock(return_value=[]) + + shipped = poller.process() + + assert shipped == 1 + assert poller.tracker.is_processed("CA1") + assert poller.tracker.get_vcon_uuid("CA1") == "vcon-1" + + def test_already_processed_calls_are_skipped(self, config): + builder = MagicMock() + poster = MagicMock() + poller = make_poller(config, builder=builder, poster=poster) + poller.tracker.mark_processed("CA1", "vcon-old", status="success") + + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock() + + shipped = poller.process() + + assert shipped == 0 + builder.build.assert_not_called() + poller._fetch_call_meta.assert_not_called() + + +class TestSignalWirePollerFailurePath: + def test_failed_post_does_not_mark_processed(self, config): + """CON-703: a failed POST must not be recorded as processed.""" + builder = MagicMock() + vcon = MagicMock() + vcon.uuid = "vcon-1" + builder.build.return_value = vcon + + poster = MagicMock() + poster.post.return_value = False # conserver rejected/unreachable + + poller = make_poller(config, builder=builder, poster=poster) + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock(return_value={"sid": "CA1"}) + poller._fetch_transcriptions = MagicMock(return_value=[]) + + shipped = poller.process() + + assert shipped == 0 + assert not poller.tracker.is_processed("CA1") + + def test_failed_post_keeps_watermark_from_advancing_past_it(self, config): + """A failed call must stay inside the next poll's fetch window.""" + builder = MagicMock() + vcon = MagicMock() + vcon.uuid = "vcon-1" + builder.build.return_value = vcon + + poster = MagicMock() + poster.post.return_value = False + + poller = make_poller(config, builder=builder, poster=poster) + since = datetime.now(UTC) - timedelta(hours=1) + poller._load_watermark = MagicMock(return_value=since) + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock(return_value={"sid": "CA1"}) + poller._fetch_transcriptions = MagicMock(return_value=[]) + + saved = {} + poller._save_watermark = lambda wm: saved.setdefault("watermark", wm) + + poller.process() + + # Watermark must not have advanced past the original `since`, or the + # failed call would drop out of the next poll's DateCreated> window. + assert saved["watermark"] <= since + + def test_build_failure_does_not_mark_processed(self, config): + builder = MagicMock() + builder.build.return_value = None # build failed + + poster = MagicMock() + poller = make_poller(config, builder=builder, poster=poster) + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock(return_value={"sid": "CA1"}) + poller._fetch_transcriptions = MagicMock(return_value=[]) + + shipped = poller.process() + + assert shipped == 0 + assert not poller.tracker.is_processed("CA1") + poster.post.assert_not_called() + + def test_call_meta_fetch_failure_does_not_mark_processed(self, config): + builder = MagicMock() + poster = MagicMock() + poller = make_poller(config, builder=builder, poster=poster) + poller._fetch_recordings_since = MagicMock(return_value=[{"call_sid": "CA1", "sid": "RE1"}]) + poller._fetch_call_meta = MagicMock(return_value=None) # API error / 404 + + shipped = poller.process() + + assert shipped == 0 + assert not poller.tracker.is_processed("CA1") + builder.build.assert_not_called() + + def test_recordings_fetch_api_error_returns_empty_without_raising(self, config): + """requests raising should not crash the poll cycle.""" + import requests + + poller = make_poller(config) + + class BoomSession: + def get(self, *args, **kwargs): + raise requests.RequestException("boom") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "adapters.signalwire.poller.requests.get", + lambda *a, **k: (_ for _ in ()).throw(requests.RequestException("boom")), + ) + recordings = poller._fetch_recordings_since(datetime.now(UTC)) + + assert recordings == [] + + +class TestSignalWirePollerWatermarkPersistence: + def test_watermark_round_trips_through_file(self, config, tmp_path): + poller = make_poller(config) + wm = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + poller._save_watermark(wm) + + poller2 = make_poller(config) + loaded = poller2._load_watermark() + assert loaded == wm + + def test_missing_watermark_file_falls_back_to_poll_interval(self, config): + poller = make_poller(config) + before = datetime.now(UTC) - timedelta(seconds=config.poll_interval_seconds) + loaded = poller._load_watermark() + # Allow a couple seconds of test execution slack. + assert abs((loaded - before).total_seconds()) < 5 + + def test_corrupt_watermark_file_falls_back_safely(self, config): + poller = make_poller(config) + poller._watermark_file.write_text("not json") + # Must not raise. + loaded = poller._load_watermark() + assert loaded is not None diff --git a/tests/adapters/vapi/__init__.py b/tests/adapters/vapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/vapi/test_builder.py b/tests/adapters/vapi/test_builder.py new file mode 100644 index 0000000..b0d8d95 --- /dev/null +++ b/tests/adapters/vapi/test_builder.py @@ -0,0 +1,99 @@ +"""Tests for the VAPI vCon builder.""" + +from unittest.mock import MagicMock + +from adapters.vapi.builder import VapiVconBuilder +from core.lawful_basis import LawfulBasisConfig + + +def make_message(**overrides): + message = { + "type": "end-of-call-report", + "startedAt": "2024-01-15T10:29:30.000Z", + "endedAt": "2024-01-15T10:30:00.000Z", + "call": {"id": "call-123"}, + "artifact": { + "messages": [ + {"role": "user", "message": "Hello", "time": 1705314570000}, + {"role": "bot", "message": "Hi there", "time": 1705314572000}, + {"role": "system", "message": "ignored", "time": 1705314573000}, + ] + }, + "transcript": "Hello\nHi there", + "analysis": {"summary": "A short call.", "successEvaluation": True}, + "cost": 0.12, + "durationSeconds": 30, + "endedReason": "customer-ended-call", + } + message.update(overrides) + return message + + +class TestVapiVconBuilder: + def test_ignores_non_end_of_call_messages(self): + builder = VapiVconBuilder() + assert builder.build({"type": "status-update"}) is None + + def test_builds_vcon_with_dialogs_and_analysis(self): + builder = VapiVconBuilder(download_recordings=False) + vcon = builder.build(make_message()) + + assert vcon is not None + assert len(vcon.vcon_dict["parties"]) == 2 + # Two text dialogs (user + bot); system message skipped. + text_dialogs = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "text"] + assert len(text_dialogs) == 2 + assert text_dialogs[0]["originator"] == 0 + assert text_dialogs[1]["originator"] == 1 + + analysis_types = {a["type"] for a in vcon.vcon_dict.get("analysis", [])} + assert "transcript" in analysis_types + assert "summary" in analysis_types + assert "success_evaluation" in analysis_types + + source_tags = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "tags" + ] + assert source_tags and "source:vapi" in source_tags[0]["body"] + + def test_call_record_attachment(self): + builder = VapiVconBuilder(download_recordings=False) + vcon = builder.build(make_message()) + attachments = vcon.vcon_dict.get("attachments", []) + call_records = [a for a in attachments if a.get("purpose") == "call_record"] + assert len(call_records) == 1 + + def test_no_lawful_basis_does_not_raise(self): + builder = VapiVconBuilder(download_recordings=False, lawful_basis=None) + vcon = builder.build(make_message()) + assert vcon is not None + purposes = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert purposes == [] + + def test_lawful_basis_applied_when_configured(self): + lawful_basis = LawfulBasisConfig(lawful_basis="consent") + builder = VapiVconBuilder(download_recordings=False, lawful_basis=lawful_basis) + vcon = builder.build(make_message()) + purposes = [ + a for a in vcon.vcon_dict.get("attachments", []) if a.get("purpose") == "lawful_basis" + ] + assert len(purposes) == 1 + + def test_recording_dialog_downloaded_and_embedded(self): + builder = VapiVconBuilder(download_recordings=True) + builder._download = MagicMock(return_value=b"fake-audio-bytes") + message = make_message(recordingUrl="https://vapi.example.com/rec.wav") + vcon = builder.build(message) + + recording_dialogs = [d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording"] + assert len(recording_dialogs) == 1 + assert recording_dialogs[0]["encoding"] == "base64url" + assert "content_hash" in recording_dialogs[0] + + def test_build_failure_returns_none_not_raise(self): + builder = VapiVconBuilder() + # Malformed payload: artifact.messages not a list should not crash the builder. + vcon = builder.build({"type": "end-of-call-report", "artifact": {"messages": "not-a-list"}}) + assert vcon is None or vcon is not None # build() must not raise either way diff --git a/tests/adapters/vapi/test_config.py b/tests/adapters/vapi/test_config.py new file mode 100644 index 0000000..c704e53 --- /dev/null +++ b/tests/adapters/vapi/test_config.py @@ -0,0 +1,50 @@ +"""Tests for VAPI configuration.""" + +import pytest + +from adapters.vapi.config import VapiConfig + + +class TestVapiConfig: + def test_minimal_config(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("VALIDATE_VAPI_WEBHOOK", "false") + config = VapiConfig() + assert config.conserver_url == "https://conserver.example.com/vcon" + assert config.customer_party_name == "Customer" + assert config.agent_party_name == "AI Agent" + + def test_missing_conserver_url(self, monkeypatch): + monkeypatch.delenv("CONSERVER_URL", raising=False) + with pytest.raises(ValueError, match="CONSERVER_URL"): + VapiConfig() + + def test_webhook_validation_enabled_by_default(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("VAPI_WEBHOOK_SECRET", "shh") + config = VapiConfig() + assert config.validate_webhook is True + + def test_missing_secret_refuses_to_start(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.delenv("VAPI_WEBHOOK_SECRET", raising=False) + monkeypatch.delenv("ALLOW_UNSIGNED_WEBHOOKS", raising=False) + with pytest.raises(ValueError, match="VAPI_WEBHOOK_SECRET"): + VapiConfig() + + def test_missing_secret_allowed_with_opt_out(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.delenv("VAPI_WEBHOOK_SECRET", raising=False) + monkeypatch.setenv("ALLOW_UNSIGNED_WEBHOOKS", "true") + config = VapiConfig() + assert config.validate_webhook is True + assert config.webhook_secret is None + + def test_custom_party_names(self, monkeypatch): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("VALIDATE_VAPI_WEBHOOK", "false") + monkeypatch.setenv("VAPI_CUSTOMER_NAME", "Caller") + monkeypatch.setenv("VAPI_AGENT_NAME", "Bot") + config = VapiConfig() + assert config.customer_party_name == "Caller" + assert config.agent_party_name == "Bot" diff --git a/tests/adapters/vapi/test_webhook.py b/tests/adapters/vapi/test_webhook.py new file mode 100644 index 0000000..3847c11 --- /dev/null +++ b/tests/adapters/vapi/test_webhook.py @@ -0,0 +1,122 @@ +"""Tests for the VAPI webhook endpoint, including signature validation.""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from adapters.vapi.config import VapiConfig +from adapters.vapi.webhook import create_app + + +def make_message(): + return { + "message": { + "type": "end-of-call-report", + "call": {"id": "call-abc"}, + "artifact": {"messages": [{"role": "user", "message": "hi", "time": 1}]}, + } + } + + +class TestVapiWebhook: + @pytest.fixture + def config(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state.json")) + monkeypatch.setenv("VAPI_WEBHOOK_SECRET", "topsecret") + return VapiConfig() + + @pytest.fixture + def unvalidated_config(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state2.json")) + monkeypatch.setenv("VALIDATE_VAPI_WEBHOOK", "false") + return VapiConfig() + + def test_health_check(self, config): + app = create_app(config) + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["service"] == "vcon-vapi-adapter" + + def test_valid_secret_accepted(self, config): + with ( + patch("adapters.vapi.webhook.HttpPoster") as mock_poster_class, + patch("adapters.vapi.webhook.VapiVconBuilder") as mock_builder_class, + ): + mock_vcon = MagicMock() + mock_vcon.uuid = "vcon-uuid-1" + mock_builder = MagicMock() + mock_builder.build.return_value = mock_vcon + mock_builder_class.return_value = mock_builder + + mock_poster = MagicMock() + mock_poster.post.return_value = True + mock_poster_class.return_value = mock_poster + + app = create_app(config) + client = TestClient(app) + response = client.post( + "/vapi", json=make_message(), headers={"x-vapi-secret": "topsecret"} + ) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_bad_secret_rejected(self, config): + app = create_app(config) + client = TestClient(app) + response = client.post("/vapi", json=make_message(), headers={"x-vapi-secret": "wrong"}) + assert response.status_code == 401 + + def test_missing_secret_header_rejected(self, config): + app = create_app(config) + client = TestClient(app) + response = client.post("/vapi", json=make_message()) + assert response.status_code == 401 + + def test_missing_secret_refuses_to_start(self, monkeypatch, tmp_path): + monkeypatch.setenv("CONSERVER_URL", "https://conserver.example.com/vcon") + monkeypatch.setenv("STATE_FILE", str(tmp_path / "state3.json")) + monkeypatch.delenv("VAPI_WEBHOOK_SECRET", raising=False) + monkeypatch.delenv("ALLOW_UNSIGNED_WEBHOOKS", raising=False) + with pytest.raises(ValueError, match="VAPI_WEBHOOK_SECRET"): + VapiConfig() + + def test_opt_out_accepts_unsigned(self, unvalidated_config): + with ( + patch("adapters.vapi.webhook.HttpPoster") as mock_poster_class, + patch("adapters.vapi.webhook.VapiVconBuilder") as mock_builder_class, + ): + mock_vcon = MagicMock() + mock_vcon.uuid = "vcon-uuid-2" + mock_builder = MagicMock() + mock_builder.build.return_value = mock_vcon + mock_builder_class.return_value = mock_builder + + mock_poster = MagicMock() + mock_poster.post.return_value = True + mock_poster_class.return_value = mock_poster + + app = create_app(unvalidated_config) + client = TestClient(app) + response = client.post("/vapi", json=make_message()) + assert response.status_code == 200 + + def test_ignored_message_type(self, config): + app = create_app(config) + client = TestClient(app) + response = client.post( + "/vapi", + json={"message": {"type": "status-update", "call": {"id": "x"}}}, + headers={"x-vapi-secret": "topsecret"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "ignored" + + def test_missing_message_key(self, config): + app = create_app(config) + client = TestClient(app) + response = client.post("/vapi", json={}, headers={"x-vapi-secret": "topsecret"}) + assert response.status_code == 422 diff --git a/tests/test_builder.py b/tests/test_builder.py index 73c28eb..7c15c20 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -512,7 +512,7 @@ def test_download_success_embeds_audio(self, builder_with_auth, sample_audio_byt } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = sample_audio_bytes @@ -536,7 +536,7 @@ def test_download_failure_falls_back_to_url(self, builder_with_auth): } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 404 mock_get.return_value = mock_response @@ -556,7 +556,7 @@ def test_download_exception_falls_back_to_url(self, builder_with_auth): } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_get.side_effect = Exception("Connection error") vcon = builder_with_auth.build(data) @@ -574,7 +574,7 @@ def test_download_uses_auth(self, builder_with_auth): } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"audio" @@ -597,7 +597,7 @@ def test_download_url_format(self, builder_with_auth): } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"audio" @@ -620,7 +620,7 @@ def test_download_timeout(self, builder_with_auth): } ) - with patch("twilio_adapter.builder.requests.get") as mock_get: + with patch("adapters.twilio.builder.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"audio" diff --git a/tests/test_webhook.py b/tests/test_webhook.py index d214f17..324e69f 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -692,7 +692,7 @@ class TestWebhookErrorHandling: def test_returns_ok_even_on_build_failure(self, mock_config): """Returns OK even when vCon build fails.""" - with patch("twilio_adapter.webhook.VconBuilder") as mock_builder_class: + with patch("adapters.twilio.webhook.TwilioVconBuilder") as mock_builder_class: mock_builder = MagicMock() mock_builder.build.return_value = None # Simulate build failure mock_builder_class.return_value = mock_builder From 66a503f283ac8ab2fcfd45267ed7f35b5b28c4bd Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:48:21 -0400 Subject: [PATCH 8/9] test(e2e): add FreeSWITCH end-to-end harness Ported from a private vcon-freeswitch-tester repo (cloned read-only to a scratch dir, reviewed file by file, deleted after copying). One line in setup_mac_mini.sh naming a specific machine and its LAN IP/username was redacted to a generic comment (setup_local.sh here); everything else (mock_conserver.py, the FreeSWITCH ESL/dialplan/Lua configs, and the test_*.sh/test_ws_client.py scripts) only referenced localhost, Homebrew's own /opt/homebrew paths, FreeSWITCH's documented default ESL password ("ClueCon", not a real credential), and already-fake phone numbers matching the rest of this repo's fixtures. Excluded from the default pytest run via collect_ignore_glob in tests/e2e/freeswitch/conftest.py: it needs a live FreeSWITCH + Redis + this repo's own adapter running locally, which CI doesn't have, and test_ws_client.py imports websockets, which isn't a project dependency. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- tests/e2e/freeswitch/README.md | 44 +++ tests/e2e/freeswitch/conftest.py | 14 + .../freeswitch_configs/event_socket.conf.xml | 13 + .../freeswitch_configs/recording_webhook.lua | 41 +++ .../freeswitch_configs/test_extensions.xml | 51 ++++ tests/e2e/freeswitch/mock_conserver.py | 123 ++++++++ tests/e2e/freeswitch/requirements.txt | 3 + tests/e2e/freeswitch/setup_local.sh | 281 ++++++++++++++++++ tests/e2e/freeswitch/test_call.sh | 62 ++++ tests/e2e/freeswitch/test_e2e.sh | 227 ++++++++++++++ tests/e2e/freeswitch/test_webhook.sh | 69 +++++ tests/e2e/freeswitch/test_ws_client.py | 152 ++++++++++ 13 files changed, 1089 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/freeswitch/README.md create mode 100644 tests/e2e/freeswitch/conftest.py create mode 100644 tests/e2e/freeswitch/freeswitch_configs/event_socket.conf.xml create mode 100644 tests/e2e/freeswitch/freeswitch_configs/recording_webhook.lua create mode 100644 tests/e2e/freeswitch/freeswitch_configs/test_extensions.xml create mode 100644 tests/e2e/freeswitch/mock_conserver.py create mode 100644 tests/e2e/freeswitch/requirements.txt create mode 100755 tests/e2e/freeswitch/setup_local.sh create mode 100755 tests/e2e/freeswitch/test_call.sh create mode 100755 tests/e2e/freeswitch/test_e2e.sh create mode 100755 tests/e2e/freeswitch/test_webhook.sh create mode 100644 tests/e2e/freeswitch/test_ws_client.py diff --git a/README.md b/README.md index b18a3a0..ac95d9e 100644 --- a/README.md +++ b/README.md @@ -737,12 +737,20 @@ pytest tests/adapters/freeswitch/ pytest tests/adapters/asterisk/ pytest tests/adapters/telnyx/ pytest tests/adapters/bandwidth/ +pytest tests/adapters/vapi/ +pytest tests/adapters/pipecat/ +pytest tests/adapters/elevenlabs/ +pytest tests/adapters/signalwire/ # Run with verbose output pytest -v ``` -The test suite includes 157+ tests covering all adapters with configuration, builder, and webhook tests. +The test suite includes 600+ tests covering all adapters with configuration, builder, and +webhook (or observer/poller) tests. + +`tests/e2e/freeswitch/` is a separate manual harness against a real, locally running +FreeSWITCH instance; it is excluded from the default `pytest` run (see its own README). ### Code formatting diff --git a/tests/e2e/freeswitch/README.md b/tests/e2e/freeswitch/README.md new file mode 100644 index 0000000..7d9525c --- /dev/null +++ b/tests/e2e/freeswitch/README.md @@ -0,0 +1,44 @@ +# FreeSWITCH end-to-end harness + +Manual test harness for the FreeSWITCH adapter against a real, locally +running FreeSWITCH instance (macOS/Homebrew). Ported from a private +`vcon-freeswitch-tester` repo, reviewed for secrets/hostnames/personal data +before copying in; one line in `setup_local.sh` naming a specific machine +and its LAN IP/username was redacted to a generic comment. + +Not part of the default `pytest` run: `conftest.py` in this directory +excludes it from collection (it needs a live FreeSWITCH, Redis, and this +repo's own adapter running locally; CI has none of those, and +`test_ws_client.py` also imports `websockets`, which is not a project +dependency). + +## Contents + +- `setup_local.sh` - installs/configures a local FreeSWITCH, Redis, and this + repo's `.venv`, and writes example `.env` files. Adjust paths for your + machine before running. +- `freeswitch_configs/` - `event_socket.conf.xml` (ESL access), + `recording_webhook.lua` and `test_extensions.xml` (dialplan entries that + record a test call and POST it to `/webhook/recording`). +- `mock_conserver.py` - a minimal FastAPI stand-in for a real conserver; + stores posted vCons to disk under `./vcons` and serves them back for + inspection. +- `test_call.sh` - drives `fs_cli` to originate a loopback echo/record call. +- `test_webhook.sh` - POSTs a synthetic recording-complete event straight to + the adapter, bypassing FreeSWITCH. +- `test_e2e.sh` - starts the mock conserver, the FreeSWITCH adapter, and + (if present in a sibling checkout) `vcon-real-time`, then runs the call, + webhook and WebSocket checks above and reports what arrived. +- `test_ws_client.py` - streams synthetic PCM audio to a `vcon-real-time` + WebSocket endpoint. Requires `pip install -r requirements.txt`. + +## Running + +```bash +cd tests/e2e/freeswitch +./setup_local.sh # once, adjust paths for your machine first +python3 mock_conserver.py & +cd ../../.. && python main.py freeswitch & +cd tests/e2e/freeswitch +./test_webhook.sh # or ./test_e2e.sh for the full pipeline +``` diff --git a/tests/e2e/freeswitch/conftest.py b/tests/e2e/freeswitch/conftest.py new file mode 100644 index 0000000..ed66f92 --- /dev/null +++ b/tests/e2e/freeswitch/conftest.py @@ -0,0 +1,14 @@ +"""Skip this directory from the default test run. + +Everything under tests/e2e/freeswitch/ is a manual harness (shell scripts, a +mock conserver, a FreeSWITCH Lua/dialplan config set) for exercising the +FreeSWITCH adapter against a real, locally running FreeSWITCH instance. None +of it is pytest-collectible test code, and it needs services (FreeSWITCH, +Redis, this repo's own adapter) that CI does not have, so it is skipped +unconditionally rather than gated on a marker. + +To run it: see the README in this directory. +""" + +# Standard pytest hook: excludes every file in this directory from collection. +collect_ignore_glob = ["*"] diff --git a/tests/e2e/freeswitch/freeswitch_configs/event_socket.conf.xml b/tests/e2e/freeswitch/freeswitch_configs/event_socket.conf.xml new file mode 100644 index 0000000..cb0adbd --- /dev/null +++ b/tests/e2e/freeswitch/freeswitch_configs/event_socket.conf.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/tests/e2e/freeswitch/freeswitch_configs/recording_webhook.lua b/tests/e2e/freeswitch/freeswitch_configs/recording_webhook.lua new file mode 100644 index 0000000..b6001a3 --- /dev/null +++ b/tests/e2e/freeswitch/freeswitch_configs/recording_webhook.lua @@ -0,0 +1,41 @@ +-- recording_webhook.lua +-- FreeSWITCH Lua script to POST webhook on recording complete +-- Place in /opt/homebrew/etc/freeswitch/scripts/ + +local api = freeapi:execute +local uuid = session:getVariable("uuid") +local caller_id_number = session:getVariable("caller_id_number") or "" +local caller_id_name = session:getVariable("caller_id_name") or "" +local destination_number = session:getVariable("destination_number") or "" +local direction = session:getVariable("direction") or "inbound" +local start_epoch = session:getVariable("start_epoch") or "" +local duration = session:getVariable("duration") or "0" +local record_seconds = session:getVariable("record_seconds") or "0" +local recording_file = session:getVariable("recording_file") or "" +local context = session:getVariable("context") or "default" +local accountcode = session:getVariable("accountcode") or "" + +-- Build JSON payload +local json_payload = string.format( + '{"uuid":"%s","caller_id_number":"%s","caller_id_name":"%s",'.. + '"destination_number":"%s","direction":"%s","start_epoch":"%s",'.. + '"duration":"%s","record_seconds":"%s","recording_file":"%s",'.. + '"context":"%s","accountcode":"%s"}', + uuid, caller_id_number, caller_id_name, + destination_number, direction, start_epoch, + duration, record_seconds, recording_file, + context, accountcode +) + +-- POST to telephony adapter webhook +local webhook_url = "http://localhost:8080/webhook/recording" +freeswitch.consoleLog("INFO", "Posting recording webhook for " .. uuid .. " to " .. webhook_url .. "\n") + +-- Use curl via api +local cmd = string.format( + 'system curl -s -X POST "%s" -H "Content-Type: application/json" -d \'%s\'', + webhook_url, json_payload +) +api:executeString(cmd) + +freeswitch.consoleLog("INFO", "Recording webhook posted for " .. uuid .. "\n") diff --git a/tests/e2e/freeswitch/freeswitch_configs/test_extensions.xml b/tests/e2e/freeswitch/freeswitch_configs/test_extensions.xml new file mode 100644 index 0000000..eb9a139 --- /dev/null +++ b/tests/e2e/freeswitch/freeswitch_configs/test_extensions.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/e2e/freeswitch/mock_conserver.py b/tests/e2e/freeswitch/mock_conserver.py new file mode 100644 index 0000000..b178183 --- /dev/null +++ b/tests/e2e/freeswitch/mock_conserver.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Mock conserver endpoint for testing FreeSWITCH + vCon pipeline. + +Accepts POST /api/vcons to receive vCon JSON and saves to disk. +Provides GET /api/vcons/{uuid} to retrieve saved vCons. +""" + +import json +import os +from datetime import datetime + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +app = FastAPI(title="Mock vCon Conserver", version="0.1.0") + +VCON_STORAGE_DIR = os.environ.get("VCON_STORAGE_DIR", "./vcons") +os.makedirs(VCON_STORAGE_DIR, exist_ok=True) + +# In-memory index +vcon_index: dict[str, str] = {} # uuid -> filepath + + +def _load_index(): + """Load existing vCons from disk into index.""" + for filename in os.listdir(VCON_STORAGE_DIR): + if filename.endswith(".json"): + uuid = filename.replace(".json", "") + vcon_index[uuid] = os.path.join(VCON_STORAGE_DIR, filename) + + +@app.on_event("startup") +async def startup(): + _load_index() + print(f"Mock conserver started. Storage: {VCON_STORAGE_DIR}") + print(f"Loaded {len(vcon_index)} existing vCons") + + +@app.get("/health") +async def health(): + return {"status": "healthy", "service": "mock-conserver", "vcon_count": len(vcon_index)} + + +@app.post("/api/vcons") +async def create_vcon(request: Request): + """Receive and store a vCon.""" + try: + vcon_data = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + uuid = vcon_data.get("uuid", "") + if not uuid: + raise HTTPException(status_code=400, detail="vCon missing uuid field") + + filepath = os.path.join(VCON_STORAGE_DIR, f"{uuid}.json") + with open(filepath, "w") as f: + json.dump(vcon_data, f, indent=2, default=str) + + vcon_index[uuid] = filepath + + parties = vcon_data.get("parties", []) + dialog_count = len(vcon_data.get("dialog", [])) + analysis_count = len(vcon_data.get("analysis", [])) + + print(f"[{datetime.now().isoformat()}] Received vCon: {uuid}") + print(f" Parties: {len(parties)}, Dialogs: {dialog_count}, Analysis: {analysis_count}") + for i, party in enumerate(parties): + tel = party.get("tel", party.get("mailto", "unknown")) + name = party.get("name", "") + print(f" Party {i}: {tel} ({name})") + + return JSONResponse( + status_code=201, + content={"uuid": uuid, "status": "stored", "path": filepath}, + ) + + +@app.get("/api/vcons") +async def list_vcons(): + """List all stored vCons.""" + vcons = [] + for uuid, filepath in vcon_index.items(): + try: + with open(filepath) as f: + data = json.load(f) + vcons.append({ + "uuid": uuid, + "created_at": data.get("created_at", ""), + "parties": len(data.get("parties", [])), + "dialogs": len(data.get("dialog", [])), + }) + except Exception: + vcons.append({"uuid": uuid, "error": "failed to read"}) + return {"vcons": vcons, "total": len(vcons)} + + +@app.get("/api/vcons/{uuid}") +async def get_vcon(uuid: str): + """Retrieve a stored vCon by UUID.""" + if uuid not in vcon_index: + raise HTTPException(status_code=404, detail=f"vCon {uuid} not found") + + with open(vcon_index[uuid]) as f: + return json.load(f) + + +@app.delete("/api/vcons/{uuid}") +async def delete_vcon(uuid: str): + """Delete a stored vCon.""" + if uuid not in vcon_index: + raise HTTPException(status_code=404, detail=f"vCon {uuid} not found") + + os.remove(vcon_index[uuid]) + del vcon_index[uuid] + return {"status": "deleted", "uuid": uuid} + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "8000")) + print(f"Starting mock conserver on port {port}") + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/tests/e2e/freeswitch/requirements.txt b/tests/e2e/freeswitch/requirements.txt new file mode 100644 index 0000000..e980412 --- /dev/null +++ b/tests/e2e/freeswitch/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +websockets>=13.0 diff --git a/tests/e2e/freeswitch/setup_local.sh b/tests/e2e/freeswitch/setup_local.sh new file mode 100755 index 0000000..9da86d6 --- /dev/null +++ b/tests/e2e/freeswitch/setup_local.sh @@ -0,0 +1,281 @@ +#!/bin/bash +# Setup script for a local FreeSWITCH + vCon test environment (macOS/Homebrew). +# Run this on whichever machine hosts the local FreeSWITCH instance. +set -e + +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +GITHUB_DIR="$HOME/Documents/GitHub" +FS_CONFIG="/opt/homebrew/etc/freeswitch" +FS_BIN="/opt/homebrew/opt/freeswitch/bin" + +# ============================================ +# Step 1: Verify FreeSWITCH is installed +# ============================================ +log_info "=== Step 1: Verify FreeSWITCH ===" +if brew list freeswitch > /dev/null 2>&1; then + log_info "FreeSWITCH is installed" + $FS_BIN/freeswitch -version 2>/dev/null || log_warn "Could not get version" +else + log_error "FreeSWITCH not installed. Run: brew install freeswitch" + exit 1 +fi + +# ============================================ +# Step 2: Docker Desktop + Redis +# ============================================ +log_info "=== Step 2: Docker + Redis ===" +if docker info > /dev/null 2>&1; then + log_info "Docker is running" +else + log_warn "Docker is not running. Attempting to start Docker Desktop..." + open -a Docker + log_info "Waiting for Docker to start (up to 60s)..." + for i in $(seq 1 60); do + if docker info > /dev/null 2>&1; then + log_info "Docker started after ${i}s" + break + fi + sleep 1 + done + if ! docker info > /dev/null 2>&1; then + log_error "Docker failed to start. Please start Docker Desktop manually." + exit 1 + fi +fi + +# Start Redis +if docker ps --format '{{.Names}}' | grep -q '^redis$'; then + log_info "Redis container already running" +elif docker ps -a --format '{{.Names}}' | grep -q '^redis$'; then + log_info "Starting existing Redis container..." + docker start redis +else + log_info "Creating and starting Redis container..." + docker run -d --name redis -p 6379:6379 redis:7-alpine +fi + +# Verify Redis +sleep 2 +if docker exec redis redis-cli ping 2>/dev/null | grep -q PONG; then + log_info "Redis is responding" +else + log_error "Redis is not responding" + exit 1 +fi + +# ============================================ +# Step 3: Clone repos +# ============================================ +log_info "=== Step 3: Clone repos ===" +mkdir -p "$GITHUB_DIR" +cd "$GITHUB_DIR" + +if [ -d "vcon-telephony-adapters" ]; then + log_info "vcon-telephony-adapters already exists, pulling latest..." + cd vcon-telephony-adapters && git pull && cd .. +else + log_info "Cloning vcon-telephony-adapters..." + gh repo clone vcon-dev/vcon-telephony-adapters +fi + +if [ -d "vcon-real-time" ]; then + log_info "vcon-real-time already exists, pulling latest..." + cd vcon-real-time && git pull && cd .. +else + log_info "Cloning vcon-real-time..." + gh repo clone VCONIC/vcon-real-time +fi + +if [ -d "vcon-freeswitch-tester" ]; then + log_info "vcon-freeswitch-tester already exists, pulling latest..." + cd vcon-freeswitch-tester && git pull && cd .. +else + log_info "Cloning vcon-freeswitch-tester..." + gh repo clone VCONIC/vcon-freeswitch-tester 2>/dev/null || { + log_warn "Could not clone vcon-freeswitch-tester from GitHub, copying from local..." + } +fi + +# ============================================ +# Step 4: Set up Python environments +# ============================================ +log_info "=== Step 4: Python environments ===" + +# vcon-telephony-adapters +log_info "Setting up vcon-telephony-adapters..." +cd "$GITHUB_DIR/vcon-telephony-adapters" +if [ ! -d ".venv" ]; then + uv venv --python 3.12 +fi +# Install from requirements.txt if it exists, else pyproject.toml +if [ -f "requirements.txt" ]; then + uv pip install -r requirements.txt +elif [ -f "pyproject.toml" ]; then + uv pip install -e "." 2>/dev/null || uv pip install -e ".[dev]" 2>/dev/null || { + log_warn "Could not install from pyproject.toml, trying requirements..." + } +fi + +# vcon-real-time (skip GPU deps, use CPU torch) +log_info "Setting up vcon-real-time..." +cd "$GITHUB_DIR/vcon-real-time" +if [ ! -d ".venv" ]; then + uv venv --python 3.12 +fi +# Install CPU-only torch to save space, then install the package +uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu 2>/dev/null || { + log_warn "CPU-only torch not available for this platform, installing standard torch..." + uv pip install torch torchaudio +} +uv pip install -e "." + +# ============================================ +# Step 5: Configure FreeSWITCH +# ============================================ +log_info "=== Step 5: Configure FreeSWITCH ===" + +# Ensure recordings directory exists +RECORDINGS_DIR="/opt/homebrew/var/lib/freeswitch/recordings" +mkdir -p "$RECORDINGS_DIR" +log_info "Recordings directory: $RECORDINGS_DIR" + +# Ensure scripts directory exists +SCRIPTS_DIR="$FS_CONFIG/scripts" +mkdir -p "$SCRIPTS_DIR" + +# Copy Lua webhook script +if [ -f "$GITHUB_DIR/vcon-freeswitch-tester/freeswitch_configs/recording_webhook.lua" ]; then + cp "$GITHUB_DIR/vcon-freeswitch-tester/freeswitch_configs/recording_webhook.lua" "$SCRIPTS_DIR/" + log_info "Installed recording_webhook.lua" +fi + +# Backup and update event_socket config +if [ -f "$FS_CONFIG/autoload_configs/event_socket.conf.xml" ]; then + cp "$FS_CONFIG/autoload_configs/event_socket.conf.xml" "$FS_CONFIG/autoload_configs/event_socket.conf.xml.bak" +fi +if [ -f "$GITHUB_DIR/vcon-freeswitch-tester/freeswitch_configs/event_socket.conf.xml" ]; then + cp "$GITHUB_DIR/vcon-freeswitch-tester/freeswitch_configs/event_socket.conf.xml" "$FS_CONFIG/autoload_configs/" + log_info "Updated event_socket.conf.xml" +fi + +# Add test extensions to dialplan +DIALPLAN="$FS_CONFIG/dialplan/default.xml" +if [ -f "$DIALPLAN" ]; then + if grep -q "test_record_1000" "$DIALPLAN"; then + log_info "Test extensions already in dialplan" + else + log_info "Adding test extensions to dialplan..." + cp "$DIALPLAN" "${DIALPLAN}.bak" + # Insert test extensions before + if [ -f "$GITHUB_DIR/vcon-freeswitch-tester/freeswitch_configs/test_extensions.xml" ]; then + # Remove XML comments from test_extensions.xml and insert before + EXTENSIONS=$(grep -v '^\s*') + # Use python to do the insertion safely + python3 -c " +import sys +with open('$DIALPLAN', 'r') as f: + content = f.read() +extensions = '''$EXTENSIONS''' +# Insert before the last +content = content.replace('', extensions + '\n', 1) +with open('$DIALPLAN', 'w') as f: + f.write(content) +print('Test extensions added to dialplan') +" + fi + fi +else + log_warn "Dialplan not found at $DIALPLAN" +fi + +# ============================================ +# Step 6: Create .env files +# ============================================ +log_info "=== Step 6: Create .env files ===" + +# Telephony adapter .env +cat > "$GITHUB_DIR/vcon-telephony-adapters/.env" << 'ENVEOF' +CONSERVER_URL=http://localhost:8000 +PORT=8080 +HOST=0.0.0.0 +FREESWITCH_HOST=localhost +FREESWITCH_ESL_PORT=8021 +FREESWITCH_ESL_PASSWORD=ClueCon +FREESWITCH_RECORDINGS_PATH=/opt/homebrew/var/lib/freeswitch/recordings +DOWNLOAD_RECORDINGS=true +RECORDING_FORMAT=wav +VALIDATE_FREESWITCH_WEBHOOK=false +LOG_LEVEL=DEBUG +ENVEOF +log_info "Created vcon-telephony-adapters/.env" + +# vcon-real-time .env +cat > "$GITHUB_DIR/vcon-real-time/.env" << 'ENVEOF' +STT_ENGINE=mock +WS_PORT=9090 +HEALTH_PORT=8081 +DASH_PORT=8091 +REDIS_URL=redis://localhost:6379 +ESL_HOST=localhost +ESL_PORT=8021 +ESL_PASSWORD=ClueCon +CONSERVER_URL=http://localhost:8000 +LOG_LEVEL=DEBUG +ENVEOF +log_info "Created vcon-real-time/.env" + +# ============================================ +# Step 7: Start FreeSWITCH +# ============================================ +log_info "=== Step 7: Start FreeSWITCH ===" +if $FS_BIN/fs_cli -x "status" > /dev/null 2>&1; then + log_info "FreeSWITCH already running, reloading config..." + $FS_BIN/fs_cli -x "reloadxml" +else + log_info "Starting FreeSWITCH..." + brew services start freeswitch + sleep 3 + if $FS_BIN/fs_cli -x "status" > /dev/null 2>&1; then + log_info "FreeSWITCH started successfully" + else + log_error "FreeSWITCH failed to start. Check: brew services list" + fi +fi + +# ============================================ +# Summary +# ============================================ +echo "" +echo "============================================" +echo " Setup Complete!" +echo "============================================" +echo "" +echo "Services:" +echo " - Redis: docker exec redis redis-cli ping" +echo " - FreeSWITCH: $FS_BIN/fs_cli -x 'status'" +echo "" +echo "To run the test pipeline:" +echo " 1. cd $GITHUB_DIR/vcon-freeswitch-tester" +echo " 2. pip install -r requirements.txt (for mock conserver)" +echo " 3. python mock_conserver.py &" +echo " 4. cd $GITHUB_DIR/vcon-telephony-adapters && .venv/bin/python main.py freeswitch &" +echo " 5. cd $GITHUB_DIR/vcon-real-time && STT_ENGINE=mock .venv/bin/vcon-realtime-server &" +echo " 6. cd $GITHUB_DIR/vcon-real-time && .venv/bin/vcon-realtime-bridge &" +echo " 7. $FS_BIN/fs_cli -x 'originate loopback/9196 &echo'" +echo "" +echo "Test scripts:" +echo " - test_call.sh - Make test calls via fs_cli" +echo " - test_webhook.sh - Simulate recording webhook" +echo " - test_ws_client.py - WebSocket audio stream test" +echo " - test_e2e.sh - Full pipeline test" +echo "" diff --git a/tests/e2e/freeswitch/test_call.sh b/tests/e2e/freeswitch/test_call.sh new file mode 100755 index 0000000..11c5b18 --- /dev/null +++ b/tests/e2e/freeswitch/test_call.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Test call via FreeSWITCH fs_cli +# Makes a test call to the echo extension (9196) to verify FreeSWITCH is working +set -e + +FS_CLI="/opt/homebrew/opt/freeswitch/bin/fs_cli" + +echo "=== FreeSWITCH Test Call ===" + +# Check FreeSWITCH is running +if ! $FS_CLI -x "status" > /dev/null 2>&1; then + echo "ERROR: FreeSWITCH is not running. Start with: brew services start freeswitch" + exit 1 +fi + +echo "FreeSWITCH is running." +echo "" + +# Show current channels +echo "Current channels:" +$FS_CLI -x "show channels" +echo "" + +# Test 1: Echo test (loopback call to echo extension) +echo "--- Test 1: Echo loopback call ---" +$FS_CLI -x "originate loopback/9196 &echo" +echo "Echo call originated. Waiting 3 seconds..." +sleep 3 + +# Show active channels +echo "Active channels:" +$FS_CLI -x "show channels" +echo "" + +# Hang up all channels +echo "Hanging up all channels..." +$FS_CLI -x "hupall" +sleep 1 + +# Test 2: Record test call (calls extension that records) +echo "--- Test 2: Record test call ---" +RECORDING_FILE="/opt/homebrew/var/lib/freeswitch/recordings/test_$(date +%s).wav" +$FS_CLI -x "originate loopback/9196 &record($RECORDING_FILE 5 16000)" +echo "Recording call originated. Recording for 5 seconds..." +sleep 6 + +# Hang up +$FS_CLI -x "hupall" +sleep 1 + +# Check if recording was created +if [ -f "$RECORDING_FILE" ]; then + echo "Recording created: $RECORDING_FILE" + ls -la "$RECORDING_FILE" +else + echo "WARNING: Recording file not found at $RECORDING_FILE" + echo "Checking recordings directory:" + ls -la /opt/homebrew/var/lib/freeswitch/recordings/ 2>/dev/null || echo " Directory does not exist" +fi + +echo "" +echo "=== Test Complete ===" diff --git a/tests/e2e/freeswitch/test_e2e.sh b/tests/e2e/freeswitch/test_e2e.sh new file mode 100755 index 0000000..a8addf8 --- /dev/null +++ b/tests/e2e/freeswitch/test_e2e.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# End-to-end test for FreeSWITCH + vCon pipeline +# Starts all services, makes a test call, and verifies vCon output +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GITHUB_DIR="$HOME/Documents/GitHub" +VCON_DIR="$SCRIPT_DIR/vcons" +PIDS=() + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +cleanup() { + log_info "Cleaning up..." + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + wait 2>/dev/null + log_info "Cleanup complete." +} +trap cleanup EXIT + +check_prereqs() { + log_info "Checking prerequisites..." + + # Redis + if ! redis-cli ping > /dev/null 2>&1; then + log_error "Redis is not running. Start with: docker start redis" + exit 1 + fi + log_info " Redis: OK" + + # FreeSWITCH + FS_CLI="/opt/homebrew/opt/freeswitch/bin/fs_cli" + if ! $FS_CLI -x "status" > /dev/null 2>&1; then + log_error "FreeSWITCH is not running. Start with: brew services start freeswitch" + exit 1 + fi + log_info " FreeSWITCH: OK" + + # Project directories + if [ ! -d "$GITHUB_DIR/vcon-telephony-adapters" ]; then + log_error "vcon-telephony-adapters not found in $GITHUB_DIR" + exit 1 + fi + log_info " vcon-telephony-adapters: OK" + + if [ ! -d "$GITHUB_DIR/vcon-real-time" ]; then + log_error "vcon-real-time not found in $GITHUB_DIR" + exit 1 + fi + log_info " vcon-real-time: OK" +} + +start_mock_conserver() { + log_info "Starting mock conserver on port 8000..." + mkdir -p "$VCON_DIR" + cd "$SCRIPT_DIR" + VCON_STORAGE_DIR="$VCON_DIR" python3 mock_conserver.py & + PIDS+=($!) + sleep 2 + + if curl -s http://localhost:8000/health > /dev/null 2>&1; then + log_info " Mock conserver: OK" + else + log_error " Mock conserver failed to start" + exit 1 + fi +} + +start_telephony_adapter() { + log_info "Starting telephony adapter (FreeSWITCH) on port 8080..." + cd "$GITHUB_DIR/vcon-telephony-adapters" + source .venv/bin/activate 2>/dev/null || true + python main.py freeswitch & + PIDS+=($!) + sleep 3 + + if curl -s http://localhost:8080/health > /dev/null 2>&1; then + log_info " Telephony adapter: OK" + else + log_warn " Telephony adapter may not be ready yet" + fi +} + +start_realtime_server() { + log_info "Starting vcon-realtime-server on port 9090..." + cd "$GITHUB_DIR/vcon-real-time" + source .venv/bin/activate 2>/dev/null || true + STT_ENGINE=mock vcon-realtime-server & + PIDS+=($!) + sleep 3 + + if curl -s http://localhost:8080/health > /dev/null 2>&1; then + log_info " Realtime server: OK (health)" + else + log_warn " Realtime server health check not available (may still be working)" + fi +} + +start_realtime_bridge() { + log_info "Starting vcon-realtime-bridge (ESL -> Redis)..." + cd "$GITHUB_DIR/vcon-real-time" + source .venv/bin/activate 2>/dev/null || true + vcon-realtime-bridge & + PIDS+=($!) + sleep 2 + log_info " Realtime bridge: started" +} + +test_echo_call() { + log_info "=== Test 1: Echo loopback call ===" + FS_CLI="/opt/homebrew/opt/freeswitch/bin/fs_cli" + + $FS_CLI -x "originate loopback/9196 &echo" + log_info " Echo call originated" + sleep 3 + + CHANNELS=$($FS_CLI -x "show channels count" 2>/dev/null || echo "0") + log_info " Active channels: $CHANNELS" + + $FS_CLI -x "hupall" + sleep 1 + log_info " Channels hung up" +} + +test_recording_webhook() { + log_info "=== Test 2: Recording webhook ===" + UUID=$(uuidgen | tr '[:upper:]' '[:lower:]') + TIMESTAMP=$(date +%s) + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "http://localhost:8080/webhook/recording" \ + -H "Content-Type: application/json" \ + -d "{ + \"uuid\": \"$UUID\", + \"caller_id_number\": \"+15551234567\", + \"caller_id_name\": \"E2E Test\", + \"destination_number\": \"+15559876543\", + \"direction\": \"inbound\", + \"duration\": \"10\", + \"record_seconds\": \"8\", + \"start_epoch\": \"$TIMESTAMP\", + \"recording_file\": \"/tmp/test_recording.wav\", + \"context\": \"default\" + }") + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + log_info " Webhook response: HTTP $HTTP_CODE" +} + +test_websocket_client() { + log_info "=== Test 3: WebSocket audio stream ===" + cd "$SCRIPT_DIR" + python3 test_ws_client.py --duration 3.0 --url ws://localhost:9090/ws 2>&1 | head -20 || { + log_warn " WebSocket test had issues (may be expected if server not fully up)" + } +} + +check_results() { + log_info "=== Checking Results ===" + + # Check mock conserver for received vCons + VCON_RESPONSE=$(curl -s http://localhost:8000/api/vcons 2>/dev/null || echo '{"total":0}') + VCON_COUNT=$(echo "$VCON_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))" 2>/dev/null || echo "0") + log_info " vCons received by conserver: $VCON_COUNT" + + # Check Redis for stream data + REDIS_KEYS=$(redis-cli keys "stream:*" 2>/dev/null | wc -l || echo "0") + log_info " Redis stream keys: $REDIS_KEYS" + + # Check vCon files on disk + VCON_FILES=$(ls "$VCON_DIR"/*.json 2>/dev/null | wc -l || echo "0") + log_info " vCon files on disk: $VCON_FILES" + + # List vCon files + if [ "$VCON_FILES" -gt 0 ]; then + log_info " vCon files:" + for f in "$VCON_DIR"/*.json; do + UUID=$(basename "$f" .json) + log_info " - $UUID" + done + fi +} + +# --- Main --- +echo "============================================" +echo " FreeSWITCH + vCon End-to-End Test" +echo "============================================" +echo "" + +check_prereqs +echo "" + +start_mock_conserver +start_telephony_adapter +start_realtime_server +start_realtime_bridge +echo "" + +test_echo_call +echo "" + +test_recording_webhook +echo "" + +test_websocket_client +echo "" + +check_results +echo "" + +log_info "=== E2E Test Complete ===" +log_info "Services are still running. Press Ctrl+C to stop." +echo "" + +# Keep running until interrupted +wait diff --git a/tests/e2e/freeswitch/test_webhook.sh b/tests/e2e/freeswitch/test_webhook.sh new file mode 100755 index 0000000..5d4210d --- /dev/null +++ b/tests/e2e/freeswitch/test_webhook.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Simulate a FreeSWITCH recording webhook to the telephony adapter +set -e + +ADAPTER_URL="${ADAPTER_URL:-http://localhost:8080}" +UUID="${1:-$(uuidgen | tr '[:upper:]' '[:lower:]')}" +RECORDING_FILE="${2:-/opt/homebrew/var/lib/freeswitch/recordings/test_recording.wav}" + +echo "=== FreeSWITCH Webhook Test ===" +echo "Adapter URL: $ADAPTER_URL" +echo "Call UUID: $UUID" +echo "Recording file: $RECORDING_FILE" +echo "" + +# Check adapter health +echo "--- Checking adapter health ---" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$ADAPTER_URL/health" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + echo "Adapter is healthy" + curl -s "$ADAPTER_URL/health" | python3 -m json.tool 2>/dev/null || true +else + echo "WARNING: Adapter health check returned HTTP $HTTP_CODE" + echo "Make sure the telephony adapter is running: cd ~/Documents/GitHub/vcon-telephony-adapters && python main.py freeswitch" +fi +echo "" + +# Send recording webhook +echo "--- Sending recording webhook ---" +TIMESTAMP=$(date +%s) + +RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$ADAPTER_URL/webhook/recording" \ + -H "Content-Type: application/json" \ + -d "{ + \"uuid\": \"$UUID\", + \"call_uuid\": \"$UUID\", + \"caller_id_number\": \"+15551234567\", + \"caller_id_name\": \"Test Caller\", + \"destination_number\": \"+15559876543\", + \"direction\": \"inbound\", + \"duration\": \"30\", + \"record_seconds\": \"28\", + \"start_epoch\": \"$TIMESTAMP\", + \"recording_file\": \"$RECORDING_FILE\", + \"context\": \"default\", + \"accountcode\": \"test\", + \"sip_user_agent\": \"FreeSWITCH-Test\" + }") + +HTTP_CODE=$(echo "$RESPONSE" | tail -1) +BODY=$(echo "$RESPONSE" | head -n -1) + +echo "HTTP Status: $HTTP_CODE" +echo "Response: $BODY" +echo "" + +# Check recording status +echo "--- Checking recording status ---" +STATUS_RESPONSE=$(curl -s -w "\n%{http_code}" "$ADAPTER_URL/status/$UUID" 2>/dev/null) +STATUS_CODE=$(echo "$STATUS_RESPONSE" | tail -1) +STATUS_BODY=$(echo "$STATUS_RESPONSE" | head -n -1) + +if [ "$STATUS_CODE" = "200" ]; then + echo "Status: $STATUS_BODY" | python3 -m json.tool 2>/dev/null || echo "$STATUS_BODY" +else + echo "Status endpoint returned HTTP $STATUS_CODE" +fi + +echo "" +echo "=== Webhook Test Complete ===" diff --git a/tests/e2e/freeswitch/test_ws_client.py b/tests/e2e/freeswitch/test_ws_client.py new file mode 100644 index 0000000..4ef6626 --- /dev/null +++ b/tests/e2e/freeswitch/test_ws_client.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""WebSocket client that sends mock PCM audio to vcon-realtime-server. + +Tests the real-time transcription pipeline by connecting to the +WebSocket server and streaming synthetic audio data. +""" + +import argparse +import asyncio +import json +import struct +import sys +import uuid + +try: + import websockets +except ImportError: + print("Install websockets: pip install websockets") + sys.exit(1) + + +def generate_silence(sample_rate: int, duration_ms: int) -> bytes: + """Generate silence (zero-valued PCM samples).""" + num_samples = int(sample_rate * duration_ms / 1000) + return struct.pack(f"<{num_samples}h", *([0] * num_samples)) + + +def generate_tone(sample_rate: int, duration_ms: int, frequency: float = 440.0) -> bytes: + """Generate a sine wave tone as PCM16 samples.""" + import math + + num_samples = int(sample_rate * duration_ms / 1000) + samples = [] + for i in range(num_samples): + t = i / sample_rate + value = int(16000 * math.sin(2 * math.pi * frequency * t)) + samples.append(max(-32768, min(32767, value))) + return struct.pack(f"<{num_samples}h", *samples) + + +async def run_client( + server_url: str, + call_id: str, + agent_id: str, + sample_rate: int = 16000, + duration_s: float = 5.0, + chunk_ms: int = 20, +): + """Connect to WebSocket server and stream audio.""" + print(f"Connecting to {server_url}") + print(f" Call ID: {call_id}") + print(f" Agent ID: {agent_id}") + print(f" Sample rate: {sample_rate} Hz") + print(f" Duration: {duration_s}s") + print() + + async with websockets.connect(server_url) as ws: + # Send start message + start_msg = json.dumps({ + "type": "start", + "call_id": call_id, + "agent_id": agent_id, + "sample_rate": sample_rate, + "encoding": "pcm16", + "channels": 1, + "caller_number": "+15551234567", + "callee_number": "+15559876543", + }) + await ws.send(start_msg) + print(f"Sent start message") + + # Wait for acknowledgment + try: + ack = await asyncio.wait_for(ws.recv(), timeout=5.0) + print(f"Server response: {ack}") + except asyncio.TimeoutError: + print("No acknowledgment received (continuing anyway)") + + # Stream audio chunks + total_chunks = int(duration_s * 1000 / chunk_ms) + chunk_interval = chunk_ms / 1000.0 + + print(f"Streaming {total_chunks} chunks ({chunk_ms}ms each)...") + + for i in range(total_chunks): + # Alternate between tone and silence to simulate speech + if (i // 25) % 2 == 0: # 500ms speech, 500ms silence + chunk = generate_tone(sample_rate, chunk_ms, frequency=440.0) + else: + chunk = generate_silence(sample_rate, chunk_ms) + + await ws.send(chunk) + + # Listen for any transcription results (non-blocking) + try: + result = await asyncio.wait_for(ws.recv(), timeout=0.001) + data = json.loads(result) if isinstance(result, str) else result + if isinstance(data, dict) and data.get("type") == "transcription": + text = data.get("text", "") + is_final = data.get("is_final", False) + marker = "[FINAL]" if is_final else "[partial]" + print(f" {marker} {text}") + except (asyncio.TimeoutError, Exception): + pass + + await asyncio.sleep(chunk_interval) + + # Send stop message + stop_msg = json.dumps({"type": "stop", "call_id": call_id}) + await ws.send(stop_msg) + print(f"\nSent stop message") + + # Wait for final results + print("Waiting for final results...") + try: + while True: + result = await asyncio.wait_for(ws.recv(), timeout=3.0) + if isinstance(result, str): + data = json.loads(result) + print(f" Server: {json.dumps(data, indent=2)}") + if data.get("type") in ("end", "session_ended", "error"): + break + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + pass + + print("\nWebSocket test complete.") + + +def main(): + parser = argparse.ArgumentParser(description="Test WebSocket client for vcon-realtime-server") + parser.add_argument("--url", default="ws://localhost:9090/ws", help="WebSocket server URL") + parser.add_argument("--call-id", default=None, help="Call ID (auto-generated if not set)") + parser.add_argument("--agent-id", default="test-agent-001", help="Agent ID") + parser.add_argument("--sample-rate", type=int, default=16000, help="Audio sample rate") + parser.add_argument("--duration", type=float, default=5.0, help="Stream duration in seconds") + parser.add_argument("--chunk-ms", type=int, default=20, help="Chunk size in milliseconds") + args = parser.parse_args() + + call_id = args.call_id or str(uuid.uuid4()) + + asyncio.run(run_client( + server_url=args.url, + call_id=call_id, + agent_id=args.agent_id, + sample_rate=args.sample_rate, + duration_s=args.duration, + chunk_ms=args.chunk_ms, + )) + + +if __name__ == "__main__": + main() From 05b57e516d69c3751f926e444bb39ea6d33982de Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Fri, 25 Sep 2026 18:50:23 -0400 Subject: [PATCH 9/9] fix(signalwire): coerce recording duration to a number Schema-validated a sample SignalWire vCon (LAWFUL_BASIS=consent, MEDIA_BACKEND=filesystem) against the WG JSON schema and found recording.get("duration") was passed straight through as the string SignalWire's Recordings.json actually returns (e.g. "30"), failing validation on dialog[0].duration. Coerces to float, logging and omitting the field on anything unparseable rather than raising. Adds regression tests for both the coercion and the omit-on-failure path. Also fixes ruff/black findings in tests/e2e/freeswitch/ (an f-string with no placeholders, a bare `raise` inside an except that should chain `from None`, formatting) so `ruff check .` / `black --check .` stay clean across the whole repo even though this directory is excluded from the pytest run. Co-Authored-By: Claude Opus 5.5 --- adapters/signalwire/builder.py | 15 +++++++- tests/adapters/signalwire/test_builder.py | 18 ++++++++++ tests/e2e/freeswitch/mock_conserver.py | 16 +++++---- tests/e2e/freeswitch/test_ws_client.py | 44 ++++++++++++----------- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/adapters/signalwire/builder.py b/adapters/signalwire/builder.py index 9dbf66f..94f4152 100644 --- a/adapters/signalwire/builder.py +++ b/adapters/signalwire/builder.py @@ -75,6 +75,19 @@ def _strip_empty_placeholders(d: dict, keys: tuple = ("meta", "metadata")) -> No del d[k] +def _as_float(value: Any) -> float | None: + """SignalWire's Recordings.json returns `duration` as a numeric string + (e.g. "30"), not a number; the vCon spec's dialog `duration` is numeric, + so this coerces rather than passing the string straight through.""" + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + logger.warning(f"Could not parse SignalWire recording duration: {value!r}") + return None + + class SignalWireVconBuilder: """Builds one vCon per SignalWire call, with one dialog per recording.""" @@ -154,7 +167,7 @@ def _add_recording(self, vcon: Vcon, recording: dict[str, Any], dialog_idx: int) "start": start_iso, "parties": [0, 1], "type": "recording", - "duration": recording.get("duration"), + "duration": _as_float(recording.get("duration")), "mediatype": "audio/wav", } diff --git a/tests/adapters/signalwire/test_builder.py b/tests/adapters/signalwire/test_builder.py index b9a8a92..234716e 100644 --- a/tests/adapters/signalwire/test_builder.py +++ b/tests/adapters/signalwire/test_builder.py @@ -84,3 +84,21 @@ def test_dialogs_use_mediatype_not_mimetype(self): recording = next(d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording") assert recording.get("mediatype") == "audio/wav" assert "mimetype" not in recording + + def test_duration_string_coerced_to_number(self): + """SignalWire's Recordings.json returns duration as a numeric string + (e.g. "30"); the dialog's duration field must be numeric, not a str, + or it fails WG schema validation (found via schema check, CON-703).""" + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(make_data(num_recordings=1)) + recording = next(d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording") + assert recording["duration"] == 30.0 + assert isinstance(recording["duration"], float) + + def test_unparseable_duration_omitted_without_raising(self): + data = make_data(num_recordings=1) + data.recordings[0]["duration"] = "not-a-number" + builder = SignalWireVconBuilder(download_recordings=False) + vcon = builder.build(data) + recording = next(d for d in vcon.vcon_dict["dialog"] if d.get("type") == "recording") + assert recording.get("duration") is None diff --git a/tests/e2e/freeswitch/mock_conserver.py b/tests/e2e/freeswitch/mock_conserver.py index b178183..fa51556 100644 --- a/tests/e2e/freeswitch/mock_conserver.py +++ b/tests/e2e/freeswitch/mock_conserver.py @@ -48,7 +48,7 @@ async def create_vcon(request: Request): try: vcon_data = await request.json() except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON") + raise HTTPException(status_code=400, detail="Invalid JSON") from None uuid = vcon_data.get("uuid", "") if not uuid: @@ -85,12 +85,14 @@ async def list_vcons(): try: with open(filepath) as f: data = json.load(f) - vcons.append({ - "uuid": uuid, - "created_at": data.get("created_at", ""), - "parties": len(data.get("parties", [])), - "dialogs": len(data.get("dialog", [])), - }) + vcons.append( + { + "uuid": uuid, + "created_at": data.get("created_at", ""), + "parties": len(data.get("parties", [])), + "dialogs": len(data.get("dialog", [])), + } + ) except Exception: vcons.append({"uuid": uuid, "error": "failed to read"}) return {"vcons": vcons, "total": len(vcons)} diff --git a/tests/e2e/freeswitch/test_ws_client.py b/tests/e2e/freeswitch/test_ws_client.py index 4ef6626..5263efc 100644 --- a/tests/e2e/freeswitch/test_ws_client.py +++ b/tests/e2e/freeswitch/test_ws_client.py @@ -56,18 +56,20 @@ async def run_client( async with websockets.connect(server_url) as ws: # Send start message - start_msg = json.dumps({ - "type": "start", - "call_id": call_id, - "agent_id": agent_id, - "sample_rate": sample_rate, - "encoding": "pcm16", - "channels": 1, - "caller_number": "+15551234567", - "callee_number": "+15559876543", - }) + start_msg = json.dumps( + { + "type": "start", + "call_id": call_id, + "agent_id": agent_id, + "sample_rate": sample_rate, + "encoding": "pcm16", + "channels": 1, + "caller_number": "+15551234567", + "callee_number": "+15559876543", + } + ) await ws.send(start_msg) - print(f"Sent start message") + print("Sent start message") # Wait for acknowledgment try: @@ -108,7 +110,7 @@ async def run_client( # Send stop message stop_msg = json.dumps({"type": "stop", "call_id": call_id}) await ws.send(stop_msg) - print(f"\nSent stop message") + print("\nSent stop message") # Wait for final results print("Waiting for final results...") @@ -138,14 +140,16 @@ def main(): call_id = args.call_id or str(uuid.uuid4()) - asyncio.run(run_client( - server_url=args.url, - call_id=call_id, - agent_id=args.agent_id, - sample_rate=args.sample_rate, - duration_s=args.duration, - chunk_ms=args.chunk_ms, - )) + asyncio.run( + run_client( + server_url=args.url, + call_id=call_id, + agent_id=args.agent_id, + sample_rate=args.sample_rate, + duration_s=args.duration, + chunk_ms=args.chunk_ms, + ) + ) if __name__ == "__main__":