22 KiB
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 wieGET /sessions(eigene Sessions, ausser Platform-Admin sieht alle).
Schlüssel-Dateien
| Datei / Bereich | Rolle |
|---|---|
platform-core/modules/features/teamsbot/ |
Gateway-seitiges Feature-Modul (inkl. dashboard/stream, Session-SSE, Module-CRUD) |
ui-nyla/src/pages/views/teamsbot/ |
Dashboard (SSE), Assistent, Module, Live-Session, Einstellungen |
ui-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.3–0.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:
- Wrapper-Init setzt
window.__audioCaptureEnabled = falseund legt einentrack-Listener pro PC an, der beifalseausschließlich Diagnostik loggt. - Erst nach
orchestrator._setState('in_meeting')ruft_enableTranscriptCapture→audioCaptureProcedure.startCapture()auf. startCapture()flippt__audioCaptureEnabled = trueund iteriertgetReceivers()aller PCs mitconnectionState === 'connected'. Für jede live Audio-Spur wird__audioCaptureAttachTrack(pc, track)ausgeführt (AudioContext +track.clone()+MediaStreamSource+ AudioWorklet/ScriptProcessor → PCM16 16 kHz Mono).- Spätere
track-Events bauen ihren Audio-Graph automatisch, sobald die zugehörige PCconnectedist.
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:
- der echte Meeting-Chat-Toggle: UUID-id,
aria-label="Chat (Ctrl+Shift+2)",aria-pressed="true|false"— toggelt das Meeting-Chat-Side-Panel. - Sidebar-Navigation "Chats":
aria-label="Chats "(mit Leerzeichen), hataria-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. - 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]derenid/data-tid/aria-label/titleeinen der Hintschat/unterhalt/besprechung/conversationenthä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 obaria-presseddes 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:
- Existenz + echte Visibility:
offsetWidth/Height > 0 && offsetParent !== null. - 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:
- Der anonyme Bot wird trotz "Jeder umgeht die Lobby"-Setting in die Lobby gezwungen.
- 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-
contentHintvon'motion'auf'detail'umgestellt — sagt dem WebRTC-Encoder "statisch", er reduziert Bitrate und behält Textschärfe. - Farben werden vom
audioProcedure.tsan den Init-Script-Payload durchgereicht und in_startBotAvatarStream()alsfillStylefü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 perdrawImage()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_TEAMSbleibt der Default-Pfad mit niedrigster Latenz. Der dazugehörige System-Prompt erlaubt dem Modell explizit,needsAgent=true+agentReasonzu setzen, wenn die Anfrage Web-Recherche, Mail oder Multi-Step-Tools erfordert.- Director Prompts umgehen
SPEECH_TEAMSkomplett und feuern direkt einenrunAgent-Lauf, dessenFINAL-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 | ui-nyla/src/pages/views/teamsbot/TeamsbotSessionView.tsx + Teamsbot.module.css |
| Frontend API-Wrapper | ui-nyla/src/api/teamsbotApi.ts (submitDirectorPrompt, listDirectorPrompts, deleteDirectorPrompt) |
| Tests | platform-core/tests/unit/teamsbot/test_directorPrompts.py (26 Tests, AC 5 + 6 abgedeckt; AC 1–4 manuell live) |