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"],
}) from meshai.notifications.channels import create_channel
from meshai.notifications.pipeline.bus import EventBus, get_bus
# Emit events through the bus from meshai.notifications.pipeline.severity_router import (
pipeline["bus"].emit(event) SeverityRouter,
""" StubDigestQueue,
)
from meshai.notifications.pipeline.bus import EventBus, get_bus from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.notifications.pipeline.severity_router import (
SeverityRouter,
StubDigestQueue, def build_pipeline(config) -> EventBus:
) """Build the pipeline and return the EventBus.
from meshai.notifications.pipeline.dispatcher import (
Dispatcher, Adapters emit events to this bus and they flow through the
StubChannelBackend, severity router to either the dispatcher (immediate) or the
) digest stub (priority/routine).
"""
bus = EventBus()
def build_pipeline( dispatcher = Dispatcher(config, create_channel)
channel_config: dict[str, list[str]] | None = None, digest = StubDigestQueue()
) -> dict: severity_router = SeverityRouter(
"""Build and wire up the notification pipeline. immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue,
Creates all pipeline components and connects them: )
- EventBus receives all events bus.subscribe(severity_router.handle)
- SeverityRouter subscribes to bus, routes by severity return bus
- Dispatcher handles immediate events
- StubDigestQueue collects priority/routine events
def build_pipeline_components(config) -> tuple:
Args: """Like build_pipeline, but returns all components for test inspection.
channel_config: Mapping of toggle -> channel names for dispatch.
Example: {"mesh_health": ["discord"]} Returns (bus, dispatcher, digest, severity_router).
"""
Returns: bus = EventBus()
Dict with all pipeline components: dispatcher = Dispatcher(config, create_channel)
- bus: EventBus instance digest = StubDigestQueue()
- router: SeverityRouter instance severity_router = SeverityRouter(
- dispatcher: Dispatcher instance immediate_handler=dispatcher.dispatch,
- digest_queue: StubDigestQueue instance digest_handler=digest.enqueue,
""" )
# Create components bus.subscribe(severity_router.handle)
bus = EventBus() return bus, dispatcher, digest, severity_router
dispatcher = Dispatcher(channel_config)
digest_queue = StubDigestQueue()
__all__ = [
# Wire up the router "EventBus",
router = SeverityRouter( "SeverityRouter",
immediate_handler=dispatcher.dispatch, "StubDigestQueue",
digest_handler=digest_queue.enqueue, "Dispatcher",
) "build_pipeline",
"build_pipeline_components",
# Subscribe router to bus "get_bus",
bus.subscribe(router.handle) ]
return {
"bus": bus,
"router": router,
"dispatcher": dispatcher,
"digest_queue": digest_queue,
}
__all__ = [
# Core classes
"EventBus",
"SeverityRouter",
"Dispatcher",
# Stubs for testing/Phase 2.x
"StubDigestQueue",
"StubChannelBackend",
# Factory
"build_pipeline",
# Singleton accessor
"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: import logging
dispatcher = Dispatcher(channel_config) from typing import Callable
dispatcher.dispatch(event) # Called by SeverityRouter for immediate events
""" from meshai.notifications.events import Event
import logging
from typing import Callable, Optional class Dispatcher:
"""Dispatches immediate events to channels matching configured rules."""
from meshai.notifications.events import Event
from meshai.notifications.categories import get_toggle SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
def __init__(self, config, channel_factory: Callable):
class Dispatcher: """Initialize.
"""Dispatches immediate events to configured channels.
Args:
Each toggle category can have multiple delivery channels configured. config: The full Config object (provides config.notifications.rules)
The dispatcher looks up the toggle for an event's category and sends channel_factory: Callable taking a NotificationRuleConfig and
to all channels registered for that toggle. returning a NotificationChannel. This is create_channel
from meshai/notifications/channels.py.
Phase 2.1: Stub implementation that logs but doesn't actually deliver. """
Phase 2.2: Will add real channel backends. self._config = config
""" self._channel_factory = channel_factory
self._logger = logging.getLogger("meshai.pipeline.dispatcher")
def __init__(
self, def dispatch(self, event: Event) -> None:
channel_config: Optional[dict[str, list[str]]] = None, """Deliver an immediate-severity event to all matching channels."""
): rules = self._matching_rules(event)
"""Initialize the dispatcher. if not rules:
self._logger.debug(
Args: f"No matching rules for {event.source}/{event.category}, skipping"
channel_config: Mapping of toggle -> list of channel names. )
Example: {"mesh_health": ["discord", "meshtastic"]} return
If None, defaults to empty (no channels configured). for rule in rules:
""" try:
self._channels = channel_config or {} channel = self._channel_factory(rule)
self._logger = logging.getLogger("meshai.pipeline.dispatcher") alert = {
self._backends: dict[str, Callable[[Event], None]] = {} "category": event.category,
"severity": event.severity,
def register_backend( "message": event.summary or event.title,
self, "node_id": event.node_ids[0] if event.node_ids else None,
channel_name: str, "region": event.region,
handler: Callable[[Event], None], "timestamp": event.timestamp,
) -> None: }
"""Register a delivery backend for a channel. channel.deliver(alert)
self._logger.info(
Args: f"Dispatched event {event.id} via {rule.delivery_type}"
channel_name: Name of the channel (e.g., "discord", "meshtastic") )
handler: Callable that delivers the event to the channel except Exception:
""" self._logger.exception(
self._backends[channel_name] = handler f"Channel delivery failed for rule {rule.name}"
self._logger.debug(f"Registered backend: {channel_name}") )
def dispatch(self, event: Event) -> None: def _matching_rules(self, event: Event) -> list:
"""Dispatch an immediate event to configured channels. """Return enabled condition rules matching this event's category
and severity threshold."""
Looks up the toggle for the event's category, then sends to event_rank = self.SEVERITY_RANK.get(event.severity, 0)
all channels configured for that toggle. matches = []
for rule in self._config.notifications.rules:
Args: if not rule.enabled:
event: The immediate-severity Event to dispatch continue
""" if rule.trigger_type != "condition":
toggle = get_toggle(event.category) continue
if toggle is None: if rule.categories and event.category not in rule.categories:
self._logger.warning( continue
f"Unknown category {event.category!r} for event {event.id}, " min_rank = self.SEVERITY_RANK.get(rule.min_severity, 0)
"defaulting to mesh_health" if event_rank < min_rank:
) continue
toggle = "mesh_health" matches.append(rule)
return matches
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
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:
backend(event)
self._logger.info(
f"DISPATCHED [{toggle}] -> {channel}: {event.title}"
)
except Exception:
self._logger.exception(
f"Failed to dispatch event {event.id} to {channel}"
)
class StubChannelBackend:
"""Stub channel backend for testing.
Collects all events "sent" to it for verification in tests.
"""
def __init__(self, name: str):
self.name = name
self.events: list[Event] = []
self._logger = logging.getLogger(f"meshai.pipeline.stub.{name}")
def send(self, event: Event) -> None:
"""Record an event as sent.
Args:
event: The Event to record
"""
self.events.append(event)
self._logger.info(f"STUB {self.name}: {event.title}")
def clear(self) -> None:
"""Clear recorded events."""
self.events = []