wiki/b-reference/teams-bot/architecture.md
2026-05-12 21:31:27 +02:00

22 KiB
Raw Blame History

Teams Meeting Bot -- Architektur

Überblick

AI-gesteuerter Meeting-Bot für Microsoft Teams. Tritt Meetings als regulärer Teilnehmer bei (Browser-Automation via Playwright/Chromium), erfasst Live-Transkripte, reagiert per Sprache (TTS) und/oder Chat. Kein Teams-Graph-SDK nötig -- funktioniert mandantenübergreifend ohne Admin-Approval.

System-Architektur

┌────────────┐      SSE       ┌──────────────┐    WebSocket    ┌─────────────┐
│  Frontend   │◄──────────────│   Gateway     │◄───────────────│  Bot Service │
│  (Nyla UI)  │               │  (AI, TTS,    │   HTTP (join/  │  (Playwright │
│             │               │   Sessions)   │    leave)      │   Chromium)  │
└────────────┘               └──────────────┘               └─────────────┘
Verbindung Protokoll Zweck
Gateway ↔ Bot WebSocket Echtzeit: Transkripte, Chat, Audio, Status
Gateway → Bot HTTP Session-Steuerung (join, leave, status)
Frontend ← Gateway SSE Live-Transkript-Stream für UI; Dashboard-Push (dashboard/stream)

Nyla UI (MeetingModule-IA)

Die Feature-Oberfläche ist in fünf Tabs strukturiert (siehe auch wiki/c-work/2-build/2026-04-teamsbot-greenfield-ia-and-live-update.md):

Tab Route-Segment Zweck
Dashboard dashboard KPIs, Modul-Aktivität, Quick-Actions; Daten per SSE GET /api/teamsbot/{instanceId}/dashboard/stream (Snapshots sessions + modules, Intervall 3 s bei aktiver eigener Sitzung, sonst 20 s)
Assistent assistant Wizard: Modul wählen/anlegen, Meeting-Link, Join-Modus (Systembot / Gast / Mein Account), optional Sitzungskontext → startet Session und navigiert zur Live-Ansicht
Module modules CRUD TeamsbotMeetingModule, aufklappbare Session-Liste pro Modul; Deep-Link ?moduleId=<uuid> klappt das Modul auf und scrollt es ins Sichtfeld
Live-Session sessions Regie-Panel, UDB, Transkript, SSE sessions/{id}/stream; MFA (mfaChallenge / mfaResolved) wird hier über dieselbe SSE-Verbindung abgewickelt
Einstellungen settings Bot-Stimme, User-Settings, System-Bots (SysAdmin)

Datenmodell: TeamsbotMeetingModule gruppiert Reihen; TeamsbotSession.moduleId verknüpft optional (Adhoc ohne Modul). Standard-Meeting-Link und Standard-Bot-Name pro Modul für Prefill im Assistenten.

Kernfähigkeiten

  • Live Transcription: Erfasst Untertitel mit Sprecher-Zuordnung, streamt via SSE
  • AI-Analyse: Transkript-Segmente werden durch AI-Modell (GPT-4o-mini / Claude) analysiert
  • Voice Response: TTS-Audio wird über den Mikrofon-Kanal ins Meeting gespielt
  • Chat Response: Bot kann Chat-Nachrichten ins Meeting schreiben
  • Multi-Session: Mehrere Bot-Instanzen parallel in verschiedenen Meetings

Use Cases

UC Beschreibung
AI Meeting Assistant Bot nimmt teil, hört zu, antwortet auf Ansprache ("Hey Nyla, ...")
Live Transcription Echtzeit-Transkript-Stream für Teilnehmer ausserhalb des Meetings
Meeting Summary AI-generierte Zusammenfassung nach Meeting-Ende
Multi-Bot Mehrere parallele Sessions in verschiedenen Meetings
Director Prompts Operator gibt dem laufenden Bot private Regieanweisungen (One-Shot oder Persistent), Antwort wird ins Meeting eingespielt
Hybrid Agent Escalation SPEECH_TEAMS-Pfad kann komplexe Anfragen via needsAgent=true an den vollen Agent (agentService.runAgent) eskalieren

