Skip to content

Prozessmanagement (Betrieb)

Technik-Setup für Jira-Mirror, WhatsApp Cloud API, Telegram-Bot, Assistant-Pipeline und Nextcloud (ADR-0059–0062).

Compose

  • Worker-Queues: celery + sync (Service celery), assistant (Service celery-assistant), Scheduler celery-beat.
  • Optional Dev: Service ollama im Profil dev (OLLAMA_BASE_URL=http://ollama:11434).
  • Production: Traefik-Router PathPrefix(/api/v1/webhooks) direkt auf backend:8000.

Env

Siehe .env.example: OLLAMA_*, WHISPER_URL, MESSAGE_RETENTION_DAYS. Mandanten-Secrets (Jira-Token, WhatsApp-Access-Token, Nextcloud-App-Passwort) liegen verschlüsselt in integration_connections, die Einstellungen der Meta-App in whatsapp_platform_settings (Control-DB) — beides nicht in .env.

Migrationen

./scripts/alembic_upgrade_all.sh

Control 0041_webhook_endpoints, Tenant 0026–0029.

Projektrollen (ADR-0069, Major-Release): Das Skript führt Tenant 0038_project_roles aus (owner → lead, editor → office, reader → field; project_contacts → Mitglieder; Personen mit role_name = 'leadership' → Leitung in allen aktiven Projekten; leert role_name für field / office / leadership), danach python -m app.projects.backfill_members und zuletzt 0039_project_members_not_null. Der Backfill verknüpft jede Mitgliedschaft mit Mitarbeiter und Person, legt für Kontakte Staff-Mitarbeiter ohne Login an und verwirft Mitgliedschaften mandantenfremder Konten (z. B. Systemadmins); er ist idempotent. 0039 setzt user_id und person_id auf NOT NULL, löscht project_contacts und bricht ab, wenn noch unverknüpfte Zeilen übrig sind. Control 0050_project_member_roles spiegelt das für die gemeinsame pytest-DB.

Neue Organisationen werden aus linetra_tenant_template geklont, auf die Baseline 0021 gestempelt und danach per alembic -c alembic.tenant.ini upgrade head hochgezogen (app/tenants/provision.py). Organisationen, die vor diesem Schritt angelegt wurden, holst Du mit dem Skript oben nach.

WhatsApp einrichten

Zwei Ebenen, getrennt nach Rolle (ADR-0060):

Ebene Wer Wo Inhalt
Webhooks Systemadmin System → Webhooks (/settings/webhooks), ✎ → Dialog Öffentliche Webhook-Basis-URL (nur HTTPS), gemeinsam für WhatsApp und Telegram. Gespeichert in whatsapp_platform_settings.public_webhook_base_url. GET/PUT /api/v1/platform/webhooks
Plattform (eine Meta-App) Systemadmin System → WhatsApp (/settings/whatsapp), ✎ → Dialog; Singleton whatsapp_platform_settings in der Control-DB App-Secret (verschlüsselt, write-only; API liefert nur die letzten drei Zeichen), Verify-Token (verschlüsselt gespeichert, für Systemadmins lesbar, weil er bei Meta eingetragen werden muss), Graph-API-Version (Standard v23.0)
Mandant (eine Nummer) Org-Admin Einstellungen → Integrationen Telefonnummer-ID, WhatsApp-Business-Konto-ID, Zugriffstoken (verschlüsselt in integration_connections)

Beim Anlegen der WhatsApp-Integration legt das Backend in der Control-DB an:

  • webhook_endpoints: ein stabiler path_token pro Mandant → Webhook-URL {Webhook-Basis-URL}/api/v1/webhooks/whatsapp/{path_token}.
  • messaging_channels: phone_number_id → Mandant. Eine ID gehört genau einem Mandanten; ein zweiter Mandant bekommt 409.

Bei Meta (App → WhatsApp → Konfiguration → Webhooks) trägst Du die Webhook-URL und den Verify-Token ein und abonnierst das Feld messages. Verbindung testen liest das Nummernprofil (display_phone_number, verified_name) und abonniert die App per POST /{waba_id}/subscribed_apps, wenn eine Business-Konto-ID gesetzt ist.

App-Secret

Ohne App-Secret prüft der Webhook die Signatur X-Hub-Signature-256 nicht. Das ist nur lokal vertretbar; vor dem Livegang setzen.

Absender erkennen

Telefonnummern von Personen werden als E.164 gespeichert (+491701234567, app/messaging/phone.py, Bibliothek phonenumbers, Standardland DE). Die wa_id von Meta ist dieselbe Nummer ohne +. Jedes Speichern einer Nummer (Stammdaten, Mitarbeiter-Dialog, Profil) legt die WhatsApp-Identität in contact_identities an oder aktualisiert sie und ordnet frühere Unterhaltungen dieser Nummer ohne Person der Person zu.

Organisationen und Schema-Felder vom Typ phone speichern ebenfalls E.164, ohne Eindeutigkeit. Schema-Felder normalisieren nachsichtig: Alte Werte, die die Formatprüfung bestehen, aber nicht als Nummer lesbar sind, bleiben unverändert.

Eine Nummer gehört genau einer Katalog-Person (project_id IS NULL); die API antwortet sonst mit 409, die Tenant-Migration 0030 legt dazu den Index persons_phone_catalog_key an. Projektkopien tragen die Nummer ihrer Quelle und sind ausgenommen.

Lokal braucht Meta eine öffentliche HTTPS-Adresse: Tunnel (z. B. cloudflared tunnel --url http://localhost:${BACKEND_PORT}) und dessen URL als Webhook-Basis-URL unter System → Webhooks.

Telegram einrichten

Zweiter Kanal neben WhatsApp (ADR-0068): ein Bot pro Organisation, nur private 1:1-Chats. Keine Migration nötig, es werden bestehende Tabellen verwendet.

Verbindung: Org-Admin unter Einstellungen → Integrationen → + Telegram verbinden mit Bezeichnung und Bot-Token (Format 123456789:AAH…, validiert). Gespeichert als integration_connections mit provider = 'telegram'; der Token ist Fernet-verschlüsselt und write-only. config enthält bot_id (aus dem Token-Präfix) und nach dem Test bot_username.

Verbindung testen läuft beim Speichern automatisch:

  1. getMe prüft den Token und liefert den @Benutzernamen.
  2. setWebhook registriert {Webhook-Basis-URL}/api/v1/webhooks/telegram/{path_token} mit zufälligem secret_token (verschlüsselt in webhook_endpoints.secret_enc) und allowed_updates = message, callback_query.

Bei Telegram muss nichts kopiert werden. Voraussetzung ist die öffentliche Webhook-Basis-URL (HTTPS), die der Systemadmin unter System → Webhooks setzt; ohne sie schlägt der Test mit einer klaren Meldung fehl.

Routing: messaging_channels(provider = 'telegram', external_id = bot_id) → Mandant. Ein Bot gehört genau einer Organisation, sonst 409. Beim Entfernen der Verbindung wird das Routing freigegeben und deleteWebhook aufgerufen (best effort).

Webhook POST /api/v1/webhooks/telegram/{path_token}: prüft den Header X-Telegram-Bot-Api-Secret-Token gegen das gespeicherte Secret (401 sonst), antwortet sofort 202; der Celery-Task linetra.messaging.handle_telegram (Queue sync) verarbeitet den Rest. Idempotenz über messages.external_id = tg:{chat_id}:{message_id}. Nur private Chats werden verarbeitet; Gruppen, Kanäle und bearbeitete Nachrichten werden ignoriert.

Absender erkennen

Ein Bot sieht keine Telefonnummern. Die Identität entsteht über einen Einladungslink:

  • Verknüpft wird pro Mitarbeiter (App-User der Organisation, app_users.person_id → persons im Mandanten). POST /api/v1/users/{user_id}/telegram-invite liefert {url, expires_at} mit https://t.me/<bot>?start=<token>, 7 Tage gültig. Recht: Mitarbeiter verwalten (Organisationsadmin). Ohne getesteten Bot 400 („Connect and test a Telegram bot first“).
  • Der Token ist zustandslos, HMAC-SHA256-signiert mit SESSION_SECRET und an Organisation und Person gebunden (43 Zeichen). Er wird nie in Nachrichten gespeichert; der abgelegte Text ist nur /start.
  • Drückt die Person Starten, entsteht contact_identities(channel = 'telegram', external_id = chat_id) mit gesetztem opt_in_at; der Bot antwortet mit einer Begrüßung.
  • Ein Link übernimmt weder eine Person, die schon mit einem anderen Chat verknüpft ist, noch einen Chat, der zu einer anderen Person gehört. Abgelaufene oder gefälschte Links bekommen eine höfliche Antwort.
  • DELETE /api/v1/users/{user_id}/telegram (204) widerruft die Identität; der Chat ist wieder unbekannt, der Verlauf bleibt zugeordnet. ManagedUserOut.telegram_linked zeigt den Zustand in der Mitarbeiterliste.
  • Unbekannte Chats bekommen eine Standardantwort mit Hinweis auf den Einladungslink und landen als unbekannter Kontakt in der Inbox; es wird nichts verarbeitet.

Eingehender Text verknüpfter Kontakte läuft durch dieselbe Assistant-Pipeline wie bei WhatsApp (ADR-0061). Fotos, Sprachnachrichten und Dokumente werden mit kind image / audio / document und der Bildunterschrift als body abgelegt; der Medien-Download ist (wie bei WhatsApp) noch nicht angebunden.

Telegram kennt weder Templates noch ein 24-Stunden-Fenster; Texte über 4096 Zeichen werden geteilt. Der Bot kann nicht als Erster schreiben, bevor der Kontakt Starten gedrückt hat.

Jira einrichten

Org-Admin unter Einstellungen → Integrationen → Tab Jira → Jira verbinden. Gespeichert als integration_connections mit provider = 'jira'; der API-Token ist Fernet-verschlüsselt und write-only.

Feld Inhalt
base_url https://<site>.atlassian.net (ohne Pfad)
config.email Atlassian-E-Mail des Kontos
secret API-Token ohne Scopes

Speichern ruft POST /integrations/{id}/test auf. Der Test meldet sich per Basic Auth (E-Mail:Token) bei GET {base_url}/rest/api/3/myself an.

Projektbindung: projects.tracker_project_key (zum Beispiel LIN). Am Projekt, Tab Integration, speichert Aufgaben holen den Schlüssel und ruft POST /api/v1/projects/{id}/sync-tracker auf. Der Abruf liest POST {base_url}/rest/api/3/search/jql und schreibt die Vorgänge nach tracker_issues. Beat linetra.tracker.poll_all wiederholt das alle 15 Minuten für jedes Projekt mit Schlüssel. Ohne echtes Jira-Konto antwortet der Abruf mit einem Fehler. Betrieb → Aufgaben liest den Spiegel alle 15 Sekunden neu. Aktualisieren ruft denselben Sync auf, wenn die Leitung das Projekt bearbeiten darf und ein Schlüssel gesetzt ist; sonst nur den Spiegel.

Personen zuordnen: Tab Mitglieder. Die Jira-accountId des Zuständigen hängt an der Person über contact_identities(channel = 'jira'), nicht am Web-Login. Gleiche E-Mail, sonst ein eindeutig gleicher Name, wird beim Öffnen des Tabs verknüpft (wer Mitglieder verwalten darf) und noch einmal, wenn die Vorgänge geholt werden (automap_mirrored). Automatisch zuordnen macht dasselbe auf Knopfdruck. Was übrig bleibt, wählt die Leitung im Feld des Mitglieds. Die Verknüpfung gilt für die ganze Organisation. Schon gespiegelte Vorgänge bekommen sofort die assignee_person_id; ein Konto, das noch einer anderen Person gehört, wird nicht überschrieben. Die Auswahlliste kommt von GET /rest/api/3/user/assignable/search und von den Zuständigen der geholten Aufgaben.

Briefings und Feld-Rückmeldungen

Briefings (app/briefings/field.py): Beat linetra.briefings.send_daily alle 15 Minuten über alle Mandanten. Pro Projekt und Mitglied mit Rolle Feld (project_members.role = 'field', Opt-in) montags weekly, werktags daily, ab projects.briefing_time (Standard 06:30, Europe/Berlin). Der Text nennt das Projekt, damit jemand in mehreren Projekten weiß, wohin die Aufgaben gehören. Idempotent über briefing_runs. Kanal: Hat die Person einen verknüpften Telegram-Chat, gewinnt dieser gegenüber der aus der Telefonnummer abgeleiteten WhatsApp-Identität; sonst WhatsApp.

Projekt bestimmen (app/messaging/context.py, app/assistant/inbound.py, ADR-0069): Ein Telegram-Bot bzw. eine WhatsApp-Nummer je Mandant, Identitäten je Person; jede Unterhaltung gehört zu genau einem Projekt. Der Webhook legt die Nachricht zunächst vorläufig ab (WhatsApp nach Person gefiltert) und stößt linetra.assistant.handle_inbound(tenant_id, message_id) an. memberships_of lädt die Projektmitgliedschaften des Absenders, resolve_project_context wählt das Projekt in dieser Reihenfolge:

  1. Antwort auf eine offene Frage „Zu welchem Projekt gehört Deine Nachricht?“ (Nummer, Name oder project_code);
  2. Projektname oder project_code im Text;
  3. das einzige aktive Projekt des Absenders (project_status = 'active');
  4. das Projekt, in das Linetra innerhalb von REPLY_WINDOW (12 Stunden, conversations.last_outbound_at) geschrieben hat, z. B. ein Briefing;
  5. sonst schickt Linetra eine nummerierte Liste der Projekte. Die Nachricht wandert in eine Unterhaltung ohne Projekt mit Zustand awaiting_project (state_payload mit project_options und pending_message_ids) und wird verarbeitet, sobald der Absender gewählt hat; die Auswahl selbst („2“) ist keine Rückmeldung.

Danach wird die Nachricht in die Unterhaltung des Projekts verschoben. Unterhaltungen ohne Projekt gibt es nur noch für die Projektfrage. Weiter geht es je nach Rolle:

Absender Antwort
ohne Mitgliedschaft NO_MEMBERSHIP_REPLY („Du bist noch in keinem Projekt eingetragen …“)
nur Leitung oder Büro (has_no_tasks) Statusantwort über seine Projekte, ohne Projektfrage
im aufgelösten Projekt Leitung oder Büro Statusantwort für dieses Projekt
im aufgelösten Projekt Feld Feld-Assistent (Rückmeldungen, unten)

Rückmeldungen (app/assistant/field_report.py): Das LLM zerlegt die Nachricht in FieldReport.items (done / progress / problem, escalate_to = chef | buero); der Code wendet sie über das Policy-Gate an:

Punkt Aktion Gate-Standard
done transition → Done review (Demo-Seed setzt assistant_policy.transition = auto)
progress comment auto ab Konfidenz 0,7, sonst review
problem create_followup (neues Issue BOB-F<n>, Labels followup + Ziel) auto

Jedes problem legt zusätzlich eine notify_manager-Freigabe an. Das LLM (bzw. die Heuristik über BLOCKING_MARKERS) setzt pro Problem blocking: Stillstand → is_overdue = true (rote Ampel), sonst false (gelbe Ampel). Fehlt das Feld in der Modellantwort, gilt Stillstand.

Ampel (app/operations/status.py, GET /api/v1/operations/status): pro zugänglichem Projekt green | yellow | red plus threads (nur bei Gelb/Rot). Ein Thread ist eine offene notify_manager-Freigabe (Status open oder sent_back); erledigte, freigegebene oder abgelehnte Freigaben gelten als geklärt, auch wenn die Folgeaufgabe im Tracker noch offen ist. Rot = offene Freigabe mit is_overdue, Gelb = offene Freigabe ohne Stillstand. Projekt-Ampel = schlechtester Thread. GET /operations/review liefert ohne state offene und zurückgeschickte Freigaben (open, sent_back), damit Punkte mit Rückfrage schließbar bleiben. Das Badge im Betrieb-Menü (/operations/alerts/summary, help_count) zählt nur rote Freigaben. Die Antwort an den Kontakt geht über app/messaging/outbound.py an den Kanal der Unterhaltung (Telegram oder WhatsApp).

Leitungs-Alarm (app/assistant/leadership_alert.py): Enthält eine Rückmeldung mindestens ein problem mit blocking = true (rote Ampel), schickt notify_leadership eine Nachricht an jedes Mitglied des betroffenen Projekts mit Rolle Leitung (project_members.role = 'lead') außer dem Meldenden: Projekt, Meldender und je Punkt Notiz, betroffene Aufgabe, Ziel (Chef/Büro) und Folgeaufgabe. Org-Admins ohne Mitgliedschaft bekommen keinen Alarm. Kanal und Adresse liefert messaging.service.opted_in_address (Telegram vor WhatsApp, nur mit Opt-in, wie bei den Briefings). Die Nachricht steht in der Unterhaltung dieses Projekts; Antworten der Leitung gehen an die Statusauskunft (unten), nicht an den Feld-Assistenten. Folgeaufgaben (Chef/Büro) bleiben ohne Zuständigen. Aufgerufen in linetra.assistant.handle_inbound. WhatsApp-Freitext scheitert außerhalb des 24-Stunden-Fensters (status = failed), bis ein Template freigegeben ist.

Projektstand für die Leitung (app/assistant/leadership_status.py): Ist der Absender in allen Projekten nur Leitung oder Büro (has_no_tasks), antwortet linetra.assistant.handle_inbound ohne Projektauflösung; im aufgelösten Projekt gilt dasselbe für die Rollen Leitung und Büro. Diese Nachrichten laufen nie durch den Feld-Assistenten. leadership_reply erkennt Statusfragen über STATUS_MARKERS (ohne LLM) und antwortet pro aktivem Projekt des Absenders (project_status = 'active', eingeschränkt auf per Name/project_code genannte Projekte) mit der Ampel aus operations.status.project_statuses, Aufgabenzählern aus tracker_issues (heute erledigt nach tracker_updated_at in Europe/Berlin, offen, überfällig) und je offener Hilfe-Freigabe Melder, Notiz, Folgeaufgabe und Ziel. Andere Texte bekommen HELP_REPLY. Die Begrüßung nach dem Telegram-Link ist für Personen, die nur Leitung oder Büro sind, LEADERSHIP_WELCOME (messaging/telegram_inbound.py).

LLM: Der Anbieter kommt aus Einstellungen → System → KI-Agent (ADR-0067); OLLAMA_BASE_URL und ASSISTANT_MODEL_* sind nur noch Vorgaben für Ollama. Mit Ollama ruft OllamaProvider /api/chat mit JSON-Schema-format und dem Modell ASSISTANT_MODEL_FAST auf (Modell vorher ziehen, z. B. docker compose exec ollama ollama pull qwen3:8b). Ohne URL, oder wenn Ollama nicht antwortet, greift eine Schlüsselwort-Heuristik (app/assistant/heuristics.py) — gut genug für Demos, kein Ersatz.

Versand: load_tenant_messengers lädt pro Mandant die aktiven Backends (WhatsApp und Telegram). Jede Unterhaltung wird über ihren eigenen Kanal gesendet: WhatsAppCloudBackend bzw. das Telegram-Backend; ein Kanal ohne aktive Verbindung nutzt SimulatedMessenger (Status simulated, sichtbar in der Inbox). Bei WhatsApp gilt: Außerhalb des 24-Stunden-Fensters nach der letzten Nachricht des Kontakts lehnt Meta freien Text ab; die Briefings brauchen dafür später freigegebene Templates. Ein abgelehnter Versand landet als status = failed mit Fehlertext in messages.

Tests mit Datenbank brauchen einen Mandanten auf Head: TEST_TENANT_SLUG=<slug> pytest tests/test_field_briefings.py (läuft in einer Transaktion mit Rollback).

API (Kurz)

Route Zweck
/api/v1/integrations Verbindungen (Org-Admin); WhatsApp- und Telegram-Zeilen mit webhook_url
/api/v1/integrations/{id}/test Verbindung prüfen; Fehler landen in last_error (Antwort 200)
/api/v1/platform/whatsapp Meta-App-Status + Nummer → Organisation (Systemadmin)
/api/v1/platform/ai/providers Verbindungen von Ollama, Anthropic, OpenAI; Schlüssel nur schreibbar (Systemadmin, ADR-0067)
/api/v1/platform/ai/providers/{provider}/models Verbindung testen und verfügbare Modelle listen
/api/v1/platform/ai/models Angebotene Modelle aller Anbieter mit Name und Version; PUT /providers/{provider}/offered-models ersetzt die Liste eines Anbieters
/api/v1/platform/ai/agents Agenten mit Anbieter, Modell und Status; PUT .../{agent} ordnet zu
/api/v1/webhooks/jira/{token} Jira-Webhook
/api/v1/webhooks/whatsapp/{token} WhatsApp Verify + Events
/api/v1/webhooks/telegram/{token} Telegram-Updates (Header X-Telegram-Bot-Api-Secret-Token, Antwort 202)
/api/v1/users/{user_id}/telegram-invite POST: Einladungslink {url, expires_at} für Telegram (Mitarbeiter)
/api/v1/users/{user_id}/telegram DELETE: Telegram-Chat des Mitarbeiters trennen (204)
/api/v1/operations/status Ampel je zugänglichem Projekt mit offenen Hilferufen (Recht operations.review)
/api/v1/operations/tasks/summary Aufgaben-KPIs zugänglicher Projekte
/api/v1/operations/alerts/summary Badge help_count über zugängliche Projekte (project_id optional)
/api/v1/projects/{id}/jira-assignees Jira-Nutzer des Projekts und die Verknüpfung je Mitglied
/api/v1/projects/{id}/jira-assignees/automap Automatisch zuordnen (E-Mail, sonst eindeutiger Name)
/api/v1/projects/{id}/members/{membership_id}/jira-account PUT: Jira-Konto eines Mitglieds setzen oder leeren
/api/v1/operations/projects/{id}/issues Issue-Mirror; assignee_name, assignee_is_member (Zuständiger nicht im Projekt)
/api/v1/operations/inbox?project_id= Konversationen des Projekts (project_id Pflicht)
/api/v1/operations/inbox/messages?project_id= Nachrichten des Projekts (project_id Pflicht)
/api/v1/operations/review?project_id= Freigabe-Queue des Projekts (project_id Pflicht)
/api/v1/operations/agent-runs?project_id= Agent-Audit des Projekts (project_id Pflicht)
/api/v1/operations/reports?project_id= Tagesberichte des Projekts (project_id Pflicht)
UI: Primärnav Betrieb, Einstellungen → Integrationen, System → Webhooks, System → WhatsApp. Inbox, Freigaben, Berichte und Agent-Log arbeiten auf dem in der Kopfzeile gewählten Projekt und zeigen ohne Auswahl einen Hinweis. Projektmitglieder pflegt die Leitung (oder ein Org-Admin) unter Stammdaten → Projekte → Projekt bzw. Kontomenü → Projekte → Projekt.