feat(notifications): alert routing with channels, rules, and delivery

- Notification pipeline: categories -> rules -> channels
- Channels: mesh broadcast, mesh DM, email (SMTP), webhook (generic)
- Per-rule severity threshold and category filtering
- Quiet hours with emergency override
- LLM summarization for mesh delivery over 200 chars only
- !subscribe shows available categories, easy mesh subscription
- Dashboard notification rules API endpoints
- Extensible channel system for future transports (Winlink, JS8Call)
- config.yaml notification section with examples

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 03:51:37 +00:00
commit 3bf5e3dfbc
12 changed files with 1215 additions and 105 deletions

View file

@ -44,6 +44,7 @@ class MeshAI:
self.mesh_reporter = None
self.subscription_manager = None
self.alert_engine = None
self.notification_router = None
self.env_store = None # Environmental feeds store
self._last_sub_check: float = 0.0
self.router: Optional[MessageRouter] = None
@ -337,6 +338,18 @@ class MeshAI:
)
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
# Notification router
if self.config.notifications.enabled:
from .notifications.router import NotificationRouter
self.notification_router = NotificationRouter(
config=self.config.notifications,
connector=self.connector,
llm_backend=self.llm,
timezone=self.config.timezone,
)
logger.info("Notification router initialized")
# Environmental feeds
env_cfg = self.config.environmental
if env_cfg.enabled:
@ -394,6 +407,7 @@ class MeshAI:
health_engine=self.health_engine,
subscription_manager=self.subscription_manager,
env_store=self.env_store,
notification_router=self.notification_router,
)
# Message router
@ -406,6 +420,7 @@ class MeshAI:
health_engine=self.health_engine,
mesh_reporter=self.mesh_reporter,
env_store=self.env_store,
notification_router=self.notification_router,
)
# Responder
@ -539,40 +554,48 @@ class MeshAI:
if pid_file.exists():
pid_file.unlink()
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
"""Dispatch alerts to subscribers and alert channel."""
mi = self.config.mesh_intelligence
alert_channel = getattr(mi, 'alert_channel', -1)
for alert in alerts:
message = alert["message"]
logger.info(f"ALERT: {message}")
# Send to alert channel if configured
if alert_channel >= 0 and self.connector:
try:
self.connector.send_message(
text=message,
destination=None, # Broadcast
channel=alert_channel,
)
logger.info(f"Alert sent to channel {alert_channel}")
except Exception as e:
logger.error(f"Failed to send channel alert: {e}")
# Send DMs to matching subscribers
if self.alert_engine and self.subscription_manager:
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
for sub in subscribers:
user_id = sub["user_id"]
try:
await self._send_sub_dm(user_id, message)
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
except Exception as e:
logger.error(f"Failed to send alert DM to {user_id}: {e}")
self.alert_engine.clear_pending()
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
"""Dispatch alerts to subscribers and alert channel."""
mi = self.config.mesh_intelligence
alert_channel = getattr(mi, 'alert_channel', -1)
for alert in alerts:
message = alert["message"]
logger.info(f"ALERT: {message}")
# Route through notification router if enabled
if self.notification_router:
try:
await self.notification_router.process_alert(alert)
except Exception as e:
logger.error(f"Notification router error: {e}")
# Fallback: Send to alert channel if no notification router
elif alert_channel >= 0 and self.connector:
try:
self.connector.send_message(
text=message,
destination=None,
channel=alert_channel,
)
logger.info(f"Alert sent to channel {alert_channel}")
except Exception as e:
logger.error(f"Failed to send channel alert: {e}")
# Fallback: Send DMs to matching subscribers
if self.alert_engine and self.subscription_manager:
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
for sub in subscribers:
user_id = sub["user_id"]
try:
await self._send_sub_dm(user_id, message)
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
except Exception as e:
logger.error(f"Failed to send alert DM to {user_id}: {e}")
if self.alert_engine:
self.alert_engine.clear_pending()
async def _check_scheduled_subs(self) -> None:
"""Check for and deliver due scheduled reports."""
from datetime import datetime