mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
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:
parent
4e4a837c5e
commit
866c55a91c
2 changed files with 143 additions and 231 deletions
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue