Files
Unraid-AppOps-Gateway-Public/backend/app/services/notification_service.py
T

1051 lines
44 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta
import json
import re
from threading import RLock
from typing import Any
from uuid import uuid4
from sqlalchemy import case, func, or_, update
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import App, AuditEvent, Connector, DiscoveryRun, NotificationChannel, NotificationEvent, NotificationRule
from . import media_pipeline_service, safety_center_service
from .audit_logger import log_event
from .notification_channels import external, home_assistant
from .secret_store import set_secret
SEVERITY_RANK = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4}
SECRET_KEYS = {'token', 'password', 'secret', 'api_key', 'apikey', 'authorization', 'bearer'}
_EVALUATION_LOCK = RLock()
def _now() -> datetime:
return utc_now()
def _redact(value: Any) -> Any:
if isinstance(value, str):
redacted = redact_message(value)
if re.search(r'(api[_-]?key|token|password|bearer)', redacted, flags=re.IGNORECASE):
return '[REDACTED]'
return redacted
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
lowered = str(key).lower()
if any(secret_key in lowered for secret_key in SECRET_KEYS):
continue
result[str(key)] = _redact(item)
return result
return value
def _rule_to_dict(rule: NotificationRule) -> dict[str, Any]:
return {
'id': rule.id,
'name': rule.name,
'enabled': rule.enabled,
'source': rule.source,
'eventType': rule.event_type,
'severityMin': rule.severity_min,
'categories': rule.categories_json,
'targets': rule.targets_json,
'channels': rule.channels_json,
'cooldownSeconds': rule.cooldown_seconds,
'quietHours': rule.quiet_hours_json,
'conditions': rule.conditions_json,
'dedupeKeyTemplate': rule.dedupe_key_template,
'createdAt': rule.created_at.isoformat(),
'updatedAt': rule.updated_at.isoformat(),
}
def _event_to_dict(event: NotificationEvent) -> dict[str, Any]:
payload = _redact(event.payload_json)
channel_results = payload.get('channelResults', {}) if isinstance(payload, dict) else {}
return {
'id': event.id,
'ruleId': event.rule_id,
'source': event.source,
'eventType': event.event_type,
'severity': event.severity,
'title': event.title,
'message': event.message,
'category': event.category,
'target': event.target,
'dedupeKey': event.dedupe_key,
'channels': event.channels_json,
'status': event.status,
'reason': event.reason,
'payload': payload,
'channelResults': channel_results,
'read': event.read,
'createdAt': event.created_at.isoformat(),
'sentAt': event.sent_at.isoformat() if event.sent_at else None,
'suppressedUntil': event.suppressed_until.isoformat() if event.suppressed_until else None,
}
def _channel_to_dict(channel: NotificationChannel, session: Session | None = None) -> dict[str, Any]:
safe_config = {
key: value
for key, value in channel.config_json.items()
if not any(secret_key in str(key).lower() for secret_key in SECRET_KEYS)
}
status = 'connected' if channel.enabled and channel.type == 'dashboard' else ('configured' if channel.enabled else 'disabled')
has_secret = bool(channel.secret_refs_json)
missing: list[str] = []
safe_config_alias = _redact(safe_config)
if channel.type == 'home_assistant' and session is not None:
ha_status = home_assistant.config_status(session, channel)
status = ha_status['status']
has_secret = ha_status['hasSecret']
missing = ha_status['missing']
safe_config_alias = _redact(ha_status['safeConfig'])
elif channel.type in external.SECRET_REFS and session is not None:
external_status = external.config_status(session, channel)
status = external_status['status']
has_secret = external_status['hasSecret']
missing = external_status['missing']
safe_config_alias = _redact(external_status['safeConfig'])
return {
'id': channel.id,
'type': channel.type,
'name': channel.name,
'enabled': channel.enabled,
'config': safe_config_alias,
'safeConfig': safe_config_alias,
'secretRefs': channel.secret_refs_json,
'status': status,
'hasSecret': has_secret,
'missing': missing,
'lastError': safe_config.get('lastError'),
'lastSentAt': safe_config.get('lastSentAt'),
'lastTestAt': channel.last_test_at.isoformat() if channel.last_test_at else None,
'createdAt': channel.created_at.isoformat(),
'updatedAt': channel.updated_at.isoformat(),
}
def list_rules(session: Session) -> list[dict[str, Any]]:
return [_rule_to_dict(rule) for rule in session.exec(select(NotificationRule).order_by(NotificationRule.name)).all()]
def create_rule(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
rule = NotificationRule(
id=str(payload.get('id') or f"notify-{uuid4().hex[:12]}"),
name=str(payload.get('name') or 'Notification Rule'),
enabled=bool(payload.get('enabled', True)),
source=str(payload.get('source') or 'safety'),
event_type=str(payload.get('eventType') or payload.get('event_type') or 'safety.finding'),
severity_min=str(payload.get('severityMin') or payload.get('severity_min') or 'medium'),
categories_json=list(payload.get('categories') or payload.get('categories_json') or []),
targets_json=list(payload.get('targets') or payload.get('targets_json') or []),
channels_json=list(payload.get('channels') or payload.get('channels_json') or ['dashboard']),
cooldown_seconds=int(payload.get('cooldownSeconds') or payload.get('cooldown_seconds') or 3600),
quiet_hours_json=dict(payload.get('quietHours') or payload.get('quiet_hours_json') or {}),
conditions_json=dict(payload.get('conditions') or payload.get('conditions_json') or {}),
dedupe_key_template=str(payload.get('dedupeKeyTemplate') or payload.get('dedupe_key_template') or '{source}:{event_type}:{target}:{category}'),
)
session.add(rule)
session.commit()
session.refresh(rule)
safety_center_service.invalidate_cache()
return _rule_to_dict(rule)
def update_rule(session: Session, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
rule = session.get(NotificationRule, rule_id)
if not rule:
return {'success': False, 'status': 'not_found', 'id': rule_id}
mapping = {
'name': 'name',
'enabled': 'enabled',
'source': 'source',
'eventType': 'event_type',
'severityMin': 'severity_min',
'cooldownSeconds': 'cooldown_seconds',
'dedupeKeyTemplate': 'dedupe_key_template',
}
for input_key, attr in mapping.items():
if input_key in payload:
setattr(rule, attr, payload[input_key])
if 'categories' in payload:
rule.categories_json = list(payload['categories'])
if 'targets' in payload:
rule.targets_json = list(payload['targets'])
if 'channels' in payload:
rule.channels_json = list(payload['channels'])
if 'quietHours' in payload:
rule.quiet_hours_json = dict(payload['quietHours'])
if 'conditions' in payload:
rule.conditions_json = dict(payload['conditions'])
rule.updated_at = _now()
session.add(rule)
session.commit()
session.refresh(rule)
safety_center_service.invalidate_cache()
return _rule_to_dict(rule)
def delete_rule(session: Session, rule_id: str) -> dict[str, Any]:
rule = session.get(NotificationRule, rule_id)
if not rule:
return {'success': False, 'status': 'not_found', 'id': rule_id}
session.delete(rule)
session.commit()
safety_center_service.invalidate_cache()
return {'success': True, 'status': 'deleted', 'id': rule_id}
def list_channels(session: Session) -> list[dict[str, Any]]:
return [_channel_to_dict(channel, session) for channel in session.exec(select(NotificationChannel).order_by(NotificationChannel.name)).all()]
def create_channel(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
channel_type = str(payload.get('type') or 'dashboard')
config = dict(payload.get('config') or payload.get('config_json') or {})
token = str(payload.get('token') or config.pop('token', '') or '').strip()
if channel_type == 'home_assistant':
config = home_assistant.safe_config(config)
elif channel_type in external.SECRET_REFS:
config = external.safe_config(channel_type, config)
channel = NotificationChannel(
id=str(payload.get('id') or f"channel-{uuid4().hex[:12]}"),
type=channel_type,
name=str(payload.get('name') or payload.get('type') or 'Dashboard'),
enabled=bool(payload.get('enabled', True)),
config_json=_redact(config),
secret_refs_json=list(payload.get('secretRefs') or payload.get('secret_refs_json') or ([home_assistant.SECRET_REF] if channel_type == 'home_assistant' else ([external.SECRET_REFS[channel_type]] if channel_type in external.SECRET_REFS else []))),
)
if channel_type == 'home_assistant' and token:
set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False)
elif channel_type in external.SECRET_REFS and token:
set_secret(session, external.SECRET_REFS[channel_type], token, used_by=[f'notification_channel:{channel_type}'], commit=False)
session.add(channel)
session.commit()
session.refresh(channel)
safety_center_service.invalidate_cache()
return _channel_to_dict(channel, session)
def update_channel(session: Session, channel_id: str, payload: dict[str, Any]) -> dict[str, Any]:
channel = session.get(NotificationChannel, channel_id)
if not channel:
return {'success': False, 'status': 'not_found', 'id': channel_id}
if 'name' in payload:
channel.name = str(payload['name'])
if 'enabled' in payload:
channel.enabled = bool(payload['enabled'])
if 'type' in payload:
channel.type = str(payload['type'])
if 'config' in payload:
config = dict(payload['config'])
token = str(payload.get('token') or config.pop('token', '') or '').strip()
if channel.type == 'home_assistant':
channel.config_json = _redact(home_assistant.safe_config(config))
if token:
set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False)
channel.secret_refs_json = [home_assistant.SECRET_REF]
elif channel.type in external.SECRET_REFS:
channel.config_json = _redact(external.safe_config(channel.type, config))
if token:
secret_ref = external.SECRET_REFS[channel.type]
set_secret(session, secret_ref, token, used_by=[f'notification_channel:{channel.type}'], commit=False)
channel.secret_refs_json = [secret_ref]
else:
channel.config_json = _redact(config)
elif channel.type == 'home_assistant' and payload.get('token'):
set_secret(session, home_assistant.SECRET_REF, str(payload['token']), used_by=['notification_channel:home_assistant'], commit=False)
channel.secret_refs_json = [home_assistant.SECRET_REF]
elif channel.type in external.SECRET_REFS and payload.get('token'):
secret_ref = external.SECRET_REFS[channel.type]
set_secret(session, secret_ref, str(payload['token']), used_by=[f'notification_channel:{channel.type}'], commit=False)
channel.secret_refs_json = [secret_ref]
if 'secretRefs' in payload:
channel.secret_refs_json = list(payload['secretRefs'])
channel.updated_at = _now()
session.add(channel)
session.commit()
session.refresh(channel)
safety_center_service.invalidate_cache()
return _channel_to_dict(channel, session)
def test_channel(session: Session, channel_id: str) -> dict[str, Any]:
channel = session.get(NotificationChannel, channel_id)
if not channel:
return {'success': False, 'status': 'not_found', 'id': channel_id}
channel.last_test_at = _now()
channel.updated_at = channel.last_test_at
session.add(channel)
session.commit()
session.refresh(channel)
if channel.type == 'home_assistant':
result = home_assistant.send_test_notification(session, channel)
channel.config_json = {
**(channel.config_json or {}),
'lastError': result.get('reason') if not result.get('success') else None,
'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
}
session.add(channel)
session.commit()
session.refresh(channel)
log_event(
session,
source='notification',
identity='system',
action_id='notification.channel.test',
target=channel.id,
target_type='notification_channel',
risk='low',
policy_decision='allowed',
reason=str(result.get('reason') or result.get('status') or 'test_notification'),
result='success' if result.get('success') else 'failed',
redacted_metadata={'status': result.get('status'), 'missing': result.get('missing')},
)
result['channel'] = _channel_to_dict(channel, session)
return result
if channel.type in external.SECRET_REFS:
result = external.send_notification(session, channel, {"title": "AppOps test", "message": "Dit is een gecontroleerde testmelding vanuit AppOps."})
channel.config_json = {
**(channel.config_json or {}),
'lastError': result.get('reason') if not result.get('success') else None,
'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
}
session.add(channel)
session.commit()
result['channel'] = _channel_to_dict(channel, session)
return result
safe = _channel_to_dict(channel, session)
if not channel.enabled:
return {'success': True, 'status': 'disabled', 'channel': safe}
if channel.type == 'dashboard':
return {'success': True, 'status': 'sent', 'channel': safe}
if channel.type in {'home_assistant', 'webhook'}:
configured = bool(channel.config_json.get('url') or channel.secret_refs_json)
return {'success': True, 'status': 'prepared' if configured else 'missing_config', 'channel': safe}
return {'success': True, 'status': 'prepared', 'channel': safe}
def test_channel_connection(session: Session, channel_id: str) -> dict[str, Any]:
channel = session.get(NotificationChannel, channel_id)
if not channel:
return {'success': False, 'status': 'not_found', 'id': channel_id}
channel.last_test_at = _now()
channel.updated_at = channel.last_test_at
session.add(channel)
session.commit()
session.refresh(channel)
if channel.type == 'home_assistant':
result = home_assistant.test_connection(session, channel)
channel.config_json = {
**(channel.config_json or {}),
'lastError': result.get('status') if not result.get('success') else None,
}
session.add(channel)
session.commit()
session.refresh(channel)
result['channel'] = _channel_to_dict(channel, session)
return result
return test_channel(session, channel_id)
def list_events(
session: Session,
include_dismissed: bool = True,
limit: int = 250,
offset: int = 0,
) -> list[dict[str, Any]]:
bounded_limit = max(1, min(limit, 500))
bounded_offset = max(0, offset)
statement = select(NotificationEvent)
if not include_dismissed:
statement = statement.where(NotificationEvent.status != 'dismissed')
inbox_priority = case(
(
(NotificationEvent.read == False) # noqa: E712
& NotificationEvent.status.not_in(['dismissed', 'suppressed']),
0,
),
(NotificationEvent.status.not_in(['dismissed', 'suppressed']), 1),
(NotificationEvent.status == 'suppressed', 2),
else_=3,
)
statement = (
statement
.order_by(inbox_priority.asc(), NotificationEvent.created_at.desc(), NotificationEvent.id.desc())
.offset(bounded_offset)
.limit(bounded_limit)
)
events = session.exec(statement).all()
return [_event_to_dict(event) for event in events]
def unread_events(session: Session, limit: int = 250, offset: int = 0) -> list[dict[str, Any]]:
bounded_limit = max(1, min(limit, 500))
bounded_offset = max(0, offset)
events = session.exec(
select(NotificationEvent)
.where(NotificationEvent.read == False) # noqa: E712
.where(NotificationEvent.status != 'dismissed')
.where(NotificationEvent.status != 'suppressed')
.order_by(NotificationEvent.created_at.desc(), NotificationEvent.id.desc())
.offset(bounded_offset)
.limit(bounded_limit)
).all()
return [_event_to_dict(event) for event in events]
def mark_read(session: Session, event_id: str) -> dict[str, Any]:
event = session.get(NotificationEvent, event_id)
if not event:
return {'success': False, 'status': 'not_found', 'id': event_id}
event.read = True
if event.status == 'queued':
event.status = 'sent'
session.add(event)
session.commit()
session.refresh(event)
result = _event_to_dict(event)
result['success'] = True
return result
def dismiss_event(session: Session, event_id: str) -> dict[str, Any]:
event = session.get(NotificationEvent, event_id)
if not event:
return {'success': False, 'status': 'not_found', 'id': event_id}
event.status = 'dismissed'
event.read = True
session.add(event)
session.commit()
session.refresh(event)
result = _event_to_dict(event)
result['success'] = True
return result
def mark_all_read(session: Session) -> dict[str, Any]:
events = session.exec(select(NotificationEvent).where(NotificationEvent.read == False)).all() # noqa: E712
for event in events:
event.read = True
if event.status == 'queued':
event.status = 'sent'
session.add(event)
session.commit()
return {'success': True, 'updated': len(events)}
def _severity_matches(rule: NotificationRule, event: dict[str, Any]) -> bool:
return SEVERITY_RANK.get(str(event.get('severity', 'info')), 0) >= SEVERITY_RANK.get(rule.severity_min, 0)
def _condition_matches(rule: NotificationRule, event: dict[str, Any]) -> bool:
if rule.source != event.get('source') or rule.event_type != event.get('event_type'):
return False
if not _severity_matches(rule, event):
return False
category = str(event.get('category') or '')
target = str(event.get('target') or '')
if rule.categories_json and category not in rule.categories_json:
return False
if rule.targets_json and target not in rule.targets_json:
return False
title_contains = rule.conditions_json.get('titleContains') if isinstance(rule.conditions_json, dict) else None
if title_contains:
title = str(event.get('title') or '').lower()
if not any(str(item).lower() in title for item in title_contains):
return False
return True
def _rule_specificity(rule: NotificationRule) -> int:
conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {}
constrained_fields = sum((
bool(rule.categories_json),
bool(rule.targets_json),
bool(conditions),
))
return constrained_fields * 100 + SEVERITY_RANK.get(rule.severity_min, 0)
def _dedupe_key(rule: NotificationRule, event: dict[str, Any]) -> str:
fallback = str(event.get('dedupe_key') or f"{event.get('source')}:{event.get('event_type')}:{event.get('target')}:{event.get('category')}")
try:
return rule.dedupe_key_template.format(
source=event.get('source') or '',
event_type=event.get('event_type') or '',
severity=event.get('severity') or '',
category=event.get('category') or '',
target=event.get('target') or '',
id=event.get('id') or event.get('findingId') or '',
) or fallback
except Exception:
return fallback
def _cooldown_event(session: Session, rule: NotificationRule, dedupe_key: str) -> NotificationEvent | None:
cutoff = _now() - timedelta(seconds=rule.cooldown_seconds)
statement = (
select(NotificationEvent)
.where(NotificationEvent.rule_id == rule.id)
.where(NotificationEvent.dedupe_key == dedupe_key)
.where(or_(
NotificationEvent.reason.is_(None),
NotificationEvent.reason.not_in(['cooldown', 'quiet_hours']),
))
.where(NotificationEvent.created_at >= cutoff)
.order_by(NotificationEvent.created_at.desc())
)
return session.exec(statement).first()
def _quiet_hours_until(
rule: NotificationRule,
event: dict[str, Any],
now: datetime | None = None,
) -> datetime | None:
quiet = rule.quiet_hours_json or {}
if not quiet.get('enabled'):
return None
if event.get('severity') == 'critical' and quiet.get('allowCriticalOverride', True):
return None
try:
start = int(quiet.get('startHour', 22)) % 24
end = int(quiet.get('endHour', 7)) % 24
except (TypeError, ValueError):
start, end = 22, 7
current = now or _now()
hour = current.hour
suppressed = hour >= start or hour < end if start > end else start <= hour < end
if not suppressed:
return None
until = current.replace(hour=end, minute=0, second=0, microsecond=0)
if until <= current:
until += timedelta(days=1)
return until
def _quiet_hours_suppressed(rule: NotificationRule, event: dict[str, Any]) -> bool:
return _quiet_hours_until(rule, event) is not None
def _persist_event(session: Session, rule: NotificationRule, event: dict[str, Any], status: str, reason: str | None = None, suppressed_until: datetime | None = None) -> NotificationEvent:
row = NotificationEvent(
id=f"notif-{uuid4().hex[:12]}",
rule_id=rule.id,
source=str(event.get('source') or rule.source),
event_type=str(event.get('event_type') or rule.event_type),
severity=str(event.get('severity') or rule.severity_min),
title=_redact(str(event.get('title') or 'Notification')),
message=_redact(str(event.get('message') or event.get('title') or 'Notification')),
category=str(event.get('category') or '') or None,
target=str(event.get('target') or '') or None,
dedupe_key=_dedupe_key(rule, event),
channels_json=list(rule.channels_json or ['dashboard']),
status=status,
reason=reason,
payload_json=_redact(dict(event.get('payload') or {})),
read=status == 'suppressed',
sent_at=_now() if status == 'sent' else None,
suppressed_until=suppressed_until,
)
session.add(row)
session.commit()
session.refresh(row)
return row
def _suppressed_channel_results(channels: list[str], reason: str) -> dict[str, dict[str, Any]]:
return {channel_id: {'status': 'suppressed', 'reason': reason} for channel_id in channels}
def _record_suppression(
session: Session,
rule: NotificationRule,
event: dict[str, Any],
reason: str,
suppressed_until: datetime,
) -> NotificationEvent:
"""Atomically aggregate one read-only evidence row per routine suppression identity."""
dedupe_key = _dedupe_key(rule, event)
now = _now()
source_payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
channels = list(rule.channels_json or ['dashboard'])
payload_patch = _redact({
**source_payload,
'channelResults': _suppressed_channel_results(channels, reason),
})
values = {
'severity': str(event.get('severity') or rule.severity_min),
'title': _redact(str(event.get('title') or 'Notification')),
'message': _redact(str(event.get('message') or event.get('title') or 'Notification')),
'category': str(event.get('category') or '') or None,
'target': str(event.get('target') or '') or None,
'channels_json': channels,
'read': True,
'suppressed_until': suppressed_until,
}
def update_existing() -> bool:
payload = func.json_set(
func.json_patch(NotificationEvent.payload_json, json.dumps(payload_patch)),
'$.suppressionCount',
func.coalesce(func.json_extract(NotificationEvent.payload_json, '$.suppressionCount'), 0) + 1,
'$.lastSuppressedAt',
now.isoformat(),
)
result = session.execute(
update(NotificationEvent)
.where(NotificationEvent.rule_id == rule.id)
.where(NotificationEvent.dedupe_key == dedupe_key)
.where(NotificationEvent.status == 'suppressed')
.where(NotificationEvent.reason == reason)
.values(**values, payload_json=payload)
)
return bool(result.rowcount)
if not update_existing():
row = NotificationEvent(
id=f'notif-{uuid4()}',
rule_id=rule.id,
source=str(event.get('source') or rule.source),
event_type=str(event.get('event_type') or rule.event_type),
dedupe_key=dedupe_key,
status='suppressed',
reason=reason,
created_at=now,
payload_json={
**payload_patch,
'suppressionCount': 1,
'firstSuppressedAt': now.isoformat(),
'lastSuppressedAt': now.isoformat(),
},
**values,
)
session.add(row)
try:
session.commit()
except IntegrityError:
session.rollback()
if not update_existing():
raise
session.commit()
row = session.exec(
select(NotificationEvent)
.where(NotificationEvent.rule_id == rule.id)
.where(NotificationEvent.dedupe_key == dedupe_key)
.where(NotificationEvent.status == 'suppressed')
.where(NotificationEvent.reason == reason)
).one()
return row
def _event_payload(event: NotificationEvent) -> dict[str, Any]:
return {
'id': event.id,
'ruleId': event.rule_id,
'source': event.source,
'event_type': event.event_type,
'severity': event.severity,
'title': event.title,
'message': event.message,
'category': event.category,
'target': event.target,
'dedupe_key': event.dedupe_key,
}
def _deliver_event(session: Session, row: NotificationEvent) -> NotificationEvent:
results: dict[str, dict[str, Any]] = {}
for channel_id in row.channels_json or ['dashboard']:
if channel_id == 'dashboard':
results[channel_id] = {'status': 'sent'}
continue
channel = session.get(NotificationChannel, channel_id)
if not channel:
results[channel_id] = {'status': 'failed', 'reason': 'channel_not_found'}
continue
if channel.type == 'home_assistant':
result = home_assistant.send_notification(session, channel, _event_payload(row))
results[channel_id] = _redact(result)
if result.get('status') == 'sent':
channel.config_json = {**(channel.config_json or {}), 'lastError': None, 'lastSentAt': _now().isoformat()}
elif result.get('status') == 'failed':
channel.config_json = {**(channel.config_json or {}), 'lastError': result.get('reason') or 'error'}
channel.updated_at = _now()
session.add(channel)
continue
if channel.type in external.SECRET_REFS:
result = external.send_notification(session, channel, _event_payload(row))
results[channel_id] = _redact(result)
channel.config_json = {
**(channel.config_json or {}),
'lastError': result.get('reason') if not result.get('success') else None,
'lastSentAt': _now().isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
}
channel.updated_at = _now()
session.add(channel)
continue
results[channel_id] = {'status': 'suppressed', 'reason': 'unsupported_channel'}
previous_retry_count = int((row.payload_json or {}).get('deliveryRetryCount') or 0)
retry_count = previous_retry_count + 1
failed_channels = [channel_id for channel_id, result in results.items() if result.get('status') in {'failed', 'missing_config', 'auth_failed', 'unreachable'}]
delivered_channels = [channel_id for channel_id, result in results.items() if result.get('status') == 'sent']
dead_letter = bool(failed_channels and retry_count >= 3)
row.status = 'sent' if delivered_channels else ('failed' if failed_channels else 'suppressed')
row.read = row.read or row.status == 'suppressed'
row.reason = 'delivery_dead_letter' if dead_letter else 'partial_delivery_failed' if failed_channels and delivered_channels else 'delivery_failed' if failed_channels else None
row.sent_at = _now() if delivered_channels else None
row.payload_json = _redact({
**(row.payload_json or {}),
'channelResults': results,
'deliveryRetryCount': retry_count,
'deliveryPending': bool(failed_channels and not dead_letter),
'deadLetter': dead_letter,
})
session.add(row)
session.commit()
session.refresh(row)
return row
def retry_failed_deliveries(session: Session, limit: int = 20) -> dict[str, Any]:
bounded_limit = max(1, min(limit, 100))
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.status != 'dismissed')
.where(NotificationEvent.payload_json['deliveryPending'].as_boolean() == True) # noqa: E712
.order_by(NotificationEvent.created_at.asc())
.limit(bounded_limit)
).all()
retried = 0
recovered = 0
dead_lettered = 0
for row in rows:
payload = row.payload_json or {}
if bool(payload.get('deadLetter')) or int(payload.get('deliveryRetryCount') or 0) >= 3:
dead_lettered += 1
continue
retried += 1
_deliver_event(session, row)
if not bool((row.payload_json or {}).get('deliveryPending')) and not bool((row.payload_json or {}).get('deadLetter')):
recovered += 1
elif bool((row.payload_json or {}).get('deadLetter')):
dead_lettered += 1
return {'retried': retried, 'recovered': recovered, 'deadLettered': dead_lettered}
def evaluate_rules(session: Session, events: list[dict[str, Any]]) -> dict[str, Any]:
rules = session.exec(select(NotificationRule).where(NotificationRule.enabled == True)).all() # noqa: E712
created: list[dict[str, Any]] = []
suppressed: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for event in events:
if event.get('event_type') == 'safety.score':
continue
matching_rules = [rule for rule in rules if _condition_matches(rule, event)]
if not matching_rules:
continue
highest_specificity = max(_rule_specificity(rule) for rule in matching_rules)
for rule in matching_rules:
if _rule_specificity(rule) != highest_specificity:
continue
dedupe = _dedupe_key(rule, event)
previous = _cooldown_event(session, rule, dedupe)
if previous:
until = previous.created_at + timedelta(seconds=rule.cooldown_seconds)
row = _record_suppression(session, rule, event, 'cooldown', until)
suppressed.append(_event_to_dict(row))
continue
quiet_hours_until = _quiet_hours_until(rule, event)
if quiet_hours_until:
row = _record_suppression(session, rule, event, 'quiet_hours', quiet_hours_until)
suppressed.append(_event_to_dict(row))
continue
try:
row = _persist_event(session, rule, event, 'sent')
row = _deliver_event(session, row)
created.append(_event_to_dict(row))
except Exception as exc:
failed.append({'ruleId': rule.id, 'reason': _redact(str(exc))})
return {'created': len(created), 'suppressed': len(suppressed), 'failed': len(failed), 'events': created, 'suppressedEvents': suppressed, 'failedEvents': failed}
def _auto_resolve_stale_connector_events(session: Session, current_events: list[dict[str, Any]]) -> int:
current_targets = {
str(event.get('target'))
for event in current_events
if event.get('source') == 'connector' and event.get('event_type') in {'connector.missing_config', 'connector.failed'}
}
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.source == 'connector')
.where(NotificationEvent.event_type.in_(['connector.missing_config', 'connector.failed']))
.where(NotificationEvent.status != 'dismissed')
).all()
resolved = 0
for row in rows:
if str(row.target) in current_targets:
continue
row.status = 'dismissed'
row.reason = 'auto_resolved'
row.read = True
session.add(row)
resolved += 1
if resolved:
session.commit()
return resolved
def _auto_resolve_stale_safety_events(session: Session, current_events: list[dict[str, Any]]) -> int:
def event_identity(event: dict[str, Any]) -> str:
payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
return str(payload.get('findingId') or event.get('dedupe_key') or '')
current_ids = {
event_identity(event)
for event in current_events
if event.get('source') == 'safety' and event.get('event_type') == 'safety.finding'
}
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.source == 'safety')
.where(NotificationEvent.event_type == 'safety.finding')
.where(NotificationEvent.status != 'dismissed')
).all()
resolved = 0
for row in rows:
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
row_identity = str(payload.get('findingId') or row.dedupe_key)
if row_identity in current_ids:
continue
row.status = 'dismissed'
row.reason = 'auto_resolved'
row.read = True
session.add(row)
resolved += 1
if resolved:
session.commit()
return resolved
def _auto_resolve_stale_media_events(session: Session, current_events: list[dict[str, Any]]) -> int:
current_keys = {
str(event.get('dedupe_key') or '')
for event in current_events
if event.get('source') == 'media_pipeline' and event.get('event_type') == 'media.issue'
}
rows = session.exec(
select(NotificationEvent)
.where(NotificationEvent.source == 'media_pipeline')
.where(NotificationEvent.event_type == 'media.issue')
.where(NotificationEvent.status != 'dismissed')
).all()
resolved = 0
for row in rows:
if str(row.dedupe_key or '') in current_keys:
continue
row.status = 'dismissed'
row.reason = 'auto_resolved'
row.read = True
session.add(row)
resolved += 1
if resolved:
session.commit()
return resolved
def _reconcile_current_state(session: Session) -> int:
current_events = [*_connector_events(session), *_safety_events(session), *_media_events(session)]
return (
_auto_resolve_stale_connector_events(session, current_events)
+ _auto_resolve_stale_safety_events(session, current_events)
+ _auto_resolve_stale_media_events(session, current_events)
)
def _safety_events(session: Session) -> list[dict[str, Any]]:
dashboard = safety_center_service.get_dashboard(session)
events: list[dict[str, Any]] = []
for finding in dashboard.get('findings', []):
if finding.get('status') != 'open':
continue
events.append({
'source': 'safety',
'event_type': 'safety.finding',
'severity': finding.get('severity', 'medium'),
'title': finding.get('title', 'Safety finding'),
'message': finding.get('description') or finding.get('title') or 'Safety finding',
'category': finding.get('category') or 'safety',
'target': ','.join(finding.get('affectedResources') or [])[:160] or 'safety-center',
'dedupe_key': f"safety.finding:{finding.get('id')}",
'payload': {'findingId': finding.get('id'), 'source': finding.get('source'), 'category': finding.get('category')},
})
return events
def _connector_events(session: Session) -> list[dict[str, Any]]:
result = []
for connector in session.exec(select(Connector)).all():
status = str(connector.status or 'unknown')
if status in {'missing_config', 'disconnected', 'error'}:
event_type = 'connector.failed' if status == 'error' else 'connector.missing_config'
result.append({
'source': 'connector',
'event_type': event_type,
'severity': 'medium' if event_type == 'connector.missing_config' else 'high',
'title': f'{connector.name} connector vraagt aandacht',
'message': f'{connector.name} connector status is {status}.',
'category': 'connector_config',
'target': connector.id,
'dedupe_key': f'{event_type}:{connector.id}',
'payload': {'connectorId': connector.id, 'status': status, 'secretRef': connector.secret_ref},
})
return result
def _media_events(session: Session) -> list[dict[str, Any]]:
summary = media_pipeline_service.summary(session)
result: list[dict[str, Any]] = []
counts = summary.get('counts', {})
for reason in summary.get('risk', {}).get('reasons', []):
normalized_reason = str(reason).lower()
if counts.get('stalledDownloads', 0) > 0 and 'deluge' in normalized_reason and 'stalled' in normalized_reason:
continue
if counts.get('failedWidefrogJobs', 0) > 0 and 'widefrog' in normalized_reason and 'fail' in normalized_reason:
continue
result.append({
'source': 'media_pipeline',
'event_type': 'media.issue',
'severity': 'medium',
'title': 'Media Pipeline issue',
'message': str(reason),
'category': 'media',
'target': 'media-pipeline',
'dedupe_key': f"media.issue:{str(reason).lower()}",
'payload': {'reason': reason},
})
if counts.get('stalledDownloads', 0) > 0:
result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Deluge stalled downloads', 'message': f"{counts['stalledDownloads']} stalled download(s).", 'category': 'downloads', 'target': 'deluge', 'dedupe_key': 'media.issue:deluge:stalled', 'payload': {'count': counts['stalledDownloads']}})
if counts.get('failedWidefrogJobs', 0) > 0:
result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Widefrog failed jobs', 'message': f"{counts['failedWidefrogJobs']} failed Widefrog job(s).", 'category': 'widefrog', 'target': 'widefrog', 'dedupe_key': 'media.issue:widefrog:failed', 'payload': {'count': counts['failedWidefrogJobs']}})
return result
def _audit_events(session: Session) -> list[dict[str, Any]]:
rows = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
result = []
for row in rows[:50]:
if row.policy_decision == 'blocked' or row.result == 'blocked':
event_type = 'audit.blocked_action' if row.risk in {'high', 'critical'} else ('audit.voice_denied' if row.source == 'voice' else 'audit.blocked_action')
result.append({
'source': 'audit',
'event_type': event_type,
'severity': 'high' if row.risk in {'high', 'critical'} else 'medium',
'title': 'Blocked dangerous action attempt' if event_type == 'audit.blocked_action' else 'Voice action denied',
'message': f'{row.action_id} on {row.target} was blocked.',
'category': 'policy',
'target': row.target,
'dedupe_key': f'{event_type}:{row.action_id}:{row.target}',
'payload': {'auditId': row.id, 'action': row.action_id, 'risk': row.risk},
})
return result
def _inventory_events(session: Session) -> list[dict[str, Any]]:
needs_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712
if not needs_review:
return []
return [{
'source': 'inventory',
'event_type': 'inventory.needs_review',
'severity': 'medium',
'title': 'Inventory review nodig',
'message': f'{len(needs_review)} app(s) vragen review.',
'category': 'inventory',
'target': 'inventory',
'dedupe_key': 'inventory.needs_review',
'payload': {'count': len(needs_review)},
}]
def _setup_events(session: Session) -> list[dict[str, Any]]:
latest = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
if latest:
return []
return [{
'source': 'setup',
'event_type': 'setup.incomplete',
'severity': 'medium',
'title': 'Setup incomplete',
'message': 'Discovery is nog niet uitgevoerd.',
'category': 'setup',
'target': 'setup',
'dedupe_key': 'setup.incomplete',
'payload': {'missing': 'discovery'},
}]
def evaluate_source(session: Session, source: str = 'all') -> dict[str, Any]:
with _EVALUATION_LOCK:
return _evaluate_source_locked(session, source)
def _evaluate_source_locked(session: Session, source: str = 'all') -> dict[str, Any]:
source_map = {
'safety': _safety_events,
'connectors': _connector_events,
'connector': _connector_events,
'media': _media_events,
'media_pipeline': _media_events,
'audit': _audit_events,
'inventory': _inventory_events,
'setup': _setup_events,
}
if source == 'all':
events: list[dict[str, Any]] = []
for key in ['safety', 'connectors', 'media', 'audit', 'inventory', 'setup']:
events.extend(source_map[key](session))
else:
events = source_map.get(source, lambda _: [])(session)
resolved = 0
if source in {'all', 'connectors', 'connector'}:
resolved += _auto_resolve_stale_connector_events(session, events)
if source in {'all', 'safety'}:
resolved += _auto_resolve_stale_safety_events(session, events)
if source in {'all', 'media', 'media_pipeline'}:
resolved += _auto_resolve_stale_media_events(session, events)
result = evaluate_rules(session, events)
result['resolved'] = resolved
result['evaluated'] = len(events)
result['source'] = source
return result
def summary(session: Session) -> dict[str, Any]:
events = session.exec(select(NotificationEvent)).all()
rules = session.exec(select(NotificationRule)).all()
channels = session.exec(select(NotificationChannel)).all()
unread = [event for event in events if not event.read and event.status not in {'dismissed', 'suppressed'}]
suppressed = [event for event in events if event.status == 'suppressed']
failed = [event for event in events if event.status == 'failed']
delivered = [event for event in events if event.status not in {'dismissed', 'suppressed'}]
last = max(delivered, key=lambda event: event.created_at) if delivered else None
return {
'unreadCount': len(unread),
'activeRulesCount': len([rule for rule in rules if rule.enabled]),
'suppressedCount': len(suppressed),
'failedCount': len(failed),
'lastNotification': _event_to_dict(last) if last else None,
'channels': [_channel_to_dict(channel, session) for channel in channels],
}