Integration mit Gateway

Der Gateway (Feature teamsbot) verwaltet Sessions und stellt die AI-Pipeline bereit:

  • Session-Lifecycle: erstellen, starten, stoppen
  • WebSocket-Verbindung pro Session
  • AI-Analyse der Transkript-Segmente via serviceAi
  • TTS-Generierung für Voice-Responses
  • Dashboard-SSE: GET /api/teamsbot/{instanceId}/dashboard/stream — wiederholte JSON-Events { "type": "dashboardState", "sessions": [...], "modules": [...] } mit gleicher Sichtbarkeit wie GET /sessions (eigene Sessions, ausser Platform-Admin sieht alle).

Schlüssel-Dateien

Datei / Bereich Rolle
gateway/modules/features/teamsbot/ Gateway-seitiges Feature-Modul (inkl. dashboard/stream, Session-SSE, Module-CRUD)
frontend_nyla/src/pages/views/teamsbot/ Dashboard (SSE), Assistent, Module, Live-Session, Einstellungen
frontend_nyla/src/api/teamsbotApi.ts u.a. createDashboardStream, createSessionStream, Module-API
service-teams-browser-bot/ Eigenständiger Bot-Service (separates Repository)

Regeln / Invarianten

  • Bot tritt als regulärer Web-Teilnehmer bei (Browser-Automation), nicht via Graph Communications SDK
  • Jede Session läuft in einer eigenen Browser-Instanz (Isolation)
  • Authentifizierter Join (mit Microsoft-Account) oder Anonymous Guest -- je nach Konfiguration
  • Gateway ist die einzige Schnittstelle für AI-Aufrufe und TTS -- der Bot-Service selbst hat keine AI-Logik

STT auf dem Gateway: Meeting-Audio-Chunks (WebSocket audioChunk, PCM) werden pro Chunk mit VoiceObjects.speechToText transkribiert (Batch recognize, gemeinsamer Connector mit CommCoach). Konfiguration u. a. audioFormat=linear16, skipFallbacks=True; Details und Modell-Defaults: voice-google.md.

Sprache ist single-language. Die STT-Sprache ist die Sessionsprache (TeamsbotUserSettings.language überschreibt TeamsbotConfig.language, Schema-Default de-DE). Es gibt keine hardcodierten Alternativ-Sprachen — frühere alternative_language_codes=["en-US"]-Kombi liess Google STT bei verrauschter Audio (z.B. nach langem Bot-TTS-Playback mit minimalem akustischem Loopback) auf en-US springen und englisches Kauderwelsch zurückgeben (Confidences 0.30.5), während saubere Sprache korrekt als de-DE mit ~0.9+ erkannt wurde. Falls mehrsprachige Meetings gebraucht werden: connectorVoiceGoogle.speechToText akzeptiert alternativeLanguages: list[str]; eine entsprechende Konfig-Spalte (z.B. TeamsbotConfig.alternativeLanguages) müsste explizit eingeführt werden.

Audio-Capture: WebRTC-Wrapper-Gating

Der Bot installiert einen RTCPeerConnection-Wrapper per addInitScript (Browser-Start, vor jeder Teams-Navigation), damit später keine PC unbeobachtet bleibt. Während Pre-Join, Lobby und SDP-Renegotiation darf der Wrapper aber NICHTS am Audio-Stream anfassen — kein clone(), kein createMediaStreamSource(), kein AudioContext. Jeder Eingriff in dieser Phase löst in Teams' aktuellem light-meetings-Bundle den Renderer-Crash Cannot read properties of null (reading 'rejectMediaDescriptionsUpdateAsync') aus und der anonyme Bot landet entweder dauerhaft in der Lobby oder wird wieder zur Pre-Join-Seite geworfen.

