Response:
 Here is a production-grade implementation for the Widget Stripe Billing Integration. This solution uses FastAPI for the API layer, SQLAlchemy with async PostgreSQL for persistence, Redis for real-time usage tracking, and Stripe's Python SDK.

## Project Structure

```
widget_billing/
├── config.py
├── models.py
├── database.py
├── repositories.py
├── services/
│   ├── stripe_client.py
│   ├── usage_tracker.py
│   └── billing_service.py
├── api/
│   └── webhooks.py
└── main.py
```

## 1. Configuration (`config.py`)

```python
"""Application configuration using Pydantic Settings."""
from functools import lru_cache
from typing import Optional

from pydantic import Field, RedisDsn, PostgresDsn
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    """Production-grade configuration management."""

    # Application
    app_name: str = "Widget Billing Service"
    debug: bool = False
    environment: str = Field(default="production", pattern="^(development|staging|production)$")

    # Database
    database_url: PostgresDsn
    database_pool_size: int = Field(default=10, ge=1, le=100)
    database_max_overflow: int = 5

    # Redis
    redis_url: RedisDsn
    redis_pool_size: int = 50

    # Stripe
    stripe_secret_key: str = Field(..., min_length=20)
    stripe_webhook_secret: str = Field(..., min_length=20)
    stripe_api_version: str = "2023-10-16"

    # Business Logic
    grace_period_days: int = 3
    overage_rate_cents: int = 15  # $0.15 per minute
    overage_calculation_precision: int = 2  # Decimal places

    class Config:
        env_file = ".env"
        case_sensitive = False

@lru_cache()
def get_settings() -> Settings:
    """Cached settings instance for dependency injection."""
    return Settings()

settings = get_settings()
```

## 2. Domain Models (`models.py`)

```python
"""Domain models and enumerations."""
from datetime import datetime
from decimal import Decimal, ROUND_UP
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4

from pydantic import BaseModel, ConfigDict, Field, field_validator

class SubscriptionTier(str, Enum):
    """Widget subscription tiers with pricing and usage limits."""
    BASIC = "basic"
    PROFESSIONAL = "professional"
    ENTERPRISE = "enterprise"

    def get_config(self) -> dict:
        """Return tier configuration."""
        configs = {
            SubscriptionTier.BASIC: {
                "monthly_cents": 19700,
                "included_minutes": 100,
                "stripe_price_id": "price_basic_monthly"  # Replace with actual Stripe IDs
            },
            SubscriptionTier.PROFESSIONAL: {
                "monthly_cents": 49700,
                "included_minutes": 500,
                "stripe_price_id": "price_professional_monthly"
            },
            SubscriptionTier.ENTERPRISE: {
                "monthly_cents": 99700,
                "included_minutes": 2000,
                "stripe_price_id": "price_enterprise_monthly"
            }
        }
        return configs[self]

class SubscriptionStatus(str, Enum):
    """Stripe subscription states."""
    ACTIVE = "active"
    PAST_DUE = "past_due"
    UNPAID = "unpaid"
    CANCELED = "canceled"
    INCOMPLETE = "incomplete"
    INCOMPLETE_EXPIRED = "incomplete_expired"
    TRIALING = "trialing"
    PAUSED = "paused"

class WidgetStatus(str, Enum):
    """Widget operational states."""
    ACTIVE = "active"
    SUSPENDED = "suspended"  # Grace period
    DEACTIVATED = "deactivated"
    PENDING = "pending"

class UsageRecord(BaseModel):
    """Voice minute usage record."""
    model_config = ConfigDict(from_attributes=True)

    id: UUID = Field(default_factory=uuid4)
    subscription_id: UUID
    minutes_used: Decimal = Field(..., gt=0, max_digits=10, decimal_places=2)
    recorded_at: datetime = Field(default_factory=datetime.utcnow)
    synced_to_stripe: bool = False
    stripe_timestamp: Optional[int] = None  # Unix timestamp for idempotency

    @field_validator("minutes_used")
    @classmethod
    def validate_minutes(cls, v: Decimal) -> Decimal:
        """Ensure minutes are positive and rounded to 2 decimal places."""
        return v.quantize(Decimal("0.01"))

class Subscription(BaseModel):
    """Customer subscription aggregate."""
    model_config = ConfigDict(from_attributes=True)

    id: UUID = Field(default_factory=uuid4)
    user_id: UUID
    stripe_customer_id: str
    stripe_subscription_id: str
    tier: SubscriptionTier
    status: SubscriptionStatus = SubscriptionStatus.PENDING
    widget_status: WidgetStatus = WidgetStatus.PENDING
    current_period_start: Optional[datetime] = None
    current_period_end: Optional[datetime] = None
    past_due_since: Optional[datetime] = None  # Track grace period start
    canceled_at: Optional[datetime] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)

    def calculate_overage_minutes(self, total_minutes: Decimal) -> int:
        """
        Calculate overage minutes, rounding up partial minutes.

        Args:
            total_minutes: Total minutes consumed in billing period

        Returns:
            Integer minutes to bill for overage (rounded up)
        """
        included = Decimal(str(self.tier.get_config()["included_minutes"]))
        if total_minutes <= included:
            return 0

        overage = total_minutes - included
        # Round up to nearest whole minute for billing
        return int(overage.quantize(Decimal("1"), rounding=ROUND_UP))

class StripeWebhookEvent(BaseModel):
    """Validated Stripe webhook payload."""
    model_config = ConfigDict(extra="allow")

    id: str
    type: str
    data: dict
    created: int
    livemode: bool
```

## 3. Database Layer (`database.py`)

```python
"""Async database configuration with connection pooling."""
from contextlib import asynccontextmanager
from typing import AsyncGenerator

from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine
)
from sqlalchemy.orm import declarative_base

from config import settings

# Convert pydantic PostgresDsn to string for SQLAlchemy
DATABASE_URL = str(settings.database_url)

engine = create_async_engine(
    DATABASE_URL,
    pool_size=settings.database_pool_size,
    max_overflow=settings.database_max_overflow,
    pool_pre_ping=True,  # Verify connections before using from pool
    echo=settings.debug
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autocommit=False,
    autoflush=False
)

Base = declarative_base()

async def init_db() -> None:
    """Initialize database tables (use migrations in production)."""
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

@asynccontextmanager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
    """Provide transactional scope around a series of operations."""
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    """FastAPI dependency for database sessions."""
    async with get_db_session() as session:
        yield session
```

## 4. Repository Layer (`repositories.py`)

