bifroest/app/notify/service.py
2026-10-27 08:34:17 +01:00

253 lines
12 KiB
Python

"""Benachrichtigungen per E-Mail und Webhook (Teams, Slack).
Anlässe: Ein Antrag wartet auf Freigabe (an alle, die ihn entscheiden dürfen, und an den
Webhook des Workspace) und ein Antrag wurde entschieden (an die Person, die ihn ausgelöst hat).
Zustellung im Worker mit Wiederholung; ohne konfigurierten Mailserver bzw. Webhook werden
Nachrichten als „übersprungen“ markiert. Webhooks nur per https an freigegebene Hosts.
"""
import logging
import re
import smtplib
from datetime import timedelta
from email.message import EmailMessage
from urllib.parse import urlsplit
import httpx
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import audit
from app.core import metrics
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import Database, now
from app.core.errors import Invalid
from app.identity.models import User
from app.notify.models import EMAIL, FAILED, PENDING, SENT, SKIPPED, WEBHOOK, Notification
from app.workspaces.models import APPROVER_ROLES, Workspace, WorkspaceMembership
log = logging.getLogger(__name__)
MAX_ATTEMPTS = 5
BATCH = 20
FOOTER = ("\n\n—\nAutomatische Nachricht von Bifröst (AI Workspace). Benachrichtigungen lassen sich im Profil abschalten.")
EMAIL_FORM = re.compile(r"^[^@\s<>]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,}$")
def check_email(value: str) -> str | None:
value = value.strip()
if not value:
return None
if not EMAIL_FORM.match(value):
raise Invalid("Ungültige E-Mail-Adresse.")
return value.lower()
def save_profile(session: Session, user_id: int, email_address: str, wants: bool) -> User:
user = session.get(User, user_id)
user.email, user.notify = check_email(email_address), wants
audit.record(session, "user.profile_updated", user_id=user_id, target_kind="user", target_id=user_id,
email=bool(user.email), notify=wants)
return user
def test_email(session: Session, user_id: int) -> None:
"""Test-Mail an die eigene Adresse, über denselben Weg wie echte Nachrichten."""
user = session.get(User, user_id)
if not user.email:
raise Invalid("Im Profil ist keine E-Mail-Adresse hinterlegt.")
session.add(Notification(channel=EMAIL, user_id=user.id, event="test", subject="Test von Bifröst",
body="Diese Test-Nachricht zeigt, dass der Versand funktioniert." + FOOTER))
def test_webhook(session: Session, workspace: Workspace) -> None:
if not workspace.webhook:
raise Invalid("Es ist kein Webhook hinterlegt.")
session.add(Notification(channel=WEBHOOK, workspace_id=workspace.id, event="test", subject="Test",
body=f"Test-Nachricht aus Bifröst, Workspace „{clean(workspace.name, 80)}“."))
# Platzhalter für die Basis-URL; ersetzt erst beim Zustellen (public_url des Workers)
BASE = "[[aiw-base]]"
def link(path: str) -> str:
return f"{BASE}{path}"
def expand(text: str, settings: Settings) -> str:
return text.replace(BASE, settings.public_url.rstrip("/"))
def clean(text: str, chars: int = 200) -> str:
"""Vom Modell formulierte Texte einzeilig und ohne spitze Klammern (Link-Syntax in Slack/Teams)."""
return " ".join(str(text).replace("<", "").replace(">", "").split())[:chars]
def email(session: Session, user: User, event: str, subject: str, body: str,
workspace_id: int | None = None) -> None:
if not user.active or not user.notify or not user.email:
return
session.add(Notification(channel=EMAIL, user_id=user.id, workspace_id=workspace_id, event=event,
subject=subject[:300], body=body + FOOTER))
def webhook(session: Session, workspace: Workspace, event: str, text: str) -> None:
if workspace.webhook:
session.add(Notification(channel=WEBHOOK, workspace_id=workspace.id, event=event, subject=text[:300],
body=text))
# --- Anlässe ---
def deciders(session: Session, approval) -> list[User]:
"""Wer den Antrag entscheiden darf (ohne die auslösende Person)."""
if approval.approval_level == "global_approver":
users = session.scalars(select(User).where(User.is_global_approver, User.active))
else:
users = session.scalars(select(User).join(WorkspaceMembership, WorkspaceMembership.user_id == User.id)
.where(WorkspaceMembership.workspace_id == approval.workspace_id,
WorkspaceMembership.role.in_(APPROVER_ROLES), User.active))
return [u for u in users if u.id != approval.requested_by]
def approval_requested(session: Session, approval, workspace: Workspace) -> None:
requester = session.get(User, approval.requested_by)
title = clean(approval.arguments.get("title") or approval.tool)
url = link(f"/approvals/{approval.id}")
subject = f"Freigabe nötig: {title} ({clean(workspace.name, 80)})"
body = (f"{requester.display_name if requester else 'Jemand'} hat im Workspace „{workspace.name}“ eine Aktion "
f"ausgelöst, die eine Freigabe braucht.\n\nTool: {approval.tool}\nTitel: {title}\n"
f"Läuft ab: {approval.expires_at.astimezone().strftime('%d.%m.%Y %H:%M')}"
f"{chr(10) + 'Vier-Augen-Prinzip: Die auslösende Person kann nicht selbst freigeben.' if approval.four_eyes else ''}"
f"\n\nZur Entscheidung: {url}")
for user in deciders(session, approval):
email(session, user, "approval.requested", subject, body, workspace.id)
webhook(session, workspace, "approval.requested", f"Freigabe nötig im Workspace {clean(workspace.name, 80)}: "
f"{title} ({approval.tool}). {url}")
def approval_decided(session: Session, approval, decider_id: int, chat_id: int | None) -> None:
requester = session.get(User, approval.requested_by)
if requester is None or requester.id == decider_id:
return
workspace = session.get(Workspace, approval.workspace_id)
decider = session.get(User, decider_id)
approved = approval.status == "approved"
title = clean(approval.arguments.get("title") or approval.tool)
subject = f"Freigabe {'erteilt' if approved else 'abgelehnt'}: {title}"
body = (f"{decider.display_name if decider else 'Jemand'} hat deinen Antrag im Workspace „{workspace.name}“ "
f"{'freigegeben' if approved else 'abgelehnt'}.\n\nTitel: {title}"
+ (f"\nKommentar: {approval.comment}" if approval.comment else "")
+ ("\n\nDie Antwort im Chat geht jetzt weiter." if approved else "")
+ f"\n\nAntrag: {link(f'/approvals/{approval.id}')}"
+ (f"\nChat: {link(f'/chats/{chat_id}')}" if chat_id else ""))
email(session, requester, "approval.decided", subject, body, workspace.id)
# --- Webhook-Adresse des Workspace ---
def check_webhook(url: str, settings: Settings) -> str:
url = url.strip()
parts = urlsplit(url)
host = (parts.hostname or "").lower()
local = host in ("localhost", "127.0.0.1", "::1")
if parts.scheme != "https" and not (local and settings.webhook_allow_local and parts.scheme == "http"):
raise Invalid("Webhooks nur über https.")
if not local and not any(host == h or (h.startswith(".") and host.endswith(h))
for h in settings.allowed_webhook_hosts):
raise Invalid(f"Host {host or '?'} ist für Webhooks nicht freigegeben (AIW_WEBHOOK_ALLOWED_HOSTS).")
if local and not settings.webhook_allow_local:
raise Invalid("Lokale Webhooks sind nicht freigegeben.")
if len(url) > 2000:
raise Invalid("Die Adresse ist zu lang.")
return url
def set_webhook(session: Session, settings: Settings, vault: Vault, workspace: Workspace, url: str,
remove: bool = False) -> None:
if remove:
workspace.webhook, workspace.webhook_hint = None, ""
return
if not url.strip():
return
if not vault.ready:
raise Invalid("AIW_SECRET_KEY fehlt; die Adresse kann nicht verschlüsselt gespeichert werden.")
url = check_webhook(url, settings)
workspace.webhook = vault.encrypt(url)
workspace.webhook_hint = urlsplit(url).hostname[:40]
# --- Zustellung (Worker) ---
def send_email(settings: Settings, to: str, subject: str, body: str) -> None:
message = EmailMessage()
message["From"], message["To"], message["Subject"] = settings.smtp_from, to, subject
message.set_content(body)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=settings.smtp_timeout_seconds) as smtp:
if settings.smtp_starttls:
smtp.starttls()
if settings.smtp_user:
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(message)
def post_webhook(settings: Settings, url: str, text: str) -> None:
# {"text": …} verstehen eingehende Webhooks von Slack und Microsoft Teams
response = httpx.post(check_webhook(url, settings), json={"text": text}, timeout=10, follow_redirects=False)
response.raise_for_status()
def deliver(db: Database, settings: Settings) -> int:
"""Fällige Nachrichten zustellen; gibt die Zahl der bearbeiteten zurück."""
with db.session() as session:
due = list(session.scalars(select(Notification).where(Notification.status == PENDING,
Notification.available_at <= func.now())
.order_by(Notification.id).limit(BATCH).with_for_update(skip_locked=True)))
for item in due:
try:
target = address(session, settings, item)
if target is None:
item.status = SKIPPED
continue
if item.channel == EMAIL:
send_email(settings, target, item.subject, expand(item.body, settings))
else:
post_webhook(settings, target, expand(item.body, settings))
item.status, item.sent, item.error = SENT, now(), ""
metrics.notifications_sent.labels(item.channel, "sent").inc()
except Exception as e:
item.attempts += 1
item.error = f"{type(e).__name__}: {e}"[:500]
metrics.notifications_sent.labels(item.channel, "error").inc()
log.warning("Zustellung fehlgeschlagen", extra={"event": "notification_error", "channel": item.channel,
"notification_id": item.id, "attempt": item.attempts,
"error": item.error})
if item.attempts >= MAX_ATTEMPTS:
item.status = FAILED
log.warning("Benachrichtigung %s endgültig fehlgeschlagen: %s", item.id, item.error)
else:
item.available_at = func.now() + timedelta(seconds=30 * 2 ** item.attempts)
return len(due)
def address(session: Session, settings: Settings, item: Notification) -> str | None:
"""Empfänger zum Zeitpunkt der Zustellung; None = überspringen (nicht eingerichtet oder abbestellt)."""
if item.channel == EMAIL:
user = session.get(User, item.user_id) if item.user_id else None
if not settings.smtp_host or not settings.smtp_from or user is None or not user.email \
or not user.notify or not user.active:
return None
return user.email
workspace = session.get(Workspace, item.workspace_id) if item.workspace_id else None
if workspace is None or not workspace.webhook or workspace.deleted is not None:
return None
return Vault(settings.secret_key).decrypt(workspace.webhook)
def recent(session: Session, limit: int = 50) -> list[Notification]:
return list(session.scalars(select(Notification).order_by(Notification.id.desc()).limit(limit)))