Mechanik:

  1. Wrapper-Init setzt window.__audioCaptureEnabled = false und legt einen track-Listener pro PC an, der bei false ausschließlich Diagnostik loggt.
  2. Erst nach orchestrator._setState('in_meeting') ruft _enableTranscriptCaptureaudioCaptureProcedure.startCapture() auf.
  3. startCapture() flippt __audioCaptureEnabled = true und iteriert getReceivers() aller PCs mit connectionState === 'connected'. Für jede live Audio-Spur wird __audioCaptureAttachTrack(pc, track) ausgeführt (AudioContext + track.clone() + MediaStreamSource + AudioWorklet/ScriptProcessor → PCM16 16 kHz Mono).
  4. Spätere track-Events bauen ihren Audio-Graph automatisch, sobald die zugehörige PC connected ist.

Es wird keine Label-Filterung angewendet — die Tracks haben je nach Session/Layout entweder mainAudio-<n> oder UUID-Labels. Der einzige saubere Trigger ist der Bot-State.

Refs: service-teams-browser-bot/src/bot/audioCaptureProcedure.ts (__audioCaptureEnabled, __audioCaptureAttachTrack, startCapture); Aufrufer orchestrator.ts (_attemptJoin STEP 4 → _setState('in_meeting')_enableTranscriptCapture).

Chat-Panel-Toggle-Auswahl im Auth-Layout

Im authentifizierten Full-Teams-Layout existieren MEHRERE sichtbare Buttons mit "Chat" im aria-label. Kritisch ist die Unterscheidung:

  1. der echte Meeting-Chat-Toggle: UUID-id, aria-label="Chat (Ctrl+Shift+2)", aria-pressed="true|false" — toggelt das Meeting-Chat-Side-Panel.
  2. Sidebar-Navigation "Chats": aria-label="Chats " (mit Leerzeichen), hat aria-pressed — klickt man diesen, navigiert der Bot weg vom Meeting zur Teams-Chat-Sektion. Das Meeting wird als PiP minimiert, alle PeerConnections schließen → Video/Audio bricht ab.
  3. Weitere Buttons: More chat options (Menü), tab-item-com.microsoft.chattabs.* (Tabs im Chat-Panel), chat-join-button, chat-header-participant-count.

In light-meetings (anon) existiert nur #chat-button und der IST der echte Toggle (light-meetings nutzt menu-button-Semantik ohne aria-pressed).

_openChatPanel() löst das so:

  • Sammelt alle sichtbaren button / [role=button] / [role=menuitem] deren id/data-tid/aria-label/title einen der Hints chat/unterhalt/besprechung/conversation enthält (sprachunabhängig).
  • Filtert gefährliche Buttons aus (isDangerousNavButton): Sidebar-Navigation (aria="Chats"/"Unterhaltungen"), Tab-Items (tab-item-*), Submenu-Triggers (More chat options), Participant-Count, Chat-Join-Button, App-Bar-Kinder.
  • Bevorzugt Kandidaten mit aria-pressed ∈ {true,false} (echte Toggle-Buttons), Fallback ist der erste passende Nicht-Toggle.
  • Trackt geklickte Buttons per id|data-tid|aria-label-Key und überspringt sie in den nächsten Runden.
  • Nach Toggle-Klick: wenn _isChatPanelOpen() DOM-basiert fehlschlägt, prüft Fallback ob aria-pressed des geklickten Toggles jetzt "true" ist → Panel gilt als offen (auth-mode Fallback).

Refs: service-teams-browser-bot/src/bot/chatProcedure.ts._openChatPanel.

Chat-Panel-Detection

_isChatPanelOpen() macht zwei Strategie-Checks:

Strategie 1 (light-meetings + standard): Check am [data-tid="calling-right-side-panel"]-Container:

  1. Existenz + echte Visibility: offsetWidth/Height > 0 && offsetParent !== null.
  2. Mode-Disambiguation per chat-spezifischen Child-Tids: message-pane-layout, message-pane-body, chat-pane-compose-message-footer, message-pane-footer, #chat-pane-list, [data-app-name="chats"].

Strategie 2 (auth full-Teams Fallback): Im vollen Teams-Web-Client existiert calling-right-side-panel möglicherweise nicht oder hat ein anderes Layout. Stattdessen wird geprüft ob ein Toggle-Button mit Keyboard-Shortcut im Label (Chat (Ctrl+...) / Chat (Strg+...)) aria-pressed="true" hat. Dies erkennt den geöffneten Zustand zuverlässig auch wenn der DOM-Container anders strukturiert ist.