```python
"""Data access layer with async PostgreSQL operations."""
from datetime import datetime
from decimal import Decimal
from typing import Optional
from uuid import UUID

from sqlalchemy import select, update, func, and_
from sqlalchemy.ext.asyncio import AsyncSession

from models import Subscription, SubscriptionStatus, UsageRecord, WidgetStatus

class SubscriptionRepository:
    """Repository for subscription persistence."""

    def __init__(self, session: AsyncSession):
        self.session = session

    async def get_by_id(self, subscription_id: UUID) -> Optional[Subscription]:
        """Fetch subscription by ID."""
        result = await self.session.execute(
            select(Subscription).where(Subscription.id == subscription_id)
        )
        return result.scalar_one_or_none()

    async def get_by_stripe_id(self, stripe_sub_id: str) -> Optional[Subscription]:
        """Fetch subscription by Stripe subscription ID."""
        result = await self.session.execute(
            select(Subscription).where(Subscription.stripe_subscription_id == stripe_sub_id)
        )
        return result.scalar_one_or_none()

    async def update_status(
        self,
        subscription_id: UUID,
        status: SubscriptionStatus,
        widget_status: Optional[WidgetStatus] = None
    ) -> None:
        """Update subscription and widget status."""
        updates = {
            "status": status,
            "updated_at": datetime.utcnow()
        }

        if status == SubscriptionStatus.PAST_DUE:
            updates["past_due_since"] = datetime.utcnow()
        elif status == SubscriptionStatus.ACTIVE:
            updates["past_due_since"] = None

        if widget_status:
            updates["widget_status"] = widget_status

        await self.session.execute(
            update(Subscription)
            .where(Subscription.id == subscription_id)
            .values(**updates)
        )

    async def get_total_usage_for_period(
        self,
        subscription_id: UUID,
        period_start: datetime,
        period_end: datetime
    ) -> Decimal:
        """
        Calculate total minutes used in billing period.
        Source of truth for billing calculations.
        """
        result = await self.session.execute(
            select(func.sum(UsageRecord.minutes_used))
            .where(
                and_(
                    UsageRecord.subscription_id == subscription_id,
                    UsageRecord.recorded_at >= period_start,
                    UsageRecord.recorded_at <= period_end
                )
            )
        )
        total = result.scalar()
        return Decimal(str(total)) if total else Decimal("0")

class UsageRepository:
    """Repository for usage tracking persistence."""

    def __init__(self, session: AsyncSession):
        self.session = session

    async def record_usage(self, usage: UsageRecord) -> None:
        """Persist usage record to database."""
        self.session.add(usage)
        await self.session.flush()

    async def mark_synced_to_stripe(self, usage_id: UUID, timestamp: int) -> None:
        """Mark usage record as synced to Stripe."""
        await self.session.execute(
            update(UsageRecord)
            .where(UsageRecord.id == usage_id)
            .values(
                synced_to_stripe=True,
                stripe_timestamp=timestamp
            )
        )
```

## 5. Redis Usage Tracker (`services/usage_tracker.py`)

```python
"""High-performance usage tracking with Redis and PostgreSQL synchronization."""
import json
from decimal import Decimal
from typing import Optional
from uuid import UUID

import redis.asyncio as redis
from structlog import get_logger

from config import settings

logger = get_logger(__name__)

class UsageTracker:
    """
    Manages real-time usage counters in Redis with PostgreSQL persistence.

    Key patterns:
    - usage:{sub_id}:current_period_total -> float (total minutes)
    - usage:{sub_id}:overage_reported -> int (minutes already reported to Stripe)
    - usage:{sub_id}:lock -> str (distributed lock for critical sections)
    """

    def __init__(self, redis_client: Optional[redis.Redis] = None):
        self.redis = redis_client or redis.from_url(
            str(settings.redis_url),
            encoding="utf-8",
            decode_responses=True,
            max_connections=settings.redis_pool_size
        )

    def _key(self, subscription_id: UUID, suffix: str) -> str:
        """Generate Redis key with namespace."""
        return f"usage:{str(subscription_id)}:{suffix}"

    async def increment_minutes(
        self,
        subscription_id: UUID,
        minutes: Decimal,
        tx_id: str  # Idempotency key for duplicate detection
    ) -> Decimal:
        """
        Atomically increment usage counter.

        Args:
            subscription_id: Subscription UUID
            minutes: Minutes to add (supports fractional minutes)
            tx_id: Unique transaction ID for idempotency

        Returns:
            New total minutes for current period
        """
        key = self._key(subscription_id, "current_period_total")

        # Check idempotency first (optional: implement with Redis set for 24h)
        pipe = self.redis.pipeline()
        pipe.incrbyfloat(key, float(minutes))
        pipe.expire(key, 86400 * 35)  # 35 days TTL (covers monthly billing + grace)

        results = await pipe.execute()
        new_total = Decimal(str(results[0]))

        await logger.ainfo(
            "usage_incremented",
            subscription_id=str(subscription_id),
            added=float(minutes),
            new_total=float(new_total),
            tx_id=tx_id
        )

        return new_total

    async def get_current_usage(self, subscription_id: UUID) -> Decimal:
        """Get current period usage from Redis."""
        key = self._key(subscription_id, "current_period_total")
        value = await self.redis.get(key)
        return Decimal(str(value)) if value else Decimal("0")

    async def get_reported_overage(self, subscription_id: UUID) -> int:
        """Get overage minutes already reported to Stripe."""
        key = self._key(subscription_id, "overage_reported")
        value = await self.redis.get(key)
        return int(value) if value else 0

    async def set_reported_overage(self, subscription_id: UUID, minutes: int) -> None:
        """Update reported overage counter."""
        key = self._key(subscription_id, "overage_reported")
        await self.redis.set(key, minutes, ex=86400 * 35)

    async def reset_period_counters(self, subscription_id: UUID) -> None:
        """Reset counters at billing period end."""
        keys = [
            self._key(subscription_id, "current_period_total"),
            self._key(subscription_id, "overage_reported")
        ]
        await self.redis.delete(*keys)
        await logger.ainfo("period_counters_reset", subscription_id=str(subscription_id))

    async def acquire_lock(self, subscription_id: UUID, timeout: int = 30) -> bool:
        """Acquire distributed lock for critical billing operations."""
        lock_key = self._key(subscription_id, "lock")
        # NX = Only set if not exists, EX = expire time
        return await self.redis.set(lock_key, "1", nx=True, ex=timeout) is not None

    async def release_lock(self, subscription_id: UUID) -> None:
        """Release distributed lock."""
        await self.redis.delete(self._key(subscription_id, "lock"))
```

## 6. Stripe Client (`services/stripe_client.py`)

```python
"""Stripe API client wrapper with error handling and idempotency."""
import time
from decimal import Decimal
from typing import Optional, Dict, Any
from uuid import UUID

import stripe
from structlog import get_logger

from config import settings
from models import SubscriptionTier

logger = get_logger(__name__)

class StripeClientError(Exception):
    """Custom exception for Stripe operations."""
    pass

class StripeClient:
    """Production-grade Stripe API client."""

    def __init__(self):
        stripe.api_key = settings.stripe_secret_key
        stripe.api_version = settings.stripe_api_version
        self.client = stripe

    def _generate_idempotency_key(self, operation: str, unique_id: str) -> str:
        """Generate deterministic idempotency key."""
        return f"{operation}_{unique_id}_{int(time.time() / 3600)}"  # Hourly rotation

    async def create_subscription(
        self,
        customer_id: str,
        tier: SubscriptionTier,
        overage_price_id: str = "price_overage_metered"  # Stripe metered price ID
    ) -> Dict[str, Any]:
        """
        Create subscription with flat rate + metered overage.

        Args:
            customer_id: Stripe customer ID
            tier: Subscription tier
            overage_price_id: Stripe price ID for metered overages

        Returns:
            Stripe subscription object
        """
        try:
            tier_config = tier.get_config()

            subscription = self.client.Subscription.create(
                customer=customer_id,
                items=[
                    {"price": tier_config["stripe_price_id"]},  # Flat rate
                    {"price": overage_price_id}  # Metered overage (starts at 0)
                ],
                billing_cycle_anchor="now",
                proration_behavior="none",
                idempotency_key=self._generate_idempotency_key("sub_create", customer_id)
            )

            await logger.ainfo(
                "subscription_created",
                stripe_subscription_id=subscription.id,
                customer_id=customer_id,
                tier=tier.value
            )

            return subscription

        except stripe.error.StripeError as e:
            await logger.aerror("stripe_subscription_failed", error=str(e))
            raise StripeClientError(f"Failed to create subscription: {e.user_message}") from e

    async def report_usage(
        self,
        subscription_item_id: str,
        quantity: int,
        timestamp: Optional[int] = None
    ) -> None:
        """
        Report usage to Stripe metered billing.

        Args:
            subscription_item_id: Stripe subscription item ID for metered price
            quantity: Quantity to report (overage minutes)
            timestamp: Unix timestamp for usage (defaults to now)
        """
        try:
            # Idempotency key based on hour to allow retries but prevent duplicates
            idempotency_key = self._generate_idempotency_key(
                "usage",
                f"{subscription_item_id}_{timestamp or int(time.time() / 3600)}"
            )

            self.client.UsageRecord.create(
                subscription_item=subscription_item_id,
                quantity=quantity,
                timestamp=timestamp or int(time.time()),
                action="set" if quantity == 0 else "increment",
                idempotency_key=idempotency_key
            )

            await logger.ainfo(
                "usage_reported_to_stripe",
                subscription_item_id=subscription_item_id,
                quantity=quantity
            )

        except stripe.error.StripeError as e:
            await logger.aerror("stripe_usage_report_failed", error=str(e))
            raise StripeClientError(f"Failed to report usage: {e.user_message}") from e

    async def get_subscription(self, subscription_id: str) -> Dict[str, Any]:
        """Retrieve subscription from Stripe."""
        try:
            return self.client.Subscription.retrieve(subscription_id)
        except stripe.error.StripeError as e:
            raise StripeClientError(f"Failed to retrieve subscription: {e}") from e
```

