From f6d27610257956ac2aaab2bb5a067e66ea4fcac3 Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Sun, 9 Aug 2026 12:00:00 +0000 Subject: [PATCH] feat: add the concierge bot for expiring guest invitations Turns guest onboarding from an admin-only click in the Authentik UI into a traceable command a defined circle can run: !einladen creates a single-use invitation valid for three days, !verlaengern extends it twice at most, !freischalten makes it permanent, and expired accounts are deactivated automatically. Authorisation is deliberately twofold - the Authentik group decides, the invite room makes it visible. A group alone leaves no trace of who invited whom; a room alone would authorise anyone who gets in. Two deployment details matter: exactly one replica with Recreate, because a second instance would execute every command twice; and the script ConfigMap keeps its name hash so a change actually restarts the pod, avoiding the trap described in #50. Endpoints and field names were taken from the running Authentik OpenAPI schema, not guessed. Refs axion1337.chat/axion1337.chat-gitops#48 --- apps/production/concierge-bot.py | 313 ++++++++++++++++++ apps/production/concierge-bot.yaml | 85 +++++ apps/production/kustomization.yaml | 10 + .../08-concierge-gaeste-einladungen.md | 107 ++++++ 4 files changed, 515 insertions(+) create mode 100644 apps/production/concierge-bot.py create mode 100644 apps/production/concierge-bot.yaml create mode 100644 docs/deployment-guides/08-concierge-gaeste-einladungen.md diff --git a/apps/production/concierge-bot.py b/apps/production/concierge-bot.py new file mode 100644 index 0000000..76dab96 --- /dev/null +++ b/apps/production/concierge-bot.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# @concierge - Gaeste-Einladungen mit Ablauf, Freischaltung und begrenzter +# Verlaengerung (gitops#48, Design von sorb am 2026-08-01 festgezurrt). +# +# WARUM EIN EIGENER BOT UND NICHT DRAUPNIR +# Draupnir ist ein Moderationsbot ohne Lebenszyklus-Funktionen. Er kann einen +# Gast policy-seitig einschraenken, aber Links erzeugen, Ablaeufe verwalten und +# Konten deaktivieren kann er nicht. Ihn dafuer zu verbiegen hiesse, Upstream- +# Code zu forken, den wir sonst unveraendert mitziehen. +# +# WARUM AUTHENTIK UND NICHT SYNAPSE-REGISTRATION-TOKENS +# In diesem Stack laeuft Registrierung ausschliesslich ueber Authentik (MAS-OIDC). +# Synapse kennt gar keinen offenen Registrierungsweg - ein Registration-Token +# waere wirkungslos. Der natuerliche Einladungslink ist deshalb ein +# Authentik-Invitation-Token: einmalig verwendbar, mit eigenem Ablaufdatum. +# +# BERECHTIGUNG = GRUPPE **UND** RAUM +# Autoritativ ist die Mitgliedschaft in der Authentik-Gruppe (INVITE_GROUP). +# Zusaetzlich nimmt der Bot Kommandos nur im Einladungsraum an. Die Gruppe ist +# die Kontrolle, der Raum die Transparenz: Jede Einladung hinterlaesst einen +# nachlesbaren Eintrag, wer wen eingeladen hat. Beides zusammen, weil eine +# Gruppe allein unsichtbar ist und ein Raum allein nicht autorisiert. +# +# ⚠️ ZUORDNUNG MATRIX -> AUTHENTIK +# Der Bot nimmt an, dass der Matrix-Localpart dem Authentik-Benutzernamen +# entspricht (@gast:axion1337.chat -> "gast"). Das gilt in diesem Stack, weil +# MAS die Konten aus Authentik provisioniert. Stimmt das einmal nicht, findet +# der Bot den Nutzer nicht und sagt das - er raet nicht. +# +# FEHLERVERHALTEN, BEWUSST ASYMMETRISCH +# - Einladen/Freischalten scheitert LAUT: lieber keine Einladung als eine, von +# der niemand weiss. +# - Die Ablaufpruefung deaktiviert NUR, wenn Authentik sauber geantwortet hat. +# Ein API-Fehler darf nicht dazu fuehren, dass Konten reihenweise abgeschaltet +# werden - im Zweifel bleibt ein Gast einen Durchlauf laenger aktiv. +# +# Stdlib only, wie die uebrigen Bots dieses Verbunds. + +import json +import logging +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone + +log = logging.getLogger("concierge") + +MATRIX = os.environ["MATRIX_HOMESERVER"].rstrip("/") +ROOM = os.environ["MATRIX_ROOM_ID"] +AUTHENTIK = os.environ["AUTHENTIK_URL"].rstrip("/") +INVITE_GROUP = os.environ.get("INVITE_GROUP", "invite-berechtigt") +MEMBER_GROUP = os.environ.get("MEMBER_GROUP", "members") +ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins") +INVITE_FLOW = os.environ.get("INVITE_FLOW_SLUG", "matrix-invitation") +GUEST_DAYS = int(os.environ.get("GUEST_DAYS", "3")) +MAX_RENEWALS = int(os.environ.get("MAX_RENEWALS", "2")) +SWEEP_SECONDS = int(os.environ.get("SWEEP_SECONDS", "900")) + +# Attribute am Authentik-Nutzer. Praefix, damit sie nicht mit Feldern anderer +# Werkzeuge kollidieren, die sich denselben attributes-Topf teilen. +ATTR_EXPIRES = "threadnet_guest_expires_at" +ATTR_RENEWALS = "threadnet_guest_renewals" +ATTR_INVITED_BY = "threadnet_invited_by" + + +def _read(path_env, direct_env): + """Token entweder aus einer Datei (Secret-Mount) oder direkt. Dateien sind + der Normalfall - ein Wert in der Umgebung steht in jedem Prozess-Dump.""" + p = os.environ.get(path_env) + if p: + with open(p) as f: + return f.read().strip() + return os.environ[direct_env] + + +MATRIX_TOKEN = _read("MATRIX_TOKEN_FILE", "MATRIX_TOKEN") +AUTHENTIK_TOKEN = _read("AUTHENTIK_TOKEN_FILE", "AUTHENTIK_TOKEN") + + +def _call(url, token, method="GET", body=None, scheme="Bearer"): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"{scheme} {token}") + if data: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def ak(path, method="GET", body=None): + return _call(f"{AUTHENTIK}/api/v3{path}", AUTHENTIK_TOKEN, method, body) + + +def mx(path, method="GET", body=None): + return _call(f"{MATRIX}/_matrix/client/v3{path}", MATRIX_TOKEN, method, body) + + +def say(text): + txn = str(int(time.time() * 1000)) + room = urllib.parse.quote(ROOM) + mx(f"/rooms/{room}/send/m.room.message/{txn}", "PUT", + {"msgtype": "m.notice", "body": text}) + + +# --- Authentik --------------------------------------------------------------- + +def find_user(username): + r = ak(f"/core/users/?username={urllib.parse.quote(username)}") + for u in r.get("results", []): + if u["username"] == username: + return u + return None + + +def group_uuid(name): + r = ak(f"/core/groups/?name={urllib.parse.quote(name)}") + for g in r.get("results", []): + if g["name"] == name: + return g["pk"] + return None + + +def in_group(user, name): + return any(g.get("name") == name for g in user.get("groups_obj", [])) + + +def set_attrs(user, **changes): + """attributes ist ein einzelnes JSON-Feld: PATCH ersetzt es komplett. Wer nur + einen Schluessel schickt, loescht alle anderen - deshalb immer mischen.""" + attrs = dict(user.get("attributes") or {}) + for k, v in changes.items(): + if v is None: + attrs.pop(k, None) + else: + attrs[k] = v + return ak(f"/core/users/{user['pk']}/", "PATCH", {"attributes": attrs}) + + +def localpart(mxid): + return mxid.lstrip("@").split(":")[0] + + +# --- Kommandos --------------------------------------------------------------- + +def darf_einladen(sender): + u = find_user(localpart(sender)) + return u is not None and in_group(u, INVITE_GROUP), u + + +def ist_admin(sender): + u = find_user(localpart(sender)) + return u is not None and in_group(u, ADMIN_GROUP) + + +def cmd_einladen(sender, args): + ok, _ = darf_einladen(sender) + if not ok: + say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.") + return + name = (args or "gast").strip().replace(" ", "-")[:40] + expires = datetime.now(timezone.utc) + timedelta(days=GUEST_DAYS) + inv = ak("/stages/invitation/invitations/", "POST", { + "name": f"gast-{name}-{int(time.time())}", + "expires": expires.isoformat(), + "single_use": True, + "fixed_data": {ATTR_INVITED_BY: sender}, + }) + link = f"{AUTHENTIK}/if/flow/{INVITE_FLOW}/?itoken={inv['pk']}" + say(f"Einladung von {sender} fuer '{name}':\n{link}\n" + f"Einmalig verwendbar, verfaellt {expires:%d.%m.%Y %H:%M} UTC.") + + +def cmd_freischalten(sender, args): + if not ist_admin(sender): + say(f"{sender}: Freischalten darf nur die Gruppe '{ADMIN_GROUP}'.") + return + u = find_user(localpart(args.strip())) + if not u: + say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.") + return + set_attrs(u, **{ATTR_EXPIRES: None, ATTR_RENEWALS: None}) + gid = group_uuid(MEMBER_GROUP) + if gid: + ak(f"/core/groups/{gid}/add_user/", "POST", {"pk": u["pk"]}) + say(f"{u['username']} ist dauerhaft freigeschaltet (von {sender}).") + + +def cmd_verlaengern(sender, args): + ok, _ = darf_einladen(sender) + if not ok: + say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.") + return + u = find_user(localpart(args.strip())) + if not u: + say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.") + return + used = int((u.get("attributes") or {}).get(ATTR_RENEWALS, 0)) + if used >= MAX_RENEWALS: + say(f"{u['username']}: {MAX_RENEWALS} Verlaengerungen sind aufgebraucht. " + f"Jetzt muss ein Admin freischalten.") + return + neu = datetime.now(timezone.utc) + timedelta(days=1) + set_attrs(u, **{ATTR_EXPIRES: neu.isoformat(), ATTR_RENEWALS: used + 1}) + if not u.get("is_active"): + ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": True}) + say(f"{u['username']} um einen Tag verlaengert ({used + 1}/{MAX_RENEWALS}), " + f"laeuft {neu:%d.%m.%Y %H:%M} UTC ab.") + + +def cmd_status(_sender, _args): + r = ak("/core/users/?page_size=200") + zeilen = [] + for u in r.get("results", []): + exp = (u.get("attributes") or {}).get(ATTR_EXPIRES) + if exp: + used = (u.get("attributes") or {}).get(ATTR_RENEWALS, 0) + zustand = "aktiv" if u.get("is_active") else "deaktiviert" + zeilen.append(f" {u['username']}: laeuft {exp[:16]} ab, " + f"{used}/{MAX_RENEWALS} verlaengert, {zustand}") + say("Gaeste:\n" + ("\n".join(zeilen) if zeilen else " keine offenen Gastkonten")) + + +def cmd_hilfe(_sender, _args): + say("!einladen - Einladungslink erzeugen\n" + "!verlaengern @nutzer - um einen Tag verlaengern (begrenzt)\n" + "!freischalten @nutzer - dauerhaft freischalten (nur Admins)\n" + "!status - offene Gastkonten anzeigen") + + +BEFEHLE = { + "!einladen": cmd_einladen, + "!verlaengern": cmd_verlaengern, + "!freischalten": cmd_freischalten, + "!status": cmd_status, + "!hilfe": cmd_hilfe, +} + + +# --- Ablaufpruefung ---------------------------------------------------------- + +def sweep(): + try: + r = ak("/core/users/?page_size=200") + except Exception as e: + # KEIN Deaktivieren bei API-Fehlern - siehe Kopfkommentar. + log.warning("Ablaufpruefung uebersprungen, Authentik nicht erreichbar: %s", e) + return + jetzt = datetime.now(timezone.utc) + for u in r.get("results", []): + exp = (u.get("attributes") or {}).get(ATTR_EXPIRES) + if not exp or not u.get("is_active"): + continue + try: + faellig = datetime.fromisoformat(exp) + except ValueError: + log.warning("%s: unlesbares Ablaufdatum %r", u["username"], exp) + continue + if faellig.tzinfo is None: + faellig = faellig.replace(tzinfo=timezone.utc) + if faellig <= jetzt: + ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": False}) + say(f"Gastkonto {u['username']} ist abgelaufen und wurde deaktiviert. " + f"'!verlaengern @{u['username']}' oder Admin-Freischaltung.") + + +# --- Hauptschleife ----------------------------------------------------------- + +def main(): + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") + mx(f"/rooms/{urllib.parse.quote(ROOM)}/join", "POST", {}) + # Ab jetzt, nicht die Raumhistorie: ein Neustart soll keine alten Kommandos + # erneut ausfuehren. + since = mx("/sync?timeout=0").get("next_batch") + log.info("bereit, Raum %s", ROOM) + letzter_sweep = 0.0 + while True: + try: + if time.time() - letzter_sweep > SWEEP_SECONDS: + sweep() + letzter_sweep = time.time() + r = mx(f"/sync?since={urllib.parse.quote(since)}&timeout=30000") + since = r.get("next_batch", since) + raum = r.get("rooms", {}).get("join", {}).get(ROOM, {}) + for ev in raum.get("timeline", {}).get("events", []): + if ev.get("type") != "m.room.message": + continue + c = ev.get("content", {}) + if c.get("msgtype") != "m.text": + continue + text = (c.get("body") or "").strip() + wort = text.split(" ", 1)[0].lower() + if wort not in BEFEHLE: + continue + rest = text[len(wort):].strip() + try: + BEFEHLE[wort](ev["sender"], rest) + except Exception as e: + log.exception("Kommando %s fehlgeschlagen", wort) + say(f"'{wort}' fehlgeschlagen: {e}") + except urllib.error.HTTPError as e: + log.warning("HTTP %s bei /sync - warte", e.code) + time.sleep(10) + except Exception: + log.exception("Schleifenfehler") + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/apps/production/concierge-bot.yaml b/apps/production/concierge-bot.yaml new file mode 100644 index 0000000..3355781 --- /dev/null +++ b/apps/production/concierge-bot.yaml @@ -0,0 +1,85 @@ +# @concierge - Gaeste-Einladungen (gitops#48). Skript: concierge-bot.py, +# als ConfigMap ueber den configMapGenerator in kustomization.yaml. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: concierge-bot + namespace: matrix +spec: + # ⚠️ Genau EINE Instanz. Der Bot haelt eine /sync-Schleife und verarbeitet + # Kommandos; zwei Instanzen wuerden jedes Kommando doppelt ausfuehren und + # jede Meldung doppelt posten. Deshalb replicas: 1 UND Recreate - bei + # RollingUpdate liefen waehrend eines Deploys kurzzeitig zwei. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: concierge-bot + template: + metadata: + labels: + app: concierge-bot + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: bot + image: python:3.12-alpine + command: ["python3", "/app/concierge-bot.py"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + - name: MATRIX_HOMESERVER + value: "https://matrix.axion1337.chat" + # Der Einladungsraum. ⚠️ Muss invite-only sein - der Bot prueft zwar + # zusaetzlich die Authentik-Gruppe, aber ein offener Raum macht + # sichtbar, wer eingeladen wurde, und das ist der halbe Zweck. + - name: MATRIX_ROOM_ID + valueFrom: + secretKeyRef: + name: concierge-credentials + key: matrix-room-id + # In-Cluster, nicht ueber die oeffentliche Adresse: spart den Umweg + # ueber Traefik und funktioniert auch, wenn extern etwas klemmt. + - name: AUTHENTIK_URL + value: "http://authentik-server.authentik.svc.cluster.local" + - name: MATRIX_TOKEN_FILE + value: /secrets/matrix-token + - name: AUTHENTIK_TOKEN_FILE + value: /secrets/authentik-token + - name: GUEST_DAYS + value: "3" + - name: MAX_RENEWALS + value: "2" + volumeMounts: + - name: script + mountPath: /app + readOnly: true + - name: creds + mountPath: /secrets + readOnly: true + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi + volumes: + - name: script + configMap: + name: concierge-bot-script + - name: creds + secret: + secretName: concierge-credentials + - name: tmp + emptyDir: {} diff --git a/apps/production/kustomization.yaml b/apps/production/kustomization.yaml index 346f019..fd3e43c 100644 --- a/apps/production/kustomization.yaml +++ b/apps/production/kustomization.yaml @@ -52,11 +52,21 @@ resources: - clamav.yaml # Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung) - clamav-http-scanner.yaml + - concierge-bot.yaml # Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted - # disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml # referenziert wird (kustomize kann Referenzen nicht in opaken YAML-Strings umschreiben). configMapGenerator: + # ⚠️ Bewusst OHNE disableNameSuffixHash: Der Hash im ConfigMap-Namen aendert + # sich mit dem Skript, kustomize zieht die Referenz im Deployment nach, und + # der Pod startet dadurch von selbst neu. Ohne das haetten wir wieder den + # Fall aus gitops#50 - geaenderte Datei im Repo, alter Stand im laufenden + # Prozess, und niemand merkt es. + - name: concierge-bot-script + namespace: matrix + files: + - concierge-bot.py - name: synapse-clamav-module namespace: matrix files: diff --git a/docs/deployment-guides/08-concierge-gaeste-einladungen.md b/docs/deployment-guides/08-concierge-gaeste-einladungen.md new file mode 100644 index 0000000..87ff793 --- /dev/null +++ b/docs/deployment-guides/08-concierge-gaeste-einladungen.md @@ -0,0 +1,107 @@ +# @concierge — Gäste-Einladungen mit Ablauf + +**Status**: gebaut, wartet auf Zugangsdaten · Issue [#48](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/issues/48) + +Ein kleiner Bot, der Einladungslinks erzeugt, Gastkonten nach drei Tagen ablaufen +lässt und die dauerhafte Freischaltung an eine bewusste Admin-Handlung bindet. + +## Warum es diesen Bot gibt + +Registrierung läuft in diesem Stack **ausschließlich über Authentik**. Bis jetzt +hieß das: Wer jemanden einladen will, klickt in der Authentik-Oberfläche einen +Invitation-Token zusammen. Das können nur Admins, es hinterlässt keine Spur, wer +wen eingeladen hat, und ein Gastkonto bleibt für immer bestehen. + +Der Bot macht daraus einen Vorgang, den ein festgelegter Kreis selbst auslösen +kann — nachvollziehbar und mit eingebautem Ablauf. + +## Wie es funktioniert + +``` +!einladen → Authentik-Invitation (einmalig, 3 Tage) + Link im Raum + Gast registriert sich → Konto trägt threadnet_guest_expires_at +!verlaengern @gast → +1 Tag, höchstens 2× +!freischalten @gast → Ablauf entfernen, in members-Gruppe (nur Admins) + (nichts davon) → Bot deaktiviert das Konto nach Ablauf +``` + +### Berechtigung ist zweiteilig — und das ist Absicht + +**Authentik-Gruppe UND Einladungsraum.** Die Gruppe entscheidet, der Raum macht +sichtbar. Eine Gruppe allein ist unsichtbar: Niemand sähe, wer eingeladen hat. +Ein Raum allein autorisiert nicht: Wer hineinkommt, dürfte alles. Zusammen ergibt +sich beides, und jede Einladung hinterlässt einen nachlesbaren Eintrag. + +### Zwei Dinge, die beim Umbauen leicht kaputtgehen + +⚠️ **Genau eine Instanz.** `replicas: 1` **und** `strategy: Recreate`. Der Bot +hält eine `/sync`-Schleife; zwei Instanzen führen jedes Kommando doppelt aus. Bei +`RollingUpdate` liefen während eines Deploys kurzzeitig zwei. + +⚠️ **Die ConfigMap trägt bewusst einen Namens-Hash.** Anders als beim +ClamAV-Modul steht hier **kein** `disableNameSuffixHash: true`. Dadurch ändert +sich der ConfigMap-Name mit dem Skript, kustomize zieht die Referenz nach, und +der Pod startet von selbst neu. Ohne das hätten wir den Fall aus gitops#50: +geänderte Datei im Repo, alter Stand im laufenden Prozess. + +### Fehlerverhalten ist absichtlich unsymmetrisch + +- **Einladen und Freischalten scheitern laut.** Lieber keine Einladung als eine, + von der niemand weiß. +- **Die Ablaufprüfung deaktiviert nur, wenn Authentik sauber geantwortet hat.** + Ein API-Fehler darf nicht dazu führen, dass Konten reihenweise abgeschaltet + werden; im Zweifel bleibt ein Gast einen Durchlauf länger aktiv. + +## Was zur Inbetriebnahme fehlt + +Der Bot ist ausgerollt, **startet aber nicht**, solange das Secret fehlt — der Pod +meldet `secret "concierge-credentials" not found`. Das ist gewollt sichtbar; ein +Bot, der still nichts tut, wäre schlechter. + +### 1. Matrix-Konto anlegen + +```bash +kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \ + mas-cli manage register-user concierge --yes +kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \ + mas-cli manage issue-compatibility-token concierge +``` + +### 2. Authentik-Token + +*Admin → Verzeichnis → Tokens*. Braucht Schreibrechte auf Nutzer, Gruppen und +Invitations. Ein eigenes Dienstkonto ist sauberer als ein Admin-Token. + +### 3. Einladungsraum + +Invite-only anlegen, `@concierge` einladen. Die Raum-ID ist Teil des Secrets, weil +sie zusammen mit den Token gepflegt wird und sich beim Neuanlegen ändert. + +### 4. Gruppen in Authentik + +`invite-berechtigt` (wer einladen darf) und `members` (wohin Freigeschaltete +kommen). Namen sind über `INVITE_GROUP` / `MEMBER_GROUP` änderbar. + +### 5. Secret + +```bash +kubectl create secret generic concierge-credentials -n matrix \ + --from-literal=matrix-token='…' \ + --from-literal=authentik-token='…' \ + --from-literal=matrix-room-id='!….:axion1337.chat' \ + --dry-run=client -o yaml > /tmp/s.yaml +sops -e /tmp/s.yaml > apps/production/concierge-secret.yaml +``` + +⚠️ Über SOPS ins Repo, nicht mit `kubectl apply` von Hand — sonst kennt Flux das +Secret nicht und es fehlt nach einem Wiederaufbau des Clusters. + +## Grenzen + +- **Matrix-Localpart = Authentik-Benutzername.** Gilt hier, weil MAS aus + Authentik provisioniert. Stimmt es einmal nicht, findet der Bot den Nutzer + nicht und sagt das — er rät nicht. +- **Ein deaktiviertes Konto ist nicht gelöscht.** Räume und Nachrichten bleiben. + Löschen ist bewusst nicht Sache des Bots. +- **Draupnir-Verzahnung** (Gast-Label → eingeschränkte Räume) ist Stufe 2 und + nicht Teil dieser Fassung.