253 lines
12 KiB
Python
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)))
|