fix(notifications): align Phase 2.1 dispatcher with spec

The initial 2.1 dispatcher was a logging stub with manual backend
registration. The spec required integration with the existing
NotificationRuleConfig schema and channels.py create_channel factory.

- Dispatcher takes (config, channel_factory)
- _matching_rules iterates config.notifications.rules with severity ranking
- dispatch() builds alert dict and calls channel.deliver()
- build_pipeline(config) returns EventBus per spec
- build_pipeline_components(config) added for test introspection
This commit is contained in:
K7ZVX 2026-05-14 18:06:08 +00:00
commit 866c55a91c
2 changed files with 143 additions and 231 deletions

View file

@ -1,88 +1,66 @@
"""Notification pipeline package. """Notification pipeline package.
Phase 2.1 provides the bare skeleton: Phase 2.1 skeleton:
- EventBus: Central pub/sub for all events - EventBus: pub/sub for adapter ingress
- SeverityRouter: Routes immediate vs digest events - SeverityRouter: forks immediate vs digest paths
- Dispatcher: Delivers immediate events to channels - Dispatcher: routes immediate events to channels via existing rules
- StubDigestQueue: Placeholder for Phase 2.3 aggregator - StubDigestQueue: placeholder for Phase 2.3 aggregator
Usage: Usage:
from meshai.notifications.pipeline import build_pipeline from meshai.notifications.pipeline import build_pipeline
bus = build_pipeline(config)
pipeline = build_pipeline(channel_config={ bus.emit(event)
"mesh_health": ["discord"],
"weather": ["discord", "meshtastic"],
})
# Emit events through the bus
pipeline["bus"].emit(event)
""" """
from meshai.notifications.channels import create_channel
from meshai.notifications.pipeline.bus import EventBus, get_bus from meshai.notifications.pipeline.bus import EventBus, get_bus
from meshai.notifications.pipeline.severity_router import ( from meshai.notifications.pipeline.severity_router import (
SeverityRouter, SeverityRouter,
StubDigestQueue, StubDigestQueue,
) )
from meshai.notifications.pipeline.dispatcher import ( from meshai.notifications.pipeline.dispatcher import Dispatcher
Dispatcher,
StubChannelBackend,
)
def build_pipeline( def build_pipeline(config) -> EventBus:
channel_config: dict[str, list[str]] | None = None, """Build the pipeline and return the EventBus.
) -> dict:
"""Build and wire up the notification pipeline.
Creates all pipeline components and connects them: Adapters emit events to this bus and they flow through the
- EventBus receives all events severity router to either the dispatcher (immediate) or the
- SeverityRouter subscribes to bus, routes by severity digest stub (priority/routine).
- Dispatcher handles immediate events
- StubDigestQueue collects priority/routine events
Args:
channel_config: Mapping of toggle -> channel names for dispatch.
Example: {"mesh_health": ["discord"]}
Returns:
Dict with all pipeline components:
- bus: EventBus instance
- router: SeverityRouter instance
- dispatcher: Dispatcher instance
- digest_queue: StubDigestQueue instance
""" """
# Create components
bus = EventBus() bus = EventBus()
dispatcher = Dispatcher(channel_config) dispatcher = Dispatcher(config, create_channel)
digest_queue = StubDigestQueue() digest = StubDigestQueue()
severity_router = SeverityRouter(
# Wire up the router
router = SeverityRouter(
immediate_handler=dispatcher.dispatch, immediate_handler=dispatcher.dispatch,
digest_handler=digest_queue.enqueue, digest_handler=digest.enqueue,
) )
bus.subscribe(severity_router.handle)
return bus
# Subscribe router to bus
bus.subscribe(router.handle)
return { def build_pipeline_components(config) -> tuple:
"bus": bus, """Like build_pipeline, but returns all components for test inspection.
"router": router,
"dispatcher": dispatcher, Returns (bus, dispatcher, digest, severity_router).
"digest_queue": digest_queue, """
} bus = EventBus()
dispatcher = Dispatcher(config, create_channel)
digest = StubDigestQueue()
severity_router = SeverityRouter(
immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue,
)
bus.subscribe(severity_router.handle)
return bus, dispatcher, digest, severity_router
__all__ = [ __all__ = [
# Core classes
"EventBus", "EventBus",
"SeverityRouter", "SeverityRouter",
"Dispatcher",
# Stubs for testing/Phase 2.x
"StubDigestQueue", "StubDigestQueue",
"StubChannelBackend", "Dispatcher",
# Factory
"build_pipeline", "build_pipeline",
# Singleton accessor "build_pipeline_components",
"get_bus", "get_bus",
] ]

View file

@ -1,143 +1,77 @@
"""Immediate event dispatcher. """Immediate event dispatcher.
The dispatcher routes immediate-severity events to configured delivery The dispatcher routes immediate-severity events through the existing
channels based on the event's toggle category. NotificationRuleConfig rules and delivers via channels.py. This is the
transitional bridge between the new Event pipeline and the existing
Phase 2.1 provides a stub that logs dispatch attempts. Phase 2.2 will channel implementations.
add real channel backends (Discord webhooks, Meshtastic broadcast, etc.).
Usage:
dispatcher = Dispatcher(channel_config)
dispatcher.dispatch(event) # Called by SeverityRouter for immediate events
""" """
import logging import logging
from typing import Callable, Optional from typing import Callable
from meshai.notifications.events import Event from meshai.notifications.events import Event
from meshai.notifications.categories import get_toggle
class Dispatcher: class Dispatcher:
"""Dispatches immediate events to configured channels. """Dispatches immediate events to channels matching configured rules."""
Each toggle category can have multiple delivery channels configured. SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
The dispatcher looks up the toggle for an event's category and sends
to all channels registered for that toggle.
Phase 2.1: Stub implementation that logs but doesn't actually deliver. def __init__(self, config, channel_factory: Callable):
Phase 2.2: Will add real channel backends. """Initialize.
"""
def __init__(
self,
channel_config: Optional[dict[str, list[str]]] = None,
):
"""Initialize the dispatcher.
Args: Args:
channel_config: Mapping of toggle -> list of channel names. config: The full Config object (provides config.notifications.rules)
Example: {"mesh_health": ["discord", "meshtastic"]} channel_factory: Callable taking a NotificationRuleConfig and
If None, defaults to empty (no channels configured). returning a NotificationChannel. This is create_channel
from meshai/notifications/channels.py.
""" """
self._channels = channel_config or {} self._config = config
self._channel_factory = channel_factory
self._logger = logging.getLogger("meshai.pipeline.dispatcher") self._logger = logging.getLogger("meshai.pipeline.dispatcher")
self._backends: dict[str, Callable[[Event], None]] = {}
def register_backend(
self,
channel_name: str,
handler: Callable[[Event], None],
) -> None:
"""Register a delivery backend for a channel.
Args:
channel_name: Name of the channel (e.g., "discord", "meshtastic")
handler: Callable that delivers the event to the channel
"""
self._backends[channel_name] = handler
self._logger.debug(f"Registered backend: {channel_name}")
def dispatch(self, event: Event) -> None: def dispatch(self, event: Event) -> None:
"""Dispatch an immediate event to configured channels. """Deliver an immediate-severity event to all matching channels."""
rules = self._matching_rules(event)
Looks up the toggle for the event's category, then sends to if not rules:
all channels configured for that toggle. self._logger.debug(
f"No matching rules for {event.source}/{event.category}, skipping"
Args:
event: The immediate-severity Event to dispatch
"""
toggle = get_toggle(event.category)
if toggle is None:
self._logger.warning(
f"Unknown category {event.category!r} for event {event.id}, "
"defaulting to mesh_health"
)
toggle = "mesh_health"
channels = self._channels.get(toggle, [])
if not channels:
self._logger.info(
f"No channels configured for toggle {toggle!r}, "
f"event {event.id} not dispatched"
) )
return return
for rule in rules:
for channel in channels:
self._deliver_to_channel(event, channel, toggle)
def _deliver_to_channel(
self,
event: Event,
channel: str,
toggle: str,
) -> None:
"""Deliver event to a specific channel.
Args:
event: The Event to deliver
channel: Channel name
toggle: Toggle category (for logging)
"""
backend = self._backends.get(channel)
if backend is None:
# Phase 2.1: Log stub - no real backend yet
self._logger.info(
f"DISPATCH STUB [{toggle}] -> {channel}: {event.title}"
)
return
try: try:
backend(event) channel = self._channel_factory(rule)
alert = {
"category": event.category,
"severity": event.severity,
"message": event.summary or event.title,
"node_id": event.node_ids[0] if event.node_ids else None,
"region": event.region,
"timestamp": event.timestamp,
}
channel.deliver(alert)
self._logger.info( self._logger.info(
f"DISPATCHED [{toggle}] -> {channel}: {event.title}" f"Dispatched event {event.id} via {rule.delivery_type}"
) )
except Exception: except Exception:
self._logger.exception( self._logger.exception(
f"Failed to dispatch event {event.id} to {channel}" f"Channel delivery failed for rule {rule.name}"
) )
def _matching_rules(self, event: Event) -> list:
class StubChannelBackend: """Return enabled condition rules matching this event's category
"""Stub channel backend for testing. and severity threshold."""
event_rank = self.SEVERITY_RANK.get(event.severity, 0)
Collects all events "sent" to it for verification in tests. matches = []
""" for rule in self._config.notifications.rules:
if not rule.enabled:
def __init__(self, name: str): continue
self.name = name if rule.trigger_type != "condition":
self.events: list[Event] = [] continue
self._logger = logging.getLogger(f"meshai.pipeline.stub.{name}") if rule.categories and event.category not in rule.categories:
continue
def send(self, event: Event) -> None: min_rank = self.SEVERITY_RANK.get(rule.min_severity, 0)
"""Record an event as sent. if event_rank < min_rank:
continue
Args: matches.append(rule)
event: The Event to record return matches
"""
self.events.append(event)
self._logger.info(f"STUB {self.name}: {event.title}")
def clear(self) -> None:
"""Clear recorded events."""
self.events = []