Trapdoor: vdi-occlusion

Die CSS-Klasse vdi-occlusion taucht in der neuen Calling-Layout-Version als Permanent-Klasse auf calling-right-side-panel UND message-pane-layout auf — auch wenn das Panel sichtbar geöffnet ist. Sie ist KEIN Visibility-Indikator. Ein älterer Detection-Code, der vdi-occlusion → "Panel zu" ableitete, lieferte deshalb immer false-negative bei offenem Panel → periodischer Scan triggerte _openChatPanel() → klickte den Toggle → schloss das Panel echt → klickte weitere chat-aria Buttons (Side-Nav-Apps "Chats"/"Meeting chats", Tab-Items tab-item-com.microsoft.chattabs.*) → Endlos-Toggle-Loop mit UPR-Page-Errors von Teams. Visibility wird ausschließlich über offsetWidth/Height > 0 && (offsetParent !== null || position === fixed) geprüft.

Toggle-Button selbst trägt KEINE State-Information

Im neuen Calling-Layout ist der <button id="chat-button" aria-label="Chat"> byte-identisch in beiden Zuständen — kein aria-pressed, kein data-state, identische CSS-Klassen. Die optische "blaue Linie unten" beim offenen Zustand kommt aus einem CSS-:has()-Selektor der vom Vorhandensein des offenen Side-Panels abhängt, nicht vom Button-State. Detection muss daher zwingend über den Container, nicht den Button laufen.

Refs: service-teams-browser-bot/src/bot/chatProcedure.ts._isChatPanelOpen.

Browser-Channel: echtes Chrome für anonyme Joins

Anonyme Joins müssen mit dem lokal installierten echten Chrome laufen (channel: 'chrome'), nicht mit Playwrights gebündeltem Chromium. Teams' light-meetings-Pfad fingerprintet den Browser (vermutlich Sec-CH-UA Client-Hints + Canvas/WebGL-Signale + fehlende Chrome-Marken-Identität); das Bundled-Chromium fällt durch und Teams reagiert mit zwei Konsequenzen:

  1. Der anonyme Bot wird trotz "Jeder umgeht die Lobby"-Setting in die Lobby gezwungen.
  2. Während dieser Lobby-Phase läuft ein Preheating-PC-Codepfad, der mit Cannot read properties of null (reading 'rejectMediaDescriptionsUpdateAsync') crasht und den Bot auf die "Sorry, we couldn't connect you"-Seite wirft.

Der manuelle Inkognito-Test im echten Chrome derselben Maschine geht beide Symptome nicht — er joint ohne Lobby. Das beweist, dass es kein generelles Teams-Problem für anonyme Gäste ist, sondern den Playwright-Chromium-Fingerprint trifft.

Der authentifizierte Pfad funktioniert auch mit Bundled-Chromium, weil eine echte MS-Login-Session den Anti-Bot-Check überspringt.

Konfiguration: BOT_BROWSER_CHANNEL=chrome in .env, gelesen in src/config.ts als botBrowserChannel, an chromium.launch({ ..., channel }) durchgereicht in src/bot/orchestrator.ts._launchBrowser(). Leer = Bundled Chromium (Default, geeignet für Auth-Joins / Tests). chrome und msedge setzen den lokalen Real-Browser ein; auf den Bot-Hosts muss Chrome bzw. Edge installiert sein (auf den Standard-Chrome-Pfaden, die Playwright kennt).

Container-Deployment: Das Microsoft Playwright Base-Image (mcr.microsoft.com/playwright:v1.50.0-jammy) liefert nur das gebündelte Chromium mit. Das Dockerfile installiert deshalb explizit das echte Google Chrome via npx playwright install --with-deps chrome (Playwright legt Chrome an einen ihm bekannten Pfad ab + apt-installiert alle System-Deps); docker-compose.yml setzt BOT_BROWSER_CHANNEL=chrome als Default-Env. Auf reinen VMs reicht ein OS-natives apt-get install -y google-chrome-stable aus dem Google-Repo, sofern die Chrome-Binary unter /usr/bin/google-chrome[-stable] landet.

Bisect-Historie 2026-05-12 (Diagnose-Bypässe bleiben als undokumentierte Debug-Schalter im Code, falls erneut nötig):

  • BOT_DISABLE_MEDIA_WRAPPERS=true → Crash blieb. ⇒ Wrapper unschuldig.
  • BOT_ANON_USE_AUTH_BROWSER_SETUP=true (= minimale Chromium-Args + kein Stealth-Init) → Crash blieb. ⇒ weder Anon-Args noch Stealth-Properties sind die Ursache.
  • BOT_BROWSER_CHANNEL=chrome → Lobby weg, Crash weg, Audio-Capture läuft sauber. ⇒ root cause ist der Browser-Fingerprint.

Statisches Avatar-Video (ersetzt grünen Lade-Spinner)

Wenn der Bot kein Video sendet, rendert Teams für andere Teilnehmer einen eigenen Platzhalter — eine grüne Fläche mit Initialen + Lade-Spinner, die nie zur Ruhe kommt. Mit BOT_USE_CANVAS_VIDEO=true schiebt der Bot stattdessen einen ruhigen, statischen Canvas-Stream als Video-Track in die WebRTC-Sender — eine einfarbige Fläche mit dem Anzeigenamen mittig, ohne Animation, ohne Branding.

Konfiguration (alle drei Env-Variablen optional, alles in src/config.ts):

Env Default Bedeutung
BOT_USE_CANVAS_VIDEO false Schaltet das statische Avatar-Video ein. Empfohlen für den anonymen Bot.
BOT_AVATAR_BG_COLOR #a8d4f0 (hellblau) CSS-Farbe der Hintergrundfläche.
BOT_AVATAR_TEXT_COLOR #1a3552 (dunkelblau) CSS-Farbe des zentrierten Anzeigenamens.

Implementierung in src/bot/mediaGetUserMediaPatch.ts:

  • Canvas wird einmalig erzeugt (640×360), draw() zeichnet bei jedem Tick identische Pixel — keine Animation, kein Pulse, kein Gradient.
  • Tick-Rate auf 2 fps gesenkt (vorher 15 fps Animation). Der Tick bleibt nötig, weil captureStream() in headless Chromium den Track sonst pausiert; Inhalt ist aber konstant, daher entstehen keine Frame-Diffs auf dem Encoder.
  • Video-Track-contentHint von 'motion' auf 'detail' umgestellt — sagt dem WebRTC-Encoder "statisch", er reduziert Bitrate und behält Textschärfe.
  • Farben werden vom audioProcedure.ts an den Init-Script-Payload durchgereicht und in _startBotAvatarStream() als fillStyle für Hintergrund + Label verwendet.

Benutzerdefiniertes Avatar-Bild / Video: Benutzer können in den Bot-Einstellungen (TeamsbotConfig.avatarFileId) oder pro Modul (TeamsbotMeetingModule.defaultAvatarFileId) eine Datei aus dem Dateisystem auswählen (Bilder: PNG, JPG, etc.; Videos: MP4, WebM, etc.). Modul-Einstellung überschreibt Instanz-Default. Beim Session-Start löst das Gateway die Datei auf, lädt sie als Base64 und sendet sie im Join-Payload (avatarMediaData + avatarMediaType) an den Bot. Der Bot rendert:

  • Bilder: drawImage() auf den Canvas (statisch, 2 fps).
  • Videos: Unsichtbares <video>-Element mit Loop, Frames werden per drawImage() auf den Canvas gezeichnet (15 fps, contentHint='motion').
  • Kein File gesetzt: Fallback auf statische Farbfläche + Bot-Name (bisheriges Verhalten).

Der Auth-Bot ist von der Änderung nur betroffen, wenn BOT_USE_CANVAS_VIDEO=true gesetzt ist; sonst sendet er weiterhin gar kein Video.

Hybrid-Routing: SPEECH_TEAMS + Agent

Der Teamsbot läuft auf zwei kooperierenden Pfaden:

flowchart LR
    Audio["Meeting Audio"] --> STT["STT"]
    STT --> ST["SPEECH_TEAMS<br/>(fast, low-latency)"]
    ST -->|shouldRespond=true| TTS["TTS + Chat"]
    ST -->|needsAgent=true| Agent["agentService.runAgent<br/>(toolSet=core, web=on,<br/>maxRounds=5, maxCostCHF=0.10)"]
    Agent -->|FINAL text| TTS
    UI["Operator UI<br/>(Regie-Panel + UDB)"] -->|POST directorPrompt| Route["routeFeatureTeamsbot"]
    Route -->|submitDirectorPrompt| Svc["TeamsbotService<br/>(_activeServices)"]
    Svc -->|asyncio.create_task| Agent
    TTS --> Meeting
    Svc -->|SSE 'directorPrompt'| UI
  • SPEECH_TEAMS bleibt der Default-Pfad mit niedrigster Latenz. Der dazugehörige System-Prompt erlaubt dem Modell explizit, needsAgent=true + agentReason zu setzen, wenn die Anfrage Web-Recherche, Mail oder Multi-Step-Tools erfordert.
  • Director Prompts umgehen SPEECH_TEAMS komplett und feuern direkt einen runAgent-Lauf, dessen FINAL-Event-Text wieder über die bestehenden TTS-/Chat-Kanäle ins Meeting geliefert wird.

Director Prompts (private Operator-Anweisungen)

Operator-Prompts sind privat (nur per SSE an den Session-Owner sichtbar) und werden in PostgreSQL gespeichert (poweron_teamsbot.TeamsbotDirectorPrompt).

Modus Verhalten
oneShot Einmaliger Agent-Lauf, danach Status consumed
persistent Agent-Lauf wird ausgeführt und der Text wird als OPERATOR_DIRECTIVES-Block in jeden folgenden SPEECH_TEAMS-Trigger eingemischt, bis der Operator den Prompt löscht
Lifecycle-Status Bedeutung
queued Eingereicht, Agent noch nicht gestartet
running Agent läuft
succeeded Agent fertig, persistent bleibt aktiv
consumed One-Shot abgeschlossen oder persistent gelöscht
failed Agent-Lauf fehlgeschlagen, persistent wird automatisch aus aktiven Direktiven entfernt

Persistenz beim Reconnect: Bei jedem WebSocket-Reconnect ruft der Service interface.getActivePersistentPrompts(sessionId) auf und füllt _activePersistentPrompts neu, damit Direktiven Network-Drops überleben.

Limits: DIRECTOR_PROMPT_TEXT_LIMIT = 8000 Zeichen, DIRECTOR_PROMPT_FILE_LIMIT = 10 UDB-Dateien (Pflicht-Prüfung in der Route, validiert auch von TeamsbotDirectorPromptCreateRequest).

RBAC: Routes prüfen _validateInstanceAccess + _validateSessionOwnership. Ein Nicht-Owner sieht 404, niemals 403, um die Existenz der Session nicht preiszugeben.

Rate-Limit: 30/minute pro Operator (slowapi).

Schicht-Trennung (Plan #5 abgeschlossen 2026-04-24)

Verantwortung Datei
Persistenz + Lifecycle interfaceFeatureTeamsbot.py (createDirectorPrompt, getActivePersistentPrompts, updateDirectorPrompt, deleteDirectorPrompt)
Orchestrierung + Agent-Lauf + SSE service.py (submitDirectorPrompt, _processDirectorPrompt, _runAgentForMeeting, _buildPersistentDirectorContext, removePersistentPrompt)
HTTP + RBAC + Limits routeFeatureTeamsbot.py (POST/GET/DELETE /sessions/{id}/directorPrompts)
Frontend Regie-Panel + UDB-Sidebar + SSE-Listener frontend_nyla/src/pages/views/teamsbot/TeamsbotSessionView.tsx + Teamsbot.module.css
Frontend API-Wrapper frontend_nyla/src/api/teamsbotApi.ts (submitDirectorPrompt, listDirectorPrompts, deleteDirectorPrompt)
Tests gateway/tests/unit/teamsbot/test_directorPrompts.py (26 Tests, AC 5 + 6 abgedeckt; AC 14 manuell live)