Complete project scaffolding and core implementation of an AI-powered telephony system that calls companies, navigates IVR menus, waits on hold, and transfers to the user when a human answers. Key components: - FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces - SIP/VoIP call management via PJSUA2 with RTP audio streaming - LLM-powered IVR navigation using OpenAI/Anthropic with tool calling - Hold detection service combining audio analysis and silence detection - Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines - Call recording with per-channel and mixed audio capture - Event bus (asyncio pub/sub) for real-time client updates - Web dashboard with live call monitoring - SQLite persistence via SQLAlchemy with call history and analytics - Notification support (email, SMS, webhook, desktop) - Docker Compose deployment with Opal VoIP and Opal Media containers - Comprehensive test suite with unit, integration, and E2E tests - Simplified .gitignore and full project documentation in README
121 lines
3.6 KiB
Python
121 lines
3.6 KiB
Python
"""
|
|
Event Bus — Async pub/sub for real-time gateway events.
|
|
|
|
WebSocket connections, MCP server, and internal services
|
|
all subscribe to events here. Pure asyncio — no external deps.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from models.events import EventType, GatewayEvent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EventBus:
|
|
"""
|
|
Async pub/sub event bus using asyncio.Queue per subscriber.
|
|
|
|
Features:
|
|
- Non-blocking publish (put_nowait)
|
|
- Automatic dead-subscriber cleanup (full queues are removed)
|
|
- Event history (last N events for late joiners)
|
|
- Typed event filtering on subscriptions
|
|
- Async iteration via EventSubscription
|
|
"""
|
|
|
|
def __init__(self, max_history: int = 1000):
|
|
self._subscribers: list[tuple[asyncio.Queue[GatewayEvent], Optional[set[EventType]]]] = []
|
|
self._history: list[GatewayEvent] = []
|
|
self._max_history = max_history
|
|
|
|
async def publish(self, event: GatewayEvent) -> None:
|
|
"""Publish an event to all subscribers."""
|
|
self._history.append(event)
|
|
if len(self._history) > self._max_history:
|
|
self._history = self._history[-self._max_history :]
|
|
|
|
logger.info(f"📡 Event: {event.type.value} | {event.message or ''}")
|
|
|
|
dead_queues = []
|
|
for queue, type_filter in self._subscribers:
|
|
# Skip if subscriber has a type filter and this event doesn't match
|
|
if type_filter and event.type not in type_filter:
|
|
continue
|
|
try:
|
|
queue.put_nowait(event)
|
|
except asyncio.QueueFull:
|
|
dead_queues.append((queue, type_filter))
|
|
|
|
for entry in dead_queues:
|
|
self._subscribers.remove(entry)
|
|
|
|
def subscribe(
|
|
self,
|
|
max_size: int = 100,
|
|
event_types: Optional[set[EventType]] = None,
|
|
) -> "EventSubscription":
|
|
"""
|
|
Create a new subscription.
|
|
|
|
Args:
|
|
max_size: Queue depth before subscriber is considered dead.
|
|
event_types: Optional filter — only receive these event types.
|
|
None means receive everything.
|
|
|
|
Returns:
|
|
An async iterator of GatewayEvents.
|
|
"""
|
|
queue: asyncio.Queue[GatewayEvent] = asyncio.Queue(maxsize=max_size)
|
|
entry = (queue, event_types)
|
|
self._subscribers.append(entry)
|
|
return EventSubscription(queue, self, entry)
|
|
|
|
def unsubscribe(self, entry: tuple) -> None:
|
|
"""Remove a subscriber."""
|
|
if entry in self._subscribers:
|
|
self._subscribers.remove(entry)
|
|
|
|
@property
|
|
def recent_events(self) -> list[GatewayEvent]:
|
|
"""Get recent event history."""
|
|
return list(self._history)
|
|
|
|
@property
|
|
def subscriber_count(self) -> int:
|
|
return len(self._subscribers)
|
|
|
|
|
|
class EventSubscription:
|
|
"""An async iterator that yields events from the bus."""
|
|
|
|
def __init__(
|
|
self,
|
|
queue: asyncio.Queue[GatewayEvent],
|
|
bus: EventBus,
|
|
entry: tuple,
|
|
):
|
|
self._queue = queue
|
|
self._bus = bus
|
|
self._entry = entry
|
|
|
|
def __aiter__(self):
|
|
return self
|
|
|
|
async def __anext__(self) -> GatewayEvent:
|
|
try:
|
|
return await self._queue.get()
|
|
except asyncio.CancelledError:
|
|
self._bus.unsubscribe(self._entry)
|
|
raise
|
|
|
|
async def get(self, timeout: Optional[float] = None) -> GatewayEvent:
|
|
"""Get next event with optional timeout."""
|
|
return await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
|
|
|
def close(self):
|
|
"""Unsubscribe from the event bus."""
|
|
self._bus.unsubscribe(self._entry)
|