Files
hold-slayer/core/event_bus.py
Robert Helewka ecf37658ce feat: add initial Hold Slayer AI telephony gateway implementation
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
2026-03-21 19:23:26 +00:00

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)