## 7. Billing Service (`services/billing_service.py`)

```python
"""Core billing orchestration service."""
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional, Tuple
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession
from structlog import get_logger

from config import settings
from database import get_db_session
from models import (
    Subscription,
    SubscriptionStatus,
    UsageRecord,
    WidgetStatus,
    SubscriptionTier
)
from repositories import SubscriptionRepository, UsageRepository
from services.stripe_client import StripeClient, StripeClientError
from services.usage_tracker import UsageTracker

logger = get_logger(__name__)

class BillingService:
    """
    Orchestrates billing workflows: usage tracking, overage calculation,
    grace period management, and widget lifecycle.
    """

    def __init__(
        self,
        db_session: AsyncSession,
        stripe_client: Optional[StripeClient] = None,
        usage_tracker: Optional[UsageTracker] = None
    ):
        self.db = db_session
        self.stripe = stripe_client or StripeClient()
        self.usage_tracker = usage_tracker or UsageTracker()
        self.sub_repo = SubscriptionRepository(db_session)
        self.usage_repo = UsageRepository(db_session)

    async def record_voice_usage(
        self,
        subscription_id: UUID,
        minutes: Decimal,
        transaction_id: str
    ) -> Tuple[Decimal, int]:
        """
        Record voice usage and trigger overage calculations.

        Args:
            subscription_id: Subscription UUID
            minutes: Minutes consumed (supports fractional)
            transaction_id: Idempotency key for the operation

        Returns:
            Tuple of (total_minutes_this_period, overage_minutes_to_bill)
        """
        # 1. Update Redis counter (fast path)
        new_total = await self.usage_tracker.increment_minutes(
            subscription_id, minutes, transaction_id
        )

        # 2. Persist to PostgreSQL (async, non-blocking)
        usage_record = UsageRecord(
            subscription_id=subscription_id,
            minutes_used=minutes
        )
        await self.usage_repo.record_usage(usage_record)

        # 3. Check for overages
        subscription = await self.sub_repo.get_by_id(subscription_id)
        if not subscription:
            raise ValueError(f"Subscription {subscription_id} not found")

        overage_minutes = subscription.calculate_overage_minutes(new_total)
        reported_overage = await self.usage_tracker.get_reported_overage(subscription_id)

        # 4. Report new overages to Stripe if threshold crossed
        if overage_minutes > reported_overage:
            await self._report_overage_to_stripe(subscription, overage_minutes)
            await self.usage_tracker.set_reported_overage(subscription_id, overage_minutes)

        await logger.ainfo(
            "usage_recorded",
            subscription_id=str(subscription_id),
            minutes=float(minutes),
            total=float(new_total),
            overage=overage_minutes
        )

        return new_total, max(0, overage_minutes - reported_overage)

    async def _report_overage_to_stripe(
        self,
        subscription: Subscription,
        total_overage: int
    ) -> None:
        """Report cumulative overage to Stripe metered billing."""
        try:
            # Get Stripe subscription to find metered item ID
            stripe_sub = await self.stripe.get_subscription(subscription.stripe_subscription_id)

            # Find the metered price item (usually the second item)
            metered_item = None
            for item in stripe_sub["items"]["data"]:
                # Check if this is the metered overage price
                if item["price"].get("recurring", {}).get("usage_type") == "metered":
                    metered_item = item
                    break

            if not metered_item:
                await logger.aerror(
                    "metered_item_not_found",
                    subscription_id=str(subscription.id)
                )
                return

            # Report cumulative quantity (Stripe handles the increment)
            await self.stripe.report_usage(
                metered_item["id"],
                quantity=total_overage
            )

        except StripeClientError as e:
            # Log but don't fail - usage is tracked in PG for reconciliation
            await logger.aerror(
                "overage_report_failed",
                subscription_id=str(subscription.id),
                error=str(e)
            )

    async def handle_subscription_updated(
        self,
        stripe_subscription_id: str,
        stripe_status: str
    ) -> WidgetStatus:
        """
        Process Stripe subscription status changes.

        Implements grace period logic: 3 days past_due before deactivation.
        """
        subscription = await self.sub_repo.get_by_stripe_id(stripe_subscription_id)
        if not subscription:
            await logger.awarning(
                "subscription_not_found",
                stripe_id=stripe_subscription_id
            )
            return WidgetStatus.PENDING

        new_status = SubscriptionStatus(stripe_status)
        current_widget_status = subscription.widget_status

        # Determine action based on status transition
        if new_status == SubscriptionStatus.ACTIVE:
            await self._activate_widget(subscription)
            return WidgetStatus.ACTIVE

        elif new_status == SubscriptionStatus.PAST_D


Response:
Here is the production-ready implementation for the Widget Docker Deployment story.

## Directory Structure

```text
widget/
├── docker-compose.yml          # Full stack orchestration
├── Dockerfile                  # Multi-stage build (Widget + API)
├── .env.example               # Environment configuration template
├── nginx/
│   ├── nginx.conf             # Main server configuration
│   └── includes/
│       ├── proxy.conf         # Proxy settings
│       └── security.conf      # Security headers
└── src/
    ├── api/                   # Python FastAPI backend
    │   ├── main.py
    │   └── requirements.txt
    └── widget/                # Widget source
        ├── package.json
        └── src/
            └── widget.js
```

## 1. Multi-Stage Dockerfile

```dockerfile
# syntax=docker/dockerfile:1
# Multi-stage build: Stage 1 builds the widget, Stage 2 runs the API
# This minimizes final image size by excluding Node.js toolchain from production

# =================== Stage 1: Widget Builder ===================
FROM node:18-alpine AS widget-builder

WORKDIR /build

# Install dependencies first (better layer caching)
COPY src/widget/package*.json ./
RUN npm ci --only=production --ignore-scripts

# Copy source and build
COPY src/widget/ ./
RUN npm run build:prod

# =================== Stage 2: API Production ===================
FROM python:3.11-slim AS api

# Security: Create non-root user
RUN groupadd -r widgetgroup && useradd -r -g widgetgroup widgetuser

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY src/api/requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir -r requirements.txt

# Remove build dependencies to reduce attack surface
RUN apt-get purge -y --auto-remove gcc

# Copy application code
COPY src/api/ ./api/

# Copy built widget from builder stage to static files directory
# This allows the API to serve it as


Response:
 ```python
# widget/api/context_manager.py
"""
Conversation Context Manager for Widget AI System.

Manages multi-turn conversation state with Redis persistence,
context window management via summarization, business context injection,
and automatic session timeout handling.
"""

import json
import logging
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional, Protocol, Tuple

import tiktoken
from pydantic import BaseModel, Field, validator
from redis.asyncio import Redis
from redis.exceptions import RedisError, ConnectionError as RedisConnectionError

# Configure logging
logger = logging.getLogger(__name__)

# ============================================================================
# Exceptions
# ============================================================================

class ContextManagerError(Exception):
    """Base exception for context manager operations."""
    pass

class SessionNotFoundError(ContextManagerError):
    """Raised when session doesn't exist or has expired."""
    pass

class StorageError(ContextManagerError):
    """Raised when Redis operation fails."""
    pass

class SummarizationError(ContextManagerError):
    """Raised when LLM summarization fails."""
    pass

class ConfigurationError(ContextManagerError):
    """Raised when configuration is invalid."""
    pass

# ============================================================================
# Data Models
# ============================================================================

class Role(str, Enum):
    """Conversation participant roles."""
    USER = "user"
    ASSISTANT = "assistant"
    SYSTEM = "system"

class Turn(BaseModel):
    """
    Single conversation turn/message.

    Attributes:
        role: Speaker role (user/assistant/system)
        content: Message content
        timestamp: UTC timestamp of creation
        turn_id: Unique identifier for this turn
        metadata: Additional turn-specific data (sentiment, intent, etc.)
    """
    role: Role
    content: str
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    turn_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    metadata: Dict[str, Any] = Field(default_factory=dict)

    class Config:
        json_encoders = {
            datetime: lambda v: v.isoformat()
        }

class BusinessContext(BaseModel):
    """
    Static business context prepended to every conversation.

    Attributes:
        faq: Frequently asked questions content
        services: Available services description
        hours: Business hours information
        max_tokens: Maximum allowed tokens for business context
    """
    faq: str = ""
    services: str = ""
    hours: str = ""
    max_tokens: int = 2000

    def format_context(self) -> str:
        """Format business context as a system prompt section."""
        parts = []
        if self.faq:
            parts.append(f"FAQ Information:\n{self.faq}")
        if self.services:
            parts.append(f"Available Services:\n{self.services}")
        if self.hours:
            parts.append(f"Business Hours:\n{self.hours}")
        return "\n\n".join(parts)

class ConversationContext(BaseModel):
    """
    Complete conversation state for a session.

    Attributes:
        session_id: Unique session identifier
        turns: List of conversation turns (max 20 recent)
        summary: Summarized text of older conversation history
        created_at: Session creation timestamp
        last_activity: Last interaction timestamp
        metadata: Session metadata (user info, channel, etc.)
    """
    session_id: str
    turns: List[Turn] = Field(default_factory=list)
    summary: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)
    last_activity: datetime = Field(default_factory=datetime.utcnow)
    metadata: Dict[str, Any] = Field(default_factory=dict)

    @property
    def is_expired(self) -> bool:
        """Check if conversation has timed out (5 minutes)."""
        return datetime.utcnow() - self.last_activity > timedelta(minutes=5)

    @property
    def total_turns(self) -> int:
        """Get total turn count including summarized history."""
        # This is approximate since we don't know how many turns are in summary
        return len(self.turns)

    class Config:
        json_encoders = {
            datetime: lambda v: v.isoformat()
        }

class ContextManagerConfig(BaseModel):
    """
    Configuration for ConversationContextManager.

    Attributes:
        redis_url: Redis connection URL
        redis_ttl_seconds: Session timeout/TTL (default 300 = 5 min)
        max_turns_before_summary: Trigger summarization when exceeding this
        turns_to_keep: Number of recent turns to retain after summarization
        business_context: Static business context to inject
        summarization_model: Model identifier for summarization
        token_limit: Maximum tokens for context window
    """
    redis_url: str = "redis://localhost:6379/0"
    redis_ttl_seconds: int = 300  # 5 minutes
    max_turns_before_summary: int = 20
    turns_to_keep: int = 20
    business_context: BusinessContext = Field(default_factory=BusinessContext)
    summarization_model: str = "gemini-flash"
    token_limit: int = 4000  # Total token budget for context

    @validator('turns_to_keep')
    def validate_turns(cls, v, values):
        if 'max_turns_before_summary' in values and v > values['max_turns_before_summary']:
            raise ValueError('turns_to_keep cannot exceed max_turns_before_summary')
        return v

# ============================================================================
# Protocols/Interfaces
# ============================================================================

class LLMClient(Protocol):
    """
    Protocol for LLM clients used in summarization.
    Implement this for Gemini Flash, GPT-4, etc.
    """

    async def summarize(self, text: str, max_length: int = 500) -> str:
        """
        Summarize the provided text.

        Args:
            text: Text to summarize
            max_length: Target maximum length of summary

        Returns:
            Summarized text
        """
        ...

class GeminiFlashClient:
    """
    Concrete implementation of LLMClient for Google Gemini Flash.
    """

    def __init__(self, api_key: str, model: str = "gemini-flash"):
        self.api_key = api_key
        self.model = model
        # In real implementation, initialize google.generativeai client here

    async def summarize(self, text: str, max_length: int = 500) -> str:
        """
        Use Gemini Flash to summarize conversation history.
        """
        try:
            # Placeholder for actual Gemini API call
            # In production: response = await gemini_model.generate_content(...)
            prompt = f"Summarize the following conversation concisely in under {max_length} words, capturing key facts and user intent:\n\n{text}"

            # Simulated response for example
            summary = f"[Summary of {len(text)} chars of conversation history]"

            logger.info(f"Summarized text using {self.model}", extra={"input_length": len(text), "output_length": len(summary)})
            return summary

        except Exception as e:
            logger.error(f"Summarization failed: {e}")
            raise SummarizationError(f"Failed to summarize conversation: {e}") from e

# ============================================================================
# Token Counter
# ============================================================================

class TokenCounter:
    """
    Utility for counting and managing tokens in text.
    Uses tiktoken for accurate OpenAI-compatible token counting.
    """

    def __init__(self, model: str = "gpt-4"):
        try:
            self.encoding = tiktoken.encoding_for_model(model)
        except KeyError:
            # Fallback to cl100k_base for unknown models
            self.encoding = tiktoken.get_encoding("cl100k_base")

    def count_tokens(self, text: str) -> int:
        """Count tokens in text."""
        if not text:
            return 0
        return len(self.encoding.encode(text))

    def truncate_to_tokens(self, text: str, max_tokens: int) -> str:
        """Truncate text to fit within max_tokens."""
        if not text:
            return text

        tokens = self.encoding.encode(text)
        if len(tokens) <= max_tokens:
            return text

        truncated = self.encoding.decode(tokens[:max_tokens])
        return truncated + "... [truncated]"

    def count_conversation_tokens(self, turns: List[Turn]) -> int:
        """Count tokens in a list of conversation turns."""
        total = 0
        for turn in turns:
            # Approximate: role + content + formatting overhead
            total += self.count_tokens(turn.role.value) + self.count_tokens(turn.content) + 4
        return total

# ============================================================================
# Main Context Manager
# ============================================================================

class ConversationContextManager:
    """
    Manages conversation context with Redis persistence, window management,
    and business context injection.

    Features:
    - Redis-backed session storage with 5-minute TTL
    - Automatic summarization when exceeding 20 turns
    - Business context prepending with token limits
    - Comprehensive error handling and logging
    """

    def __init__(
        self,
        redis_client: Redis,
        llm_client: LLMClient,
        config: ContextManagerConfig,
        token_counter: Optional[TokenCounter] = None
    ):
        """
        Initialize the context manager.

        Args:
            redis_client: Async Redis client instance
            llm_client: LLM client for summarization
            config: Configuration settings
            token_counter: Optional token counter instance
        """
        self.redis = redis_client
        self.llm = llm_client
        self.config = config
        self.token_counter = token_counter or TokenCounter()
        self.logger = logging.getLogger(__name__)

        # Validate business context token limit
        self._validate_business_context()

    def _validate_business_context(self) -> None:
        """Ensure business context doesn't exceed token limit on startup."""
        biz_text = self.config.business_context.format_context()
        token_count = self.token_counter.count_tokens(biz_text)

        if token_count > self.config.business_context.max_tokens:
            self.logger.warning(
                f"Business context exceeds {self.config.business_context.max_tokens} tokens "
                f"({token_count} found). Will truncate in prompts."
            )

    def _get_redis_key(self, session_id: str) -> str:
        """Generate Redis key for session."""
        return f"conversation:{session_id}"

    async def get_context(self, session_id: str) -> ConversationContext:
        """
        Retrieve or initialize conversation context.

        Args:
            session_id: Unique session identifier

        Returns:
            ConversationContext object

        Raises:
            StorageError: If Redis operation fails
        """
        try:
            key = self._get_redis_key(session_id)
            data = await self.redis.get(key)

            if data:
                # Parse existing context
                context_dict = json.loads(data)
                context = ConversationContext(**context_dict)
                self.logger.debug(f"Retrieved context for session {session_id}",
                                extra={"turn_count": len(context.turns)})
                return context
            else:
                # Initialize new context
                context = ConversationContext(session_id=session_id)
                await self._save_context(context)
                self.logger.info(f"Initialized new context for session {session_id}")
                return context

        except RedisError as e:
            self.logger.error(f"Redis error retrieving context: {e}")
            raise StorageError(f"Failed to retrieve session {session_id}") from e
        except json.JSONDecodeError as e:
            self.logger.error(f"Corrupted session data: {e}")
            # Start fresh if data is corrupted
            context = ConversationContext(session_id=session_id)
            return context

    async def add_turn(
        self,
        session_id: str,
        role: Role,
        content: str,
        metadata: Optional[Dict[str, Any]] = None
    ) -> ConversationContext:
        """
        Add a new turn to the conversation.

        Manages context window (keeps last 20, summarizes older),
        updates activity timestamp, and refreshes Redis TTL.

        Args:
            session_id: Session identifier
            role: Speaker role
            content: Message content
            metadata: Optional turn metadata

        Returns:
            Updated ConversationContext

        Raises:
            StorageError: If persistence fails
            SummarizationError: If summarization fails (non-blocking, logs warning)
        """
        try:
            context = await self.get_context(session_id)

            # Create new turn
            turn = Turn(
                role=role,
                content=content,
                metadata=metadata or {}
            )

            # Add to context
            context.turns.append(turn)
            context.last_activity = datetime.utcnow()

            # Manage context window (keep last 20, summarize older)
            await self._manage_context_window(context)

            # Save with TTL refresh
            await self._save_context(context)

            self.logger.debug(
                f"Added turn to session {session_id}",
                extra={
                    "role": role.value,
                    "turn_count": len(context.turns),
                    "has_summary": bool(context.summary)
                }
            )

            return context

        except StorageError:
            raise
        except Exception as e:
            self.logger.error(f"Unexpected error adding turn: {e}")
            raise StorageError(f"Failed to add turn to session {session_id}") from e

    async def _manage_context_window(self, context: ConversationContext) -> None:
        """
        Manage conversation window size.

        If turns exceed max_turns_before_summary (20), summarize the oldest
        (excess) turns and update the summary field.

        Args:
            context: Conversation context to modify in-place
        """
        total_turns = len(context.turns)
        max_turns = self.config.max_turns_before_summary
        keep_turns = self.config.turns_to_keep

        if total_turns <= max_turns:
            return

        # Calculate how many turns to summarize (everything except the last 'keep_turns')
        turns_to_summarize = context.turns[:-keep_turns]
        turns_to_keep = context.turns[-keep_turns:]

        try:
            # Summarize the old turns
            new_summary = await self._summarize_turns(turns_to_summarize, context.summary)

            # Update context
            context.summary = new_summary
            context.turns = list(turns_to_keep)

            self.logger.info(
                f"Summarized {len(turns_to_summarize)} turns for session {context.session_id}",
                extra={
                    "summarized_count": len(turns_to_summarize),
                    "remaining_count": len(context.turns),
                    "summary_length": len(new_summary) if new_summary else 0
                }
            )

        except SummarizationError as e:
            # Non-blocking: log error but keep recent turns, discard old ones without summary
            self.logger.error(f"Failed to summarize turns: {e}")
            context.turns = list(turns_to_keep)
            # Don't update summary if summarization failed

    async def _summarize_turns(
        self,
        turns: List[Turn],
        existing_summary: Optional[str]
    ) -> str:
        """
        Summarize conversation turns using LLM (Gemini Flash).

        Args:
            turns: List of turns to summarize
            existing_summary: Previous summary to incorporate

        Returns:
            Combined summary string

        Raises:
            SummarizationError: If LLM call fails
        """
        # Format turns for summarization
        turn_text = "\n".join([
            f"{turn.role.value}: {turn.content}"
            for turn in turns
        ])

        # Include existing summary if present
        if existing_summary:
            prompt = (
                f"Previous conversation summary:\n{existing_summary}\n\n"
                f"New conversation to incorporate:\n{turn_text}\n\n"
                f"Provide a concise summary capturing key facts, user preferences, "
                f"and unresolved questions. Be brief but comprehensive."
            )
        else:
            prompt = (
                f"Summarize the following conversation concisely, "
                f"capturing key facts and user intent:\n\n{turn_text}"
            )

        try:
            summary = await self.llm.summarize(prompt, max_length=500)
            return summary
        except Exception as e:
            raise SummarizationError(f"LLM summarization failed: {e}") from e

    async def _save_context(self, context: ConversationContext) -> None:
        """
        Persist context to Redis with TTL.

        Args:
            context: Context to save

        Raises:
            StorageError: If Redis operation fails
        """
        try:
            key = self._get_redis_key(context.session_id)

            # Serialize context
            data = context.json()

            # Save with TTL
            await self.redis.setex(
                key,
                self.config.redis_ttl_seconds,
                data
            )

        except RedisError as e:
            self.logger.error(f"Failed to save context to


Widget Security - CORS and Domain Verification


Response:
I'll provide a comprehensive implementation covering both the frontend JavaScript widget and the Python backend API to ensure full stack production readiness.

## 1. Frontend Implementation (`widget/src/text_chat.js`)

```javascript
/**
 * Text Chat Fallback Module for WebRTC Widget
 *
 * Provides seamless fallback to text-based communication when WebRTC is unavailable,
 * permissions are denied, or user prefers text input. Maintains conversation context
 * when switching from voice mode mid-conversation.
 *
 * @module widget/src/text_chat
 * @version 1.0.0
 */

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    root.TextChatManager = factory();
  }
}(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  /**
   * Utility function to debounce rapid events (e.g., typing indicators)
   * @param {Function} func - Function to debounce
   * @param {number} wait - Milliseconds to wait
   * @returns {Function} Debounced function
   */
  const debounce = (func, wait) => {
    let timeout;
    return function executedFunction(...args) {
      const later = () => {
        clearTimeout(timeout);
        func.apply(this, args);
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
    };
  };

  /**
   * WebRTC Feature Detector
   * Handles browser capability detection and permission checking
   */
  class WebRTCDetector {
    /**
     * Check if browser supports WebRTC getUserMedia
     * @returns {boolean} True if WebRTC is supported
     */
    static isSupported() {
      return !!(
        navigator.mediaDevices &&
        typeof navigator.mediaDevices.getUserMedia === 'function' &&
        window.RTCPeerConnection
      );
    }

    /**
     * Check microphone permission status
     * @returns {Promise<string>} Permission state: 'granted', 'denied', or 'prompt'
     */
    static async checkMicrophonePermission() {
      try {
        if (!navigator.permissions || !navigator.permissions.query) {
          console.warn('Permissions API not supported, falling back to getUserMedia test');
          return 'prompt';
        }

        const result = await navigator.permissions.query({ name: 'microphone' });
        return result.state;
      } catch (error) {
        console.error('Error checking microphone permissions:', error);
        return 'prompt';
      }
    }

    /**
     * Comprehensive fallback check
     * @returns {Promise<{shouldFallback: boolean, reason: string}>}
     */
    static async shouldUseFallback() {
      if (!this.isSupported()) {
        return { shouldFallback: true, reason: 'WebRTC not supported' };
      }

      const permissionState = await this.checkMicrophonePermission();
      if (permissionState === 'denied') {
        return { shouldFallback: true, reason: 'Microphone permission denied' };
      }

      return { shouldFallback: false, reason: 'WebRTC available' };
    }
  }

  /**
   * Chat UI Manager
   * Handles DOM manipulation and user interface rendering
   */
  class ChatUI {
    /**
     * @param {string} containerSelector - CSS selector for container element
     * @param {Object} callbacks - Event callbacks {onSend, onTyping}
     * @param {Object} options - UI configuration options
     */
    constructor(containerSelector, callbacks, options = {}) {
      this.container = document.querySelector(containerSelector);
      if (!this.container) {
        throw new Error(`Container not found: ${containerSelector}`);
      }

      this.callbacks = {
        onSend: callbacks.onSend || (() => {}),
        onTyping: callbacks.onTyping || (() => {}),
        onError: callbacks.onError || console.error
      };

      this.options = {
        placeholder: options.placeholder || 'Type your message...',
        debounceMs: options.debounceMs || 300,
        maxMessageLength: options.maxMessageLength || 4000,
        ...options
      };

      this.elements = {};
      this.debouncedTypingHandler = debounce(
        () => this.callbacks.onTyping(),
        this.options.debounceMs
      );
    }

    /**
     * Render the chat interface
     */
    render() {
      this.container.innerHTML = `

Text Chat Mode

            <button class="close-btn" id="close-chat" aria-label="Close chat">×</button>

            <input
              type="text"
              id="chat-input"
              placeholder="${this.escapeHtml(this.options.placeholder)}"
              maxlength="${this.options.maxMessageLength}"
              aria-label="Message input"
              autocomplete="off"
            />
            <button id="send-btn" aria-label="Send message">
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <line x1="22" y1="2" x2="11" y2="13"></line>
                <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
              </svg>
            </button>

      `;

      this.cacheElements();
      this.attachEventListeners();
      this.addStyles();
    }

    /**
     * Cache DOM element references
     * @private
     */
    cacheElements() {
      this.elements = {
        messages: this.container.querySelector('#chat-messages'),
        input: this.container.querySelector('#chat-input'),
        sendBtn: this.container.querySelector('#send-btn'),
        typing: this.container.querySelector('#typing-indicator'),
        errorBanner: this.container.querySelector('#error-banner'),
        closeBtn: this.container.querySelector('#close-chat'),
        status: this.container.querySelector('#connection-status')
      };
    }

    /**
     * Attach event listeners with proper error handling
     * @private
     */
    attachEventListeners() {
      // Send button click
      this.elements.sendBtn.addEventListener('click', () => this.handleSend());

      // Enter key to send
      this.elements.input.addEventListener('keypress', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          e.preventDefault();
          this.handleSend();
        }
        this.debouncedTypingHandler();
      });

      // Input validation
      this.elements.input.addEventListener('input', (e) => {
        const length = e.target.value.length;
        if (length >= this.options.maxMessageLength) {
          this.showError('Message too long');
        }
      });

      // Close button
      this.elements.closeBtn.addEventListener('click', () => {
        this.container.dispatchEvent(new CustomEvent('chat:close', { bubbles: true }));
      });
    }

    /**
     * Handle send action with validation
     * @private
     */
    handleSend() {
      const text = this.elements.input.value.trim();

      if (!text) return;

      if (text.length > this.options.maxMessageLength) {
        this.showError(`Message exceeds ${this.options.maxMessageLength} characters`);
        return;
      }

      try {
        this.callbacks.onSend(text);
        this.elements.input.value = '';
        this.elements.input.focus();
        this.hideError();
      } catch (error) {
        this.callbacks.onError('Failed to send message:', error);
        this.showError('Failed to send message. Please try again.');
      }
    }

    /**
     * Add a message to the chat history
     * @param {string} role - 'user', 'assistant', or 'system'
     * @param {string} content - Message content
     */
    addMessage(role, content) {
      const messageDiv = document.createElement('div');
      messageDiv.className = `message message-${role}`;
      message


Response:
 Here is the production-grade implementation for the Telnyx TeXML Call Handler. This solution includes comprehensive error handling, type safety, state management, and security validation.

## Project Structure

```
clawdtalk/
├── __init__.py
├── telnyx_handler.py      # Main implementation
├── models.py              # Data models
├── config.py              # Configuration
├── gemini_client.py       # AI integration
├── storage.py             # S3 recording handler
└── exceptions.py          # Custom exceptions
```

## 1. Configuration (`config.py`)

```python
"""Application configuration with environment-based settings."""

import os
from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class Config:
    """Production configuration for ClawdTalk."""

    # Telnyx
    telnyx_api_key: str = os.getenv("TELNYX_API_KEY", "")
    telnyx_public_key: str = os.getenv("TELNYX_PUBLIC_KEY", "")
    telnyx_webhook_secret: str = os.getenv("TELNYX_WEBHOOK_SECRET", "")
    telnyx_connection_id: str = os.getenv("TELNYX_CONNECTION_ID", "")

    # Gemini
    gemini_api_key: str = os.getenv("GEMINI_API_KEY", "")
    gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")

    # S3-Compatible Storage (for recordings)
    s3_endpoint: str = os.getenv("S3_ENDPOINT", "")
    s3_access_key: str = os.getenv("S3_ACCESS_KEY", "")
    s3_secret_key: str = os.getenv("S3_SECRET_KEY", "")
    s3_bucket: str = os.getenv("S3_BUCKET", "clawdtalk-recordings")
    s3_region: str = os.getenv("S3_REGION", "us-east-1")

    # Application
    base_url: str = os.getenv("BASE_URL", "https://api.clawdtalk.example.com")
    log_level: str = os.getenv("LOG_LEVEL", "INFO")
    enable_recording: bool = os.getenv("ENABLE_RECORDING", "false").lower() == "true"

    def validate(self) -> None:
        """Validate critical configuration."""
        if not self.telnyx_webhook_secret:
            raise ValueError("TELNYX_WEBHOOK_SECRET is required for webhook security")
        if not self.gemini_api_key:
            raise ValueError("GEMINI_API_KEY is required")

config = Config()
```

## 2. Data Models (`models.py`)

```python
"""Pydantic models for type safety and validation."""

from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field

from pydantic import BaseModel, Field

class CallState(str, Enum):
    """Finite state machine states for call lifecycle."""
    RINGING = "ringing"
    ANSWERED = "answered"
    CONVERSING = "conversing"
    WRAPPING = "wrapping"
    ENDED = "ended"

class TelnyxWebhookEvent(str, Enum):
    """Supported Telnyx TeXML webhook events."""
    ANSWER = "answer"
    GATHER = "gather"
    RECORDING = "recording"
    HANGUP = "hangup"
    CALLBACK = "callback"

class TelnyxWebhookPayload(BaseModel):
    """Inbound webhook payload from Telnyx."""
    call_sid: str = Field(..., alias="CallSid")
    call_status: str = Field(..., alias="CallStatus")
    from_number: str = Field(..., alias="From")
    to_number: str = Field(..., alias="To")
    call_control_id: Optional[str] = Field(None, alias="CallControlId")

    # Gather-specific fields
    speech_result: Optional[str] = Field(None, alias="SpeechResult")
    confidence: Optional[float] = Field(None, alias="Confidence")

    # Recording-specific fields
    recording_url: Optional[str] = Field(None, alias="RecordingUrl")
    recording_duration: Optional[int] = Field(None, alias="RecordingDuration")

    # Outbound-specific
    direction: str = Field("inbound", alias="Direction")

    class Config:
        populate_by_name = True
        extra = "allow"  # Allow additional Telnyx parameters

@dataclass
class CallSession:
    """In-memory call session state (replace with Redis in production)."""
    call_sid: str
    from_number: str
    to_number: str
    state: CallState
    user_name: Optional[str] = None
    conversation_history: List[Dict[str, Any]] = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.utcnow)
    last_activity: datetime = field(default_factory=datetime.utcnow)
    recording_url: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)

    def add_message(self, role: str, content: str) -> None:
        """Add message to conversation history."""
        self.conversation_history.append({
            "role": role,
            "content": content,
            "timestamp": datetime.utcnow().isoformat()
        })
        self.last_activity = datetime.utcnow()

    def transition_to(self, new_state: CallState) -> None:
        """Validate and execute state transition."""
        valid_transitions = {
            CallState.RINGING: [CallState.ANSWERED, CallState.ENDED],
            CallState.ANSWERED: [CallState.CONVERSING, CallState.WRAPPING, CallState.ENDED],
            CallState.CONVERSING: [CallState.CONVERSING, CallState.WRAPPING, CallState.ENDED],
            CallState.WRAPPING: [CallState.ENDED],
            CallState.ENDED: []
        }

        if new_state not in valid_transitions.get(self.state, []):
            raise ValueError(
                f"Invalid state transition: {self.state.value} -> {new_state.value}"
            )
        self.state = new_state
        self.last_activity = datetime.utcnow()
```

## 3. Gemini Client (`gemini_client.py`)

```python
"""Google Gemini integration for conversational AI."""

import logging
from typing import AsyncGenerator, Dict, List, Optional

import google.generativeai as genai
from google.generativeai.types import GenerationConfig

from clawdtalk.config import config
from clawdtalk.models import CallSession

logger = logging.getLogger(__name__)

class GeminiClient:
    """Production client for Gemini API with conversation memory."""

    SYSTEM_PROMPT = """You are ClawdTalk, a helpful AI phone assistant.
    Keep responses concise (under 30 seconds when spoken) and conversational.
    If the user asks to end the call, say goodbye politely."""

    def __init__(self) -> None:
        genai.configure(api_key=config.gemini_api_key)
        self.model = genai.GenerativeModel(
            model_name=config.gemini_model,
            generation_config=GenerationConfig(
                temperature=0.7,
                max_output_tokens=150,  # Keep responses short for voice
                top_p=0.9,
            )
        )

    async def generate_response(
        self,
        session: CallSession,
        user_input: str
    ) -> str:
        """
        Generate AI response based on conversation history.

        Args:
            session: Current call session with history
            user_input: Latest speech input from user

        Returns:
            Generated text response
        """
        try:
            # Build context from history
            history = []
            for msg in session.conversation_history[-6:]:  # Last 3 exchanges
                role = "user" if msg["role"] == "user" else "model"
                history.append({"role": role, "parts": [msg["content"]]})

            # Start chat with history
            chat = self.model.start_chat(history=history)

            # Generate response
            response = await chat.send_message_async(user_input)
            text = response.text.strip()

            # Update session
            session.add_message("user", user_input)
            session.add_message("assistant", text)

            logger.info(
                f"Generated response for call {session.call_sid}: "
                f"input='{user_input[:50]}...', output='{text[:50]}...'"
            )

            return text

        except Exception as e:
            logger.error(f"Gemini API error for call {session.call_sid}: {e}")
            return "I'm sorry, I'm having trouble understanding. Could you repeat that?"

    def detect_end_call_intent(self, text: str) -> bool:
        """Detect if user wants to end the call."""
        end_phrases = ["goodbye", "bye", "end call", "hang up", "stop", "that's all"]
        return any(phrase in text.lower() for phrase in end_phrases)
```

## 4. S3 Storage (`storage.py`)

```python
"""S3-compatible storage handler for call recordings."""

import logging
from typing import Optional

import boto3
from botocore.client import Config as BotoConfig
from botocore.exceptions import ClientError

from clawdtalk.config import config

logger = logging.getLogger(__name__)

class RecordingStorage:
    """Handles upload and retrieval of call recordings."""

    def __init__(self) -> None:
        self.client = boto3.client(
            "s3",
            endpoint_url=config.s3_endpoint,
            aws_access_key_id=config.s3_access_key,
            aws_secret_access_key=config.s3_secret_key,
            region_name=config.s3_region,
            config=BotoConfig(signature_version="s3v4")
        )
        self.bucket = config.s3_bucket

    async def upload_recording(
        self,
        call_sid: str,
        recording_url: str,
        duration: int
    ) -> Optional[str]:
        """
        Download recording from Telnyx and upload to S3.

        Args:
            call_sid: Unique call identifier
            recording_url: Temporary URL from Telnyx
            duration: Recording duration in seconds

        Returns:
            S3 URI of stored recording or None if failed
        """
        if not config.enable_recording:
            return None

        try:
            # In production: Download from recording_url, then upload
            # For this example, we assume Telnyx provides a direct URL
            # that we proxy or redirect to permanent storage

            key = f"recordings/{datetime.utcnow().strftime('%Y/%m/%d')}/{call_sid}.wav"

            # Here you would actually download the file and upload
            # For now, we store metadata
            metadata = {
                "call_sid": call_sid,
                "duration": str(duration),
                "source_url": recording_url
            }

            # Placeholder for actual upload logic
            s3_uri = f"s3://{self.bucket}/{key}"
            logger.info(f"Stored recording for {call_sid} at {s3_uri}")

            return s3_uri

        except Exception as e:
            logger.error(f"Failed to store recording for {call_sid}: {e}")
            return None

    def generate_presigned_url(self, s3_uri: str, expiration: int = 3600) -> str:
        """Generate temporary access URL for recording."""
        try:
            key = s3_uri.replace(f"s3://{self.bucket}/", "")
            url = self.client.generate_presigned_url(
                "get_object",
                Params={"Bucket": self.bucket, "Key": key},
                ExpiresIn=expiration
            )
            return url
        except ClientError as e:
            logger.error(f"Failed to generate presigned URL: {e}")
            raise
```

## 5. Main Handler (`telnyx_handler.py`)

```python
"""
Telnyx TeXML Call Handler for ClawdTalk.

Handles inbound/outbound calls, speech-to-text integration with Gemini,
text-to-speech responses, and optional call recording.
"""

import hashlib
import hmac
import logging
import time
from datetime import datetime
from typing import Dict, Optional, Tuple
from xml.etree.ElementTree import Element, SubElement, tostring

from clawdtalk.config import config
from clawdtalk.gemini_client import GeminiClient
from clawdtalk.models import CallSession, CallState, TelnyxWebhookPayload
from clawdtalk.storage import RecordingStorage

logger = logging.getLogger(__name__)

class TeXMLBuilder:
    """Builder pattern for generating valid TeXML responses."""

    @staticmethod
    def create_response() -> Element:
        """Create root <Response> element."""
        return Element("Response")

    @classmethod
    def say(
        cls,
        text: str,
        voice: str = "female",
        language: str = "en-US"
    ) -> Element:
        """Generate <Say> element for TTS."""
        elem = Element("Say", voice=voice, language=language)
        elem.text = text
        return elem

    @classmethod
    def gather(
        cls,
        action: str,
        input_type: str = "speech",
        timeout: int = 5,
        speech_timeout: str = "auto",
        hints: Optional[str] = None
    ) -> Element:
        """
        Generate <Gather> element for speech input.

        Args:
            action: Webhook URL to post results
            input_type: "speech" or "dtmf speech"
            timeout: Seconds to wait for input
            speech_timeout: "auto" or seconds
            hints: Phrases to improve recognition accuracy
        """
        attrs = {
            "action": action,
            "input": input_type,
            "timeout": str(timeout),
            "speechTimeout": speech_timeout
        }
        if hints:
            attrs["hints"] = hints

        return Element("Gather", attrs)

    @classmethod
    def record(
        cls,
        action: str,
        max_length: int = 3600,
        transcribe: bool = False
    ) -> Element:
        """Generate <Record> element."""
        return Element("Record", {
            "action": action,
            "maxLength": str(max_length),
            "transcribe": "true" if transcribe else "false"
        })

    @classmethod
    def hangup(cls) -> Element:
        """Generate <Hangup> element."""
        return Element("Hangup")

    @classmethod
    def pause(cls, length: int = 1) -> Element:
        """Generate <Pause> element."""
        return Element("Pause", length=str(length))

    @classmethod
    def to_xml(cls, element: Element) -> str:
        """Convert ElementTree to XML string with declaration."""
        xml_bytes = tostring(element, encoding="unicode")
        return f'<?xml version="1.0" encoding="UTF-8"?>\n{xml_bytes}'

class TelnyxHandler:
    """
    Main handler for Telnyx TeXML webhooks.

    Manages call state, integrates with Gemini for conversation,
    and handles security validation.
    """

    def __init__(self):
        self.sessions: Dict[str, CallSession] = {}
        self.gemini = GeminiClient()
        self.storage = RecordingStorage()
        self.builder = TeXMLBuilder()
        self.webhook_secret = config.telnyx_webhook_secret.encode()

        # Hints for better STT recognition
        self.conversation_hints = "ClawdTalk,AI assistant,help,support"

    def validate_signature(
        self,
        raw_body: bytes,
        signature_header: str
    ) -> bool:
        """
        Validate Telnyx webhook signature using HMAC-SHA256.

        Args:
            raw_body: Raw request body bytes
            signature_header: X-Telnyx-Signature header value

        Returns:
            True if signature is valid
        """
        try:
            # Telnyx format: "t=timestamp,v1=signature"
            parts = dict(part.split("=") for part in signature_header.split(","))
            timestamp = parts.get("t", "")
            signature = parts.get("v1", "")

            # Check timestamp (prevent replay attacks)
            current_time = int(time.time())
            request_time = int(timestamp)
            if abs(current_time - request_time) > 300:  # 5 min tolerance
                logger.warning(f"Webhook timestamp too old: {timestamp}")
                return False

            # Compute expected signature
            signed_payload = f"{timestamp}.{raw_body.decode()}"
            expected = hmac.new(
                self.webhook_secret,
                signed_payload.encode(),
                hashlib.sha256
            ).hexdigest()

            # Constant-time comparison
            return hmac.compare_digest(expected, signature)

        except Exception as e:
            logger.error(f"Signature validation error: {e}")
            return False

    def get_or_create_session(
        self,
        payload: TelnyxWebhookPayload
    ) -> CallSession:
        """Retrieve existing session or create new one."""
        if payload.call_sid not in self.sessions:
            self.sessions[payload.call_sid] = CallSession(
                call_sid=payload.call_sid,
                from_number=payload.from_number,
                to_number=payload.to_number,
                state=CallState.RINGING
            )
        return self.sessions[payload.call_sid]

    def _build_webhook_url(self, event: str, call_sid: str) -> str:
        """Construct webhook URL with event parameter."""
        return f"{config.base_url}/api/v1/calls/webhook?event={event}&call_sid={call_sid}"

    async def handle_webhook(
        self,
        payload: TelnyxWebhookPayload,
        raw_body: bytes,
        signature_header: Optional[str]
    ) -> str:
        """
        Main entry point for Telnyx webhooks.

        Args:
            payload: Parsed webhook payload
            raw_body: Raw request body for signature verification
            signature_header: X-Telnyx-Signature header

        Returns:
            TeXML XML string
        """
        # Security validation
        if not signature_header or not self.validate_signature(raw_body, signature_header):
            logger.warning(f"Invalid signature for call {payload.call_sid}")
            return self._error_response("Invalid signature")

        try:
            session = self.get_or_create_session(payload)

            # Route based on event type
            if payload.call_status == "completed" or payload.call_status == "busy":
                return await self._handle_hangup(session, payload)

            # Check for speech result (Gather completion)
            if payload.speech_result:
                return await self._handle_conversation(session, payload)

            # Initial answer
            if payload.call_status == "in-progress" and session.state == CallState.RINGING:
                return await self._handle_answer(session, payload)

            # Recording completed
            if payload.recording_url:
                return await self._handle_recording_complete(session, payload)

            # Default: continue gathering input
            return self._continue_conversation(session)

        except Exception as e:
            logger.error(f"Error handling webhook: {e}", exc_info=True)
            return self._error_response("Internal error")

    async def _handle_answer(
        self,
        session: CallSession,
        payload: TelnyxWebhookPayload
    ) -> str:
        """Handle call answer - play greeting and start listening."""
        session.transition_to(CallState.ANSWERED)

        # Lookup user name (in production: query CRM/database by phone number)
        user_name = await self._lookup_user(payload.from_number)
        session.user_name = user_name

        greeting = f"Hello {user_name}, this is ClawdTalk. How can I help you today?"

        # Build TeXML: Greeting + Gather
        response = self.builder.create_response()
        response.append(self.builder.say(greeting))
        response.append(self.builder.pause(length=1))

        gather = self.builder.gather(
            action=self._build_webhook_url("gather", session.call_sid),
            hints=self.conversation_hints
        )
        response.append(gather)

        # Fallback if no input
        response.append(self.builder.say("I didn't catch that. Goodbye!"))
        response.append(self.builder.hangup())

        session.transition_to(CallState.CONVERSING)
        logger.info(f"Call {session.call_sid} answered, greeting played")

        return self.builder.to_xml(response)

    async def _handle_conversation(
        self,
        session: CallSession,
        payload: TelnyxWebhookPayload
    ) -> str:
        """Process speech input and generate AI response."""
        if not payload.speech_result:
            return self._continue_conversation(session)

        user_input = payload.speech_result
        logger.info(f"Call {session.call_sid} input: {user_input}")

        # Check for end call intent
        if self.gemini.detect_end_call_intent(user_input):
            return await self._handle_wrap_up(session, goodbye=True)

        # Generate AI response
        ai_response = await self.gemini.generate_response(session, user_input)

        # Build TeXML: AI Response + Gather (loop)
        response = self.builder.create_response()
        response.append(self.builder.say(ai_response))
        response.append(self.builder.pause(length=1))

        gather = self.builder.gather(
            action=self._build_webhook_url("gather", session.call_sid),
            timeout=7,
            speech_timeout="auto",
            hints=self.conversation_hints
        )
        response.append(gather)

        # Fallback
        response.append(self.builder.say("Let me know if you need anything else."))
        response.append(self.builder.hangup())

        return self.builder.to_xml(response)

    def _continue_conversation(self, session: CallSession) -> str:
        """Re-prompt user if no input received."""
        response = self.builder.create_response()
        response.append(self.builder.say("I'm listening. Please go ahead."))
        response.append(self.builder.gather(
            action=self._build_webhook_url("gather", session.call_sid),
            timeout=10
        ))
        response.append(self.builder.hangup())
        return self.builder.to_xml(response)

    async def _handle_wrap_up(
        self,
        session: CallSession,
        goodbye: bool = True
    ) -> str:
        """Initiate call wrap-up and recording."""
        session.transition_to(CallState.WRAPPING)

        response = self.builder.create_response()

        if goodbye:
            response.append(self.builder.say("Thank you for calling. Goodbye!"))

        if config.enable_recording:
            # Start recording before hanging up
            record_action = self._build_webhook_url("recording", session.call_sid)
            response.append(self.builder.record(
                action=record_action,
                max_length=300
            ))

        response.append(self.builder.hangup())

        return self.builder.to_xml(response)

    async def _handle_recording_complete(
        self,
        session: CallSession,
        payload: TelnyxWebhookPayload
    ) -> str:
        """Store recording metadata and end call."""
        if payload.recording_url:
            s3_uri = await self.storage.upload_recording(
                session.call_sid,
                payload.recording_url,
                payload.recording_duration or 0
            )
            session.recording_url = s3_uri

        session.transition_to(CallState.ENDED)

        # Cleanup session after delay (or move to cold storage)
        # In production: archive to database, don't just delete
        if session.call_sid in self.sessions:
            del self.sessions[session.call_sid]

        return