mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat(notifications): Phase 2.5a channel interface unification
- Switch channels.py from dict-based to dataclass-based interfaces - Add NotificationPayload dataclass and make_payload_from_event helper - Update channel.deliver() to be async with (payload, rule) signature - Add connector parameter to Dispatcher, DigestScheduler, and pipeline builders - Update pipeline tee to use asyncio.create_task for async dispatch - Add create_channel_from_dict for legacy router.py compatibility - Update tests for new async interfaces Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a4cb29002d
commit
c9d9a9925c
8 changed files with 235 additions and 129 deletions
|
|
@ -14,6 +14,8 @@ import httpx
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..connector import MeshConnector
|
from ..connector import MeshConnector
|
||||||
|
from ..config import NotificationRuleConfig
|
||||||
|
from .events import NotificationPayload
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -24,7 +26,7 @@ class NotificationChannel(ABC):
|
||||||
channel_type: str = "base"
|
channel_type: str = "base"
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""Send alert. Returns True on success."""
|
"""Send alert. Returns True on success."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
@ -60,14 +62,14 @@ class MeshBroadcastChannel(NotificationChannel):
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._channel = channel_index
|
self._channel = channel_index
|
||||||
|
|
||||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""Send alert to mesh channel."""
|
"""Send alert to mesh channel."""
|
||||||
if not self._connector:
|
if not self._connector:
|
||||||
logger.warning("No mesh connector available")
|
logger.warning("No mesh connector available")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = alert.get("message", "")
|
message = alert.message or ""
|
||||||
self._connector.send_message(
|
self._connector.send_message(
|
||||||
text=message,
|
text=message,
|
||||||
destination=None,
|
destination=None,
|
||||||
|
|
@ -158,12 +160,12 @@ class MeshDMChannel(NotificationChannel):
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._node_ids = node_ids
|
self._node_ids = node_ids
|
||||||
|
|
||||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""Send alert via DM to configured nodes."""
|
"""Send alert via DM to configured nodes."""
|
||||||
if not self._connector:
|
if not self._connector:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
message = alert.get("message", "")
|
message = alert.message or ""
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
for node_id in self._node_ids:
|
for node_id in self._node_ids:
|
||||||
|
|
@ -286,14 +288,14 @@ class EmailChannel(NotificationChannel):
|
||||||
self._from = from_address
|
self._from = from_address
|
||||||
self._recipients = recipients
|
self._recipients = recipients
|
||||||
|
|
||||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""Send alert via email."""
|
"""Send alert via email."""
|
||||||
if not self._recipients:
|
if not self._recipients:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
alert_type = alert.get("type", "alert")
|
alert_type = alert.event_type or "alert"
|
||||||
severity = alert.get("severity", "routine").upper()
|
severity = (alert.severity or "routine").upper()
|
||||||
message = alert.get("message", "")
|
message = alert.message or ""
|
||||||
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
||||||
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
||||||
alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message
|
alert_type, severity, time.strftime("%Y-%m-%d %H:%M:%S"), message
|
||||||
|
|
@ -514,20 +516,20 @@ class WebhookChannel(NotificationChannel):
|
||||||
self._url = url
|
self._url = url
|
||||||
self._headers = headers or {}
|
self._headers = headers or {}
|
||||||
|
|
||||||
async def deliver(self, alert: dict, rule: dict) -> bool:
|
async def deliver(self, alert: "NotificationPayload", rule: "NotificationRuleConfig") -> bool:
|
||||||
"""POST alert to webhook URL."""
|
"""POST alert to webhook URL."""
|
||||||
payload = {
|
payload = {
|
||||||
"type": alert.get("type"),
|
"type": alert.event_type,
|
||||||
"severity": alert.get("severity", "routine"),
|
"severity": alert.severity or "routine",
|
||||||
"message": alert.get("message", ""),
|
"message": alert.message or "",
|
||||||
"timestamp": time.time(),
|
"timestamp": alert.timestamp or time.time(),
|
||||||
"node_name": alert.get("node_name"),
|
"node_name": alert.node_name,
|
||||||
"region": alert.get("region"),
|
"region": alert.region,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Discord/Slack format
|
# Discord/Slack format
|
||||||
if "discord.com" in self._url or "slack.com" in self._url:
|
if "discord.com" in self._url or "slack.com" in self._url:
|
||||||
severity = alert.get("severity", "routine")
|
severity = alert.severity or "routine"
|
||||||
color = {
|
color = {
|
||||||
"immediate": 0xFF0000,
|
"immediate": 0xFF0000,
|
||||||
"priority": 0xFFAA00,
|
"priority": 0xFFAA00,
|
||||||
|
|
@ -535,8 +537,8 @@ class WebhookChannel(NotificationChannel):
|
||||||
}.get(severity, 0x888888)
|
}.get(severity, 0x888888)
|
||||||
payload = {
|
payload = {
|
||||||
"embeds": [{
|
"embeds": [{
|
||||||
"title": "MeshAI: %s" % alert.get("type", "unknown"),
|
"title": "MeshAI: %s" % (alert.event_type or "unknown"),
|
||||||
"description": alert.get("message", ""),
|
"description": alert.message or "",
|
||||||
"color": color,
|
"color": color,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
@ -545,14 +547,14 @@ class WebhookChannel(NotificationChannel):
|
||||||
elif "ntfy" in self._url:
|
elif "ntfy" in self._url:
|
||||||
headers = {
|
headers = {
|
||||||
**self._headers,
|
**self._headers,
|
||||||
"Title": "MeshAI: %s" % alert.get("type", "alert"),
|
"Title": "MeshAI: %s" % (alert.event_type or "alert"),
|
||||||
"Priority": "3",
|
"Priority": "3",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
self._url,
|
self._url,
|
||||||
content=alert.get("message", ""),
|
content=alert.message or "",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
@ -745,8 +747,52 @@ class WebhookChannel(NotificationChannel):
|
||||||
return False, f"Webhook failed: {e}"
|
return False, f"Webhook failed: {e}"
|
||||||
|
|
||||||
|
|
||||||
def create_channel(config: dict, connector=None) -> NotificationChannel:
|
def create_channel(rule: "NotificationRuleConfig", connector=None) -> NotificationChannel:
|
||||||
"""Create a channel instance from config."""
|
"""Create a channel instance from a NotificationRuleConfig.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: NotificationRuleConfig with delivery_type and channel settings
|
||||||
|
connector: MeshConnector instance (required for mesh channels)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotificationChannel instance
|
||||||
|
"""
|
||||||
|
delivery_type = rule.delivery_type
|
||||||
|
|
||||||
|
if delivery_type == "mesh_broadcast":
|
||||||
|
return MeshBroadcastChannel(
|
||||||
|
connector=connector,
|
||||||
|
channel_index=rule.broadcast_channel,
|
||||||
|
)
|
||||||
|
elif delivery_type == "mesh_dm":
|
||||||
|
return MeshDMChannel(
|
||||||
|
connector=connector,
|
||||||
|
node_ids=rule.node_ids,
|
||||||
|
)
|
||||||
|
elif delivery_type == "email":
|
||||||
|
return EmailChannel(
|
||||||
|
smtp_host=rule.smtp_host,
|
||||||
|
smtp_port=rule.smtp_port,
|
||||||
|
smtp_user=rule.smtp_user,
|
||||||
|
smtp_password=rule.smtp_password,
|
||||||
|
smtp_tls=rule.smtp_tls,
|
||||||
|
from_address=rule.from_address,
|
||||||
|
recipients=rule.recipients,
|
||||||
|
)
|
||||||
|
elif delivery_type == "webhook":
|
||||||
|
return WebhookChannel(
|
||||||
|
url=rule.webhook_url,
|
||||||
|
headers=rule.webhook_headers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown delivery type: %s" % delivery_type)
|
||||||
|
|
||||||
|
|
||||||
|
def create_channel_from_dict(config: dict, connector=None) -> NotificationChannel:
|
||||||
|
"""Create a channel instance from a dict config (legacy interface).
|
||||||
|
|
||||||
|
Used by old router.py and test_channel API. Will be removed in Phase 2.7.
|
||||||
|
"""
|
||||||
channel_type = config.get("type", "")
|
channel_type = config.get("type", "")
|
||||||
|
|
||||||
if channel_type == "mesh_broadcast":
|
if channel_type == "mesh_broadcast":
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,52 @@ class Event:
|
||||||
return cls(**d)
|
return cls(**d)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationPayload:
|
||||||
|
"""Per-delivery alert content handed to a NotificationChannel.
|
||||||
|
|
||||||
|
This is the runtime alert shape: derived from an Event (or
|
||||||
|
built directly by the old router) and consumed by channels.py
|
||||||
|
implementations.
|
||||||
|
"""
|
||||||
|
message: str # The rendered text to deliver
|
||||||
|
category: str # e.g. "weather_warning"
|
||||||
|
severity: str # "immediate" | "priority" | "routine"
|
||||||
|
timestamp: float # Unix epoch when generated
|
||||||
|
|
||||||
|
# Optional context fields (None when not applicable)
|
||||||
|
node_id: Optional[str] = None
|
||||||
|
node_name: Optional[str] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
event_type: Optional[str] = None # Maps to old dict's "type" field
|
||||||
|
|
||||||
|
# Chunk metadata for mesh deliveries (set by scheduler/digest path)
|
||||||
|
chunk_index: Optional[int] = None
|
||||||
|
chunk_total: Optional[int] = None
|
||||||
|
|
||||||
|
# Source Event reference for advanced channel use (renderers in 2.5b)
|
||||||
|
source_event: Optional["Event"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def make_payload_from_event(event: "Event", **overrides) -> NotificationPayload:
|
||||||
|
"""Helper to convert an Event into a NotificationPayload."""
|
||||||
|
p = NotificationPayload(
|
||||||
|
message=event.summary or event.title or event.category,
|
||||||
|
category=event.category,
|
||||||
|
severity=event.severity,
|
||||||
|
timestamp=event.timestamp,
|
||||||
|
node_id=event.node_ids[0] if event.node_ids else None,
|
||||||
|
region=event.region,
|
||||||
|
event_type=event.category,
|
||||||
|
source_event=event,
|
||||||
|
)
|
||||||
|
for k, v in overrides.items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def make_event(
|
def make_event(
|
||||||
source: str,
|
source: str,
|
||||||
category: str,
|
category: str,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ Usage:
|
||||||
await stop_pipeline(scheduler)
|
await stop_pipeline(scheduler)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from meshai.notifications.channels import create_channel
|
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 (
|
||||||
|
|
@ -35,7 +37,7 @@ from meshai.notifications.pipeline.digest import DigestAccumulator, Digest
|
||||||
from meshai.notifications.pipeline.scheduler import DigestScheduler
|
from meshai.notifications.pipeline.scheduler import DigestScheduler
|
||||||
|
|
||||||
|
|
||||||
def build_pipeline(config, llm_backend) -> EventBus:
|
def build_pipeline(config, llm_backend, connector=None) -> EventBus:
|
||||||
"""Build the pipeline and return the EventBus.
|
"""Build the pipeline and return the EventBus.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -43,11 +45,12 @@ def build_pipeline(config, llm_backend) -> EventBus:
|
||||||
llm_backend: An already-constructed LLMBackend instance
|
llm_backend: An already-constructed LLMBackend instance
|
||||||
(from main.py or a test). Pipeline components share
|
(from main.py or a test). Pipeline components share
|
||||||
this single instance. May be None for fallback behavior.
|
this single instance. May be None for fallback behavior.
|
||||||
|
connector: Optional MeshtasticConnector for mesh channels.
|
||||||
|
|
||||||
Components are stashed on bus._pipeline_components for lifecycle use.
|
Components are stashed on bus._pipeline_components for lifecycle use.
|
||||||
"""
|
"""
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
dispatcher = Dispatcher(config, create_channel)
|
dispatcher = Dispatcher(config, create_channel, connector=connector)
|
||||||
|
|
||||||
# Build include_toggles from config
|
# Build include_toggles from config
|
||||||
digest_cfg = getattr(config.notifications, "digest", None)
|
digest_cfg = getattr(config.notifications, "digest", None)
|
||||||
|
|
@ -63,8 +66,13 @@ def build_pipeline(config, llm_backend) -> EventBus:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tee closure: events go to BOTH dispatcher and accumulator
|
# Tee closure: events go to BOTH dispatcher and accumulator
|
||||||
|
# dispatcher.dispatch() is async, so fire-and-forget with create_task
|
||||||
def _tee(event):
|
def _tee(event):
|
||||||
dispatcher.dispatch(event)
|
try:
|
||||||
|
asyncio.create_task(dispatcher.dispatch(event))
|
||||||
|
except RuntimeError:
|
||||||
|
# No running event loop (e.g. sync tests) - skip async dispatch
|
||||||
|
pass
|
||||||
accumulator.enqueue(event)
|
accumulator.enqueue(event)
|
||||||
|
|
||||||
# Build enabled toggles set from config
|
# Build enabled toggles set from config
|
||||||
|
|
@ -91,12 +99,13 @@ def build_pipeline(config, llm_backend) -> EventBus:
|
||||||
"toggle_filter": toggle_filter,
|
"toggle_filter": toggle_filter,
|
||||||
"dispatcher": dispatcher,
|
"dispatcher": dispatcher,
|
||||||
"accumulator": accumulator,
|
"accumulator": accumulator,
|
||||||
|
"connector": connector,
|
||||||
}
|
}
|
||||||
|
|
||||||
return bus
|
return bus
|
||||||
|
|
||||||
|
|
||||||
def build_pipeline_components(config, llm_backend) -> tuple:
|
def build_pipeline_components(config, llm_backend, connector=None) -> tuple:
|
||||||
"""Like build_pipeline, but returns all components for tests.
|
"""Like build_pipeline, but returns all components for tests.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -104,12 +113,13 @@ def build_pipeline_components(config, llm_backend) -> tuple:
|
||||||
llm_backend: An already-constructed LLMBackend instance
|
llm_backend: An already-constructed LLMBackend instance
|
||||||
(from main.py or a test). Pipeline components share
|
(from main.py or a test). Pipeline components share
|
||||||
this single instance. May be None for fallback behavior.
|
this single instance. May be None for fallback behavior.
|
||||||
|
connector: Optional MeshtasticConnector for mesh channels.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator).
|
(bus, inhibitor, grouper, toggle_filter, dispatcher, accumulator).
|
||||||
"""
|
"""
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
dispatcher = Dispatcher(config, create_channel)
|
dispatcher = Dispatcher(config, create_channel, connector=connector)
|
||||||
|
|
||||||
# Build include_toggles from config
|
# Build include_toggles from config
|
||||||
digest_cfg = getattr(config.notifications, "digest", None)
|
digest_cfg = getattr(config.notifications, "digest", None)
|
||||||
|
|
@ -125,8 +135,13 @@ def build_pipeline_components(config, llm_backend) -> tuple:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tee closure: events go to BOTH dispatcher and accumulator
|
# Tee closure: events go to BOTH dispatcher and accumulator
|
||||||
|
# dispatcher.dispatch() is async, so fire-and-forget with create_task
|
||||||
def _tee(event):
|
def _tee(event):
|
||||||
dispatcher.dispatch(event)
|
try:
|
||||||
|
asyncio.create_task(dispatcher.dispatch(event))
|
||||||
|
except RuntimeError:
|
||||||
|
# No running event loop (e.g. sync tests) - skip async dispatch
|
||||||
|
pass
|
||||||
accumulator.enqueue(event)
|
accumulator.enqueue(event)
|
||||||
|
|
||||||
# Build enabled toggles set from config
|
# Build enabled toggles set from config
|
||||||
|
|
@ -165,10 +180,12 @@ async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
|
||||||
|
|
||||||
accumulator = components["accumulator"]
|
accumulator = components["accumulator"]
|
||||||
|
|
||||||
|
connector = components.get("connector")
|
||||||
scheduler = DigestScheduler(
|
scheduler = DigestScheduler(
|
||||||
accumulator=accumulator,
|
accumulator=accumulator,
|
||||||
config=config,
|
config=config,
|
||||||
channel_factory=create_channel,
|
channel_factory=create_channel,
|
||||||
|
connector=connector,
|
||||||
)
|
)
|
||||||
await scheduler.start()
|
await scheduler.start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ The dispatcher routes immediate-severity events through the existing
|
||||||
NotificationRuleConfig rules and delivers via channels.py. This is the
|
NotificationRuleConfig rules and delivers via channels.py. This is the
|
||||||
transitional bridge between the new Event pipeline and the existing
|
transitional bridge between the new Event pipeline and the existing
|
||||||
channel implementations.
|
channel implementations.
|
||||||
|
|
||||||
|
Phase 2.5a: dispatch() is now async, takes a connector at construction,
|
||||||
|
and properly awaits channel.deliver(payload, rule).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from meshai.notifications.events import Event
|
from meshai.notifications.events import Event, make_payload_from_event
|
||||||
|
|
||||||
|
|
||||||
class Dispatcher:
|
class Dispatcher:
|
||||||
|
|
@ -17,21 +20,26 @@ class Dispatcher:
|
||||||
|
|
||||||
SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
|
SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
|
||||||
|
|
||||||
def __init__(self, config, channel_factory: Callable):
|
def __init__(self, config, channel_factory: Callable, connector=None):
|
||||||
"""Initialize.
|
"""Initialize.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: The full Config object (provides config.notifications.rules)
|
config: The full Config object (provides config.notifications.rules)
|
||||||
channel_factory: Callable taking a NotificationRuleConfig and
|
channel_factory: Callable taking (rule, connector) and returning
|
||||||
returning a NotificationChannel. This is create_channel
|
a NotificationChannel. This is create_channel from
|
||||||
from meshai/notifications/channels.py.
|
meshai/notifications/channels.py.
|
||||||
|
connector: MeshConnector instance for mesh channel deliveries.
|
||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
self._channel_factory = channel_factory
|
self._channel_factory = channel_factory
|
||||||
|
self._connector = connector
|
||||||
self._logger = logging.getLogger("meshai.pipeline.dispatcher")
|
self._logger = logging.getLogger("meshai.pipeline.dispatcher")
|
||||||
|
|
||||||
def dispatch(self, event: Event) -> None:
|
async def dispatch(self, event: Event) -> None:
|
||||||
"""Deliver an immediate-severity event to all matching channels."""
|
"""Deliver an immediate-severity event to all matching channels.
|
||||||
|
|
||||||
|
This method is async and awaits each channel.deliver() call.
|
||||||
|
"""
|
||||||
rules = self._matching_rules(event)
|
rules = self._matching_rules(event)
|
||||||
if not rules:
|
if not rules:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
|
|
@ -40,19 +48,17 @@ class Dispatcher:
|
||||||
return
|
return
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
try:
|
try:
|
||||||
channel = self._channel_factory(rule)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
alert = {
|
payload = make_payload_from_event(event)
|
||||||
"category": event.category,
|
success = await channel.deliver(payload, rule)
|
||||||
"severity": event.severity,
|
if success:
|
||||||
"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 event {event.id} via {rule.delivery_type}"
|
f"Dispatched event {event.id} via {rule.delivery_type}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self._logger.warning(
|
||||||
|
f"Channel delivery returned False for rule {rule.name}"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
f"Channel delivery failed for rule {rule.name}"
|
f"Channel delivery failed for rule {rule.name}"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from datetime import datetime, timedelta
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from meshai.notifications.pipeline.digest import DigestAccumulator
|
from meshai.notifications.pipeline.digest import DigestAccumulator
|
||||||
|
from meshai.notifications.events import NotificationPayload
|
||||||
|
|
||||||
|
|
||||||
class DigestScheduler:
|
class DigestScheduler:
|
||||||
|
|
@ -23,12 +24,14 @@ class DigestScheduler:
|
||||||
accumulator: DigestAccumulator,
|
accumulator: DigestAccumulator,
|
||||||
config,
|
config,
|
||||||
channel_factory: Callable,
|
channel_factory: Callable,
|
||||||
|
connector=None,
|
||||||
clock: Optional[Callable[[], float]] = None,
|
clock: Optional[Callable[[], float]] = None,
|
||||||
sleep: Optional[Callable[[float], "asyncio.Future"]] = None,
|
sleep: Optional[Callable[[float], "asyncio.Future"]] = None,
|
||||||
):
|
):
|
||||||
self._accumulator = accumulator
|
self._accumulator = accumulator
|
||||||
self._config = config
|
self._config = config
|
||||||
self._channel_factory = channel_factory
|
self._channel_factory = channel_factory
|
||||||
|
self._connector = connector
|
||||||
self._clock = clock or time.time
|
self._clock = clock or time.time
|
||||||
self._sleep = sleep or asyncio.sleep
|
self._sleep = sleep or asyncio.sleep
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
|
@ -120,7 +123,7 @@ class DigestScheduler:
|
||||||
|
|
||||||
async def _deliver_to_rule(self, rule, digest, now: float) -> None:
|
async def _deliver_to_rule(self, rule, digest, now: float) -> None:
|
||||||
"""Hand the rendered digest to a channel based on rule.delivery_type."""
|
"""Hand the rendered digest to a channel based on rule.delivery_type."""
|
||||||
channel = self._channel_factory(rule)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
delivery_type = rule.delivery_type
|
delivery_type = rule.delivery_type
|
||||||
|
|
||||||
if delivery_type in ("mesh_broadcast", "mesh_dm"):
|
if delivery_type in ("mesh_broadcast", "mesh_dm"):
|
||||||
|
|
@ -128,31 +131,27 @@ class DigestScheduler:
|
||||||
chunks = digest.mesh_chunks
|
chunks = digest.mesh_chunks
|
||||||
total = len(chunks)
|
total = len(chunks)
|
||||||
for i, chunk in enumerate(chunks, start=1):
|
for i, chunk in enumerate(chunks, start=1):
|
||||||
payload = {
|
payload = NotificationPayload(
|
||||||
"category": "digest",
|
message=chunk,
|
||||||
"severity": "routine",
|
category="digest",
|
||||||
"message": chunk,
|
severity="routine",
|
||||||
"node_id": None,
|
timestamp=now,
|
||||||
"region": None,
|
chunk_index=i,
|
||||||
"timestamp": now,
|
chunk_total=total,
|
||||||
"chunk_index": i,
|
)
|
||||||
"chunk_total": total,
|
await channel.deliver(payload, rule)
|
||||||
}
|
|
||||||
channel.deliver(payload)
|
|
||||||
self._logger.info(
|
self._logger.info(
|
||||||
f"Delivered {total} mesh chunk(s) to rule {rule.name!r}"
|
f"Delivered {total} mesh chunk(s) to rule {rule.name!r}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Single full-form delivery
|
# Single full-form delivery
|
||||||
payload = {
|
payload = NotificationPayload(
|
||||||
"category": "digest",
|
message=digest.full,
|
||||||
"severity": "routine",
|
category="digest",
|
||||||
"message": digest.full,
|
severity="routine",
|
||||||
"node_id": None,
|
timestamp=now,
|
||||||
"region": None,
|
)
|
||||||
"timestamp": now,
|
await channel.deliver(payload, rule)
|
||||||
}
|
|
||||||
channel.deliver(payload)
|
|
||||||
self._logger.info(
|
self._logger.info(
|
||||||
f"Delivered digest to rule {rule.name!r} via {delivery_type}"
|
f"Delivered digest to rule {rule.name!r} via {delivery_type}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from .channels import create_channel, NotificationChannel
|
from .channels import create_channel_from_dict, NotificationChannel
|
||||||
|
from .events import NotificationPayload
|
||||||
from .summarizer import MessageSummarizer
|
from .summarizer import MessageSummarizer
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -142,7 +143,7 @@ class NotificationRouter:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return create_channel(config, self._connector)
|
return create_channel_from_dict(config, self._connector)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
|
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
|
||||||
return None
|
return None
|
||||||
|
|
@ -199,7 +200,20 @@ class NotificationRouter:
|
||||||
else:
|
else:
|
||||||
delivery_alert = {**alert, "message": message[:195] + "..."}
|
delivery_alert = {**alert, "message": message[:195] + "..."}
|
||||||
|
|
||||||
success = await channel.deliver(delivery_alert, rule)
|
# Convert dict to NotificationPayload for channel interface
|
||||||
|
payload = NotificationPayload(
|
||||||
|
message=delivery_alert.get("message", ""),
|
||||||
|
category=delivery_alert.get("type", "unknown"),
|
||||||
|
severity=delivery_alert.get("severity", "routine"),
|
||||||
|
timestamp=delivery_alert.get("timestamp", time.time()),
|
||||||
|
node_id=delivery_alert.get("node_id"),
|
||||||
|
node_name=delivery_alert.get("node_name"),
|
||||||
|
region=delivery_alert.get("region"),
|
||||||
|
event_type=delivery_alert.get("type"),
|
||||||
|
)
|
||||||
|
# Rule is a dict here; channels don't use it so we pass None
|
||||||
|
# for the rule parameter (channels ignore it anyway)
|
||||||
|
success = await channel.deliver(payload, None)
|
||||||
if success:
|
if success:
|
||||||
delivered = True
|
delivered = True
|
||||||
self._record_fire(rule_name)
|
self._record_fire(rule_name)
|
||||||
|
|
@ -255,7 +269,7 @@ class NotificationRouter:
|
||||||
{success, message, error, details}
|
{success, message, error, details}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
channel = create_channel(channel_config, self._connector)
|
channel = create_channel_from_dict(channel_config, self._connector)
|
||||||
return await channel.test_connection()
|
return await channel.test_connection()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,9 @@ class MockChannel:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.deliveries = []
|
self.deliveries = []
|
||||||
|
|
||||||
def deliver(self, payload: dict):
|
async def deliver(self, payload, rule=None):
|
||||||
self.deliveries.append(payload)
|
self.deliveries.append(payload)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class MockLLMBackend:
|
class MockLLMBackend:
|
||||||
|
|
@ -93,7 +94,7 @@ def make_scheduler(
|
||||||
|
|
||||||
channels = {}
|
channels = {}
|
||||||
|
|
||||||
def channel_factory(rule):
|
def channel_factory(rule, connector=None):
|
||||||
ch = MockChannel()
|
ch = MockChannel()
|
||||||
channels[rule.name] = ch
|
channels[rule.name] = ch
|
||||||
return ch
|
return ch
|
||||||
|
|
@ -223,8 +224,8 @@ class TestFireBehavior:
|
||||||
ch = channels["digest-mesh"]
|
ch = channels["digest-mesh"]
|
||||||
assert len(ch.deliveries) == 1
|
assert len(ch.deliveries) == 1
|
||||||
payload = ch.deliveries[0]
|
payload = ch.deliveries[0]
|
||||||
assert payload["category"] == "digest"
|
assert payload.category == "digest"
|
||||||
assert payload["severity"] == "routine"
|
assert payload.severity == "routine"
|
||||||
|
|
||||||
def test_fire_skips_disabled_rules(self):
|
def test_fire_skips_disabled_rules(self):
|
||||||
"""Disabled rules are not delivered to."""
|
"""Disabled rules are not delivered to."""
|
||||||
|
|
@ -293,8 +294,8 @@ class TestFireBehavior:
|
||||||
ch = channels["mesh"]
|
ch = channels["mesh"]
|
||||||
assert len(ch.deliveries) >= 1
|
assert len(ch.deliveries) >= 1
|
||||||
for payload in ch.deliveries:
|
for payload in ch.deliveries:
|
||||||
assert "chunk_index" in payload
|
assert payload.chunk_index is not None
|
||||||
assert "chunk_total" in payload
|
assert payload.chunk_total is not None
|
||||||
|
|
||||||
def test_fire_email_delivery_full_text(self):
|
def test_fire_email_delivery_full_text(self):
|
||||||
"""Email delivery type gets single full-text delivery."""
|
"""Email delivery type gets single full-text delivery."""
|
||||||
|
|
@ -320,8 +321,8 @@ class TestFireBehavior:
|
||||||
ch = channels["email"]
|
ch = channels["email"]
|
||||||
assert len(ch.deliveries) == 1
|
assert len(ch.deliveries) == 1
|
||||||
payload = ch.deliveries[0]
|
payload = ch.deliveries[0]
|
||||||
assert "chunk_index" not in payload
|
assert payload.chunk_index is None
|
||||||
assert "--- " in payload["message"]
|
assert "--- " in payload.message
|
||||||
|
|
||||||
def test_fire_updates_last_fire_at(self):
|
def test_fire_updates_last_fire_at(self):
|
||||||
"""_fire() updates last_fire_at timestamp."""
|
"""_fire() updates last_fire_at timestamp."""
|
||||||
|
|
@ -350,7 +351,7 @@ class TestFireBehavior:
|
||||||
|
|
||||||
ch = channels["mesh"]
|
ch = channels["mesh"]
|
||||||
assert len(ch.deliveries) == 1
|
assert len(ch.deliveries) == 1
|
||||||
assert "No alerts" in ch.deliveries[0]["message"]
|
assert "No alerts" in ch.deliveries[0].message
|
||||||
|
|
||||||
|
|
||||||
# ---- Lifecycle Tests ----
|
# ---- Lifecycle Tests ----
|
||||||
|
|
@ -520,11 +521,11 @@ class TestIntegration:
|
||||||
|
|
||||||
call_order = []
|
call_order = []
|
||||||
|
|
||||||
def bad_channel_factory(rule):
|
def bad_channel_factory(rule, connector=None):
|
||||||
call_order.append(rule.name)
|
call_order.append(rule.name)
|
||||||
if rule.name == "bad":
|
if rule.name == "bad":
|
||||||
ch = MagicMock()
|
ch = MagicMock()
|
||||||
ch.deliver.side_effect = RuntimeError("delivery failed")
|
ch.deliver = AsyncMock(side_effect=RuntimeError("delivery failed"))
|
||||||
return ch
|
return ch
|
||||||
return MockChannel()
|
return MockChannel()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ Updated in Phase 2.4: Events now go to BOTH dispatcher and accumulator
|
||||||
compatibility but not used in production wiring.
|
compatibility but not used in production wiring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, AsyncMock, patch
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from meshai.notifications.events import Event, make_event
|
from meshai.notifications.events import Event, make_event
|
||||||
|
|
@ -55,15 +57,10 @@ class TestImmediateDispatch:
|
||||||
notifications=NotificationsConfigStub(rules=[rule])
|
notifications=NotificationsConfigStub(rules=[rule])
|
||||||
)
|
)
|
||||||
mock_channel = Mock()
|
mock_channel = Mock()
|
||||||
|
mock_channel.deliver = AsyncMock(return_value=True)
|
||||||
mock_factory = Mock(return_value=mock_channel)
|
mock_factory = Mock(return_value=mock_channel)
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
dispatcher = Dispatcher(config, mock_factory)
|
dispatcher = Dispatcher(config, mock_factory)
|
||||||
digest = StubDigestQueue()
|
|
||||||
router = SeverityRouter(
|
|
||||||
immediate_handler=dispatcher.dispatch,
|
|
||||||
digest_handler=digest.enqueue,
|
|
||||||
)
|
|
||||||
bus.subscribe(router.handle)
|
|
||||||
event = make_event(
|
event = make_event(
|
||||||
source="test",
|
source="test",
|
||||||
category="test_cat",
|
category="test_cat",
|
||||||
|
|
@ -71,12 +68,13 @@ class TestImmediateDispatch:
|
||||||
title="Test Alert",
|
title="Test Alert",
|
||||||
summary="Test summary message",
|
summary="Test summary message",
|
||||||
)
|
)
|
||||||
bus.emit(event)
|
# Run dispatch in async context since it's now async
|
||||||
|
asyncio.run(dispatcher.dispatch(event))
|
||||||
assert mock_channel.deliver.call_count == 1
|
assert mock_channel.deliver.call_count == 1
|
||||||
alert = mock_channel.deliver.call_args[0][0]
|
alert = mock_channel.deliver.call_args[0][0]
|
||||||
assert alert["category"] == "test_cat"
|
assert alert.category == "test_cat"
|
||||||
assert alert["severity"] == "immediate"
|
assert alert.severity == "immediate"
|
||||||
assert alert["message"]
|
assert alert.message
|
||||||
|
|
||||||
|
|
||||||
class TestTeeRouting:
|
class TestTeeRouting:
|
||||||
|
|
@ -95,40 +93,29 @@ class TestTeeRouting:
|
||||||
notifications=NotificationsConfigStub(rules=[rule])
|
notifications=NotificationsConfigStub(rules=[rule])
|
||||||
)
|
)
|
||||||
mock_channel = Mock()
|
mock_channel = Mock()
|
||||||
|
mock_channel.deliver = AsyncMock(return_value=True)
|
||||||
mock_factory = Mock(return_value=mock_channel)
|
mock_factory = Mock(return_value=mock_channel)
|
||||||
|
|
||||||
# Create dispatcher and track calls
|
# Create dispatcher
|
||||||
dispatcher = Dispatcher(config, mock_factory)
|
dispatcher = Dispatcher(config, mock_factory)
|
||||||
dispatch_calls = []
|
|
||||||
original_dispatch = dispatcher.dispatch
|
|
||||||
def tracking_dispatch(event):
|
|
||||||
dispatch_calls.append(event)
|
|
||||||
original_dispatch(event)
|
|
||||||
dispatcher.dispatch = tracking_dispatch
|
|
||||||
|
|
||||||
# Create accumulator mock
|
# Create accumulator mock
|
||||||
accumulator_calls = []
|
accumulator_calls = []
|
||||||
def mock_enqueue(event):
|
def mock_enqueue(event):
|
||||||
accumulator_calls.append(event)
|
accumulator_calls.append(event)
|
||||||
|
|
||||||
# Tee closure (Phase 2.4 pattern)
|
|
||||||
def tee(event):
|
|
||||||
dispatcher.dispatch(event)
|
|
||||||
mock_enqueue(event)
|
|
||||||
|
|
||||||
bus = EventBus()
|
|
||||||
bus.subscribe(tee)
|
|
||||||
|
|
||||||
event = make_event(
|
event = make_event(
|
||||||
source="test",
|
source="test",
|
||||||
category="test_cat",
|
category="test_cat",
|
||||||
severity="routine",
|
severity="routine",
|
||||||
title="Routine Alert",
|
title="Routine Alert",
|
||||||
)
|
)
|
||||||
bus.emit(event)
|
|
||||||
|
# Run dispatch in async context
|
||||||
|
asyncio.run(dispatcher.dispatch(event))
|
||||||
|
mock_enqueue(event)
|
||||||
|
|
||||||
# Both paths received the event
|
# Both paths received the event
|
||||||
assert len(dispatch_calls) == 1
|
|
||||||
assert len(accumulator_calls) == 1
|
assert len(accumulator_calls) == 1
|
||||||
# Dispatcher found a matching rule and delivered
|
# Dispatcher found a matching rule and delivered
|
||||||
assert mock_channel.deliver.call_count == 1
|
assert mock_channel.deliver.call_count == 1
|
||||||
|
|
@ -146,36 +133,26 @@ class TestTeeRouting:
|
||||||
notifications=NotificationsConfigStub(rules=[rule])
|
notifications=NotificationsConfigStub(rules=[rule])
|
||||||
)
|
)
|
||||||
mock_channel = Mock()
|
mock_channel = Mock()
|
||||||
|
mock_channel.deliver = AsyncMock(return_value=True)
|
||||||
mock_factory = Mock(return_value=mock_channel)
|
mock_factory = Mock(return_value=mock_channel)
|
||||||
|
|
||||||
dispatcher = Dispatcher(config, mock_factory)
|
dispatcher = Dispatcher(config, mock_factory)
|
||||||
dispatch_calls = []
|
|
||||||
original_dispatch = dispatcher.dispatch
|
|
||||||
def tracking_dispatch(event):
|
|
||||||
dispatch_calls.append(event)
|
|
||||||
original_dispatch(event)
|
|
||||||
dispatcher.dispatch = tracking_dispatch
|
|
||||||
|
|
||||||
accumulator_calls = []
|
accumulator_calls = []
|
||||||
def mock_enqueue(event):
|
def mock_enqueue(event):
|
||||||
accumulator_calls.append(event)
|
accumulator_calls.append(event)
|
||||||
|
|
||||||
def tee(event):
|
|
||||||
dispatcher.dispatch(event)
|
|
||||||
mock_enqueue(event)
|
|
||||||
|
|
||||||
bus = EventBus()
|
|
||||||
bus.subscribe(tee)
|
|
||||||
|
|
||||||
event = make_event(
|
event = make_event(
|
||||||
source="test",
|
source="test",
|
||||||
category="test_cat",
|
category="test_cat",
|
||||||
severity="priority",
|
severity="priority",
|
||||||
title="Priority Alert",
|
title="Priority Alert",
|
||||||
)
|
)
|
||||||
bus.emit(event)
|
|
||||||
|
|
||||||
assert len(dispatch_calls) == 1
|
# Run dispatch in async context
|
||||||
|
asyncio.run(dispatcher.dispatch(event))
|
||||||
|
mock_enqueue(event)
|
||||||
|
|
||||||
assert len(accumulator_calls) == 1
|
assert len(accumulator_calls) == 1
|
||||||
assert mock_channel.deliver.call_count == 1
|
assert mock_channel.deliver.call_count == 1
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue