From dced74766640969ff66df44646b75efecac58cdd Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 13 Feb 2026 22:55:00 +0100 Subject: [PATCH] Add Gateway WebSocket integration and CI/CD pipeline Co-authored-by: Cursor --- .github/workflows/build-deploy.yml | 69 ++++++++++ AZURE_SETUP.md | 200 +++++++++++++++++++++++++++++ src/bot/orchestrator.ts | 161 ++++++++++++++++++++++- src/index.ts | 4 +- src/server/httpServer.ts | 6 +- src/sessionManager.ts | 79 ++++-------- 6 files changed, 452 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/build-deploy.yml create mode 100644 AZURE_SETUP.md diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..28540e6 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,69 @@ +name: Build and Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + AZURE_CONTAINER_APP_NAME: teams-browser-bot + AZURE_RESOURCE_GROUP: rg-poweron-int + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix= + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Azure Login + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Deploy to Azure Container Apps + uses: azure/container-apps-deploy-action@v1 + with: + resourceGroup: ${{ env.AZURE_RESOURCE_GROUP }} + containerAppName: ${{ env.AZURE_CONTAINER_APP_NAME }} + imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/AZURE_SETUP.md b/AZURE_SETUP.md new file mode 100644 index 0000000..9fcdac1 --- /dev/null +++ b/AZURE_SETUP.md @@ -0,0 +1,200 @@ +# Azure Setup für Teams Browser Bot + +## Voraussetzungen + +1. Azure CLI installiert und eingeloggt +2. GitHub Repository erstellt unter `valueonag/service-teams-browser-bot` + +--- + +## 1. Azure Container App erstellen + +### 1.1 Resource Group (falls nicht vorhanden) + +```bash +az group create \ + --name rg-poweron-int \ + --location westeurope +``` + +### 1.2 Container Apps Environment + +```bash +az containerapp env create \ + --name cae-poweron-int \ + --resource-group rg-poweron-int \ + --location westeurope +``` + +### 1.3 Container App erstellen + +```bash +az containerapp create \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --environment cae-poweron-int \ + --image ghcr.io/valueonag/service-teams-browser-bot:latest \ + --target-port 4100 \ + --ingress external \ + --cpu 2 \ + --memory 4Gi \ + --min-replicas 0 \ + --max-replicas 3 \ + --env-vars \ + NODE_ENV=production \ + PORT=4100 \ + GATEWAY_WS_URL=wss://gateway-int.poweron-center.net/api/teamsbot/bot/ws \ + BOT_NAME="PowerOn AI" \ + BOT_HEADLESS=true \ + LOG_LEVEL=info \ + SCREENSHOT_ON_ERROR=true +``` + +### 1.4 Container App URL notieren + +```bash +az containerapp show \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --query properties.configuration.ingress.fqdn \ + --output tsv +``` + +Ergebnis z.B.: `teams-browser-bot.happysky-12345.westeurope.azurecontainerapps.io` + +--- + +## 2. GitHub Actions Setup + +### 2.1 Azure Service Principal erstellen + +```bash +az ad sp create-for-rbac \ + --name "github-teams-browser-bot" \ + --role contributor \ + --scopes /subscriptions//resourceGroups/rg-poweron-int \ + --sdk-auth +``` + +### 2.2 GitHub Secret hinzufügen + +1. GitHub Repo → Settings → Secrets and variables → Actions +2. New repository secret: `AZURE_CREDENTIALS` +3. Wert: JSON Output vom vorherigen Befehl + +### 2.3 GitHub Container Registry Zugriff + +Der Workflow verwendet `GITHUB_TOKEN` automatisch für ghcr.io. + +Falls Azure die Images nicht pullen kann: + +```bash +# PAT mit read:packages Scope erstellen auf GitHub +# Dann in Azure: +az containerapp registry set \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --server ghcr.io \ + --username \ + --password +``` + +--- + +## 3. Gateway Konfiguration + +### 3.1 Environment Variable im Gateway + +In `env_int.env` (oder Azure App Service Configuration): + +``` +TEAMSBOT_BROWSER_BOT_URL=https://teams-browser-bot.happysky-12345.westeurope.azurecontainerapps.io +``` + +### 3.2 Gateway neu deployen + +```bash +# Push to int branch triggers deployment +git push origin int +``` + +--- + +## 4. DNS (Optional) + +Falls du eine eigene Domain verwenden möchtest: + +```bash +az containerapp hostname add \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --hostname bot.poweron.swiss + +# Dann DNS A-Record oder CNAME auf die Container App zeigen +``` + +--- + +## 5. Monitoring + +### Logs anzeigen + +```bash +az containerapp logs show \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --follow +``` + +### Metriken + +```bash +az containerapp show \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --query properties.latestRevisionFqdn +``` + +--- + +## 6. Kosten + +Azure Container Apps (Consumption Plan): +- **vCPU**: ~$0.000024/vCPU-second +- **Memory**: ~$0.000003/GiB-second +- **Requests**: Erste 2M/Monat kostenlos + +Geschätzte Kosten bei 10h Bot-Nutzung/Tag: +- ~$15-25/Monat (deutlich günstiger als die alte VM!) + +--- + +## 7. Troubleshooting + +### Container startet nicht + +```bash +# Logs prüfen +az containerapp logs show \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --type system + +# Revision Status +az containerapp revision list \ + --name teams-browser-bot \ + --resource-group rg-poweron-int \ + --output table +``` + +### Playwright/Chrome Probleme + +Container Apps unterstützen keine GPU. Falls Chrome-Probleme: +1. Sicherstellen dass `BOT_HEADLESS=true` +2. Shared memory erhöhen (im Dockerfile bereits konfiguriert) + +### WebSocket Verbindung fehlschlägt + +1. Prüfen ob Gateway CORS erlaubt +2. Prüfen ob Container App WebSockets unterstützt (Standard: ja) +3. Gateway Logs prüfen diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index 242ceb7..ca6c5ed 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -3,10 +3,11 @@ import { Logger } from 'winston'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; import fs from 'fs'; +import WebSocket from 'ws'; import { config } from '../config'; import { createSessionLogger } from '../utils/logger'; -import { BotSession, BotState, TranscriptEntry } from '../types'; +import { BotSession, BotState, TranscriptEntry, StatusMessage, TranscriptMessage, PlayAudioMessage } from '../types'; import { JoinProcedure } from './joinProcedure'; import { CaptionsProcedure } from './captionsProcedure'; import { AudioProcedure } from './audioProcedure'; @@ -18,12 +19,19 @@ export interface OrchestratorCallbacks { onError: (error: Error) => void; } +export interface OrchestratorOptions { + gatewayWsUrl: string; + instanceId: string; +} + /** * Orchestrates the entire bot lifecycle: + * - Connects to Gateway via WebSocket * - Launches browser * - Joins meeting * - Enables captions - * - Handles audio playback + * - Sends transcripts to Gateway + * - Handles audio playback from Gateway * - Leaves meeting */ export class BotOrchestrator { @@ -32,10 +40,12 @@ export class BotOrchestrator { private _botName: string; private _logger: Logger; private _callbacks: OrchestratorCallbacks; + private _options: OrchestratorOptions; private _browser: Browser | null = null; private _context: BrowserContext | null = null; private _page: Page | null = null; + private _gatewayWs: WebSocket | null = null; private _joinProcedure: JoinProcedure | null = null; private _captionsProcedure: CaptionsProcedure | null = null; @@ -48,12 +58,14 @@ export class BotOrchestrator { sessionId: string, meetingUrl: string, botName: string, - callbacks: OrchestratorCallbacks + callbacks: OrchestratorCallbacks, + options: OrchestratorOptions ) { this._sessionId = sessionId; this._meetingUrl = meetingUrl; this._botName = botName || config.botName; this._callbacks = callbacks; + this._options = options; this._logger = createSessionLogger(sessionId); } @@ -66,7 +78,7 @@ export class BotOrchestrator { } /** - * Start the bot - launch browser, join meeting, enable captions. + * Start the bot - connect to Gateway, launch browser, join meeting, enable captions. */ async start(): Promise { if (!isValidMeetingUrl(this._meetingUrl)) { @@ -76,6 +88,9 @@ export class BotOrchestrator { try { this._setState('launching'); + // Connect to Gateway WebSocket first + await this._connectToGateway(); + // Launch browser await this._launchBrowser(); @@ -115,7 +130,118 @@ export class BotOrchestrator { } /** - * Stop the bot - leave meeting, close browser. + * Connect to the Gateway WebSocket for this session. + */ + private async _connectToGateway(): Promise { + const wsUrl = `${this._options.gatewayWsUrl}/${this._options.instanceId}/bot/ws/${this._sessionId}`; + this._logger.info(`Connecting to Gateway: ${wsUrl}`); + + return new Promise((resolve, reject) => { + this._gatewayWs = new WebSocket(wsUrl); + + this._gatewayWs.on('open', () => { + this._logger.info('Connected to Gateway'); + resolve(); + }); + + this._gatewayWs.on('message', (data) => { + this._handleGatewayMessage(data.toString()); + }); + + this._gatewayWs.on('close', (code, reason) => { + this._logger.warn(`Gateway connection closed: ${code} - ${reason}`); + if (!this._isShuttingDown) { + this._setState('error', 'Gateway connection lost'); + } + }); + + this._gatewayWs.on('error', (error) => { + this._logger.error('Gateway WebSocket error:', error); + reject(error); + }); + + // Timeout after 10 seconds + setTimeout(() => { + if (this._gatewayWs?.readyState !== WebSocket.OPEN) { + reject(new Error('Gateway connection timeout')); + } + }, 10000); + }); + } + + /** + * Handle incoming messages from the Gateway. + */ + private _handleGatewayMessage(data: string): void { + try { + const message = JSON.parse(data); + + switch (message.type) { + case 'playAudio': + const audioMsg = message as PlayAudioMessage; + this.playAudio(audioMsg.audio.data, audioMsg.audio.format); + break; + + case 'pong': + // Heartbeat response + break; + + default: + this._logger.debug('Unknown Gateway message type:', message.type); + } + } catch (error) { + this._logger.error('Error parsing Gateway message:', error); + } + } + + /** + * Send a message to the Gateway. + */ + private _sendToGateway(message: object): void { + if (!this._gatewayWs || this._gatewayWs.readyState !== WebSocket.OPEN) { + this._logger.warn('Cannot send to Gateway - not connected'); + return; + } + + try { + this._gatewayWs.send(JSON.stringify(message)); + } catch (error) { + this._logger.error('Error sending to Gateway:', error); + } + } + + /** + * Send a transcript to the Gateway. + */ + private _sendTranscript(speaker: string, text: string, isFinal: boolean): void { + const message: TranscriptMessage = { + type: 'transcript', + sessionId: this._sessionId, + transcript: { + speaker, + text, + timestamp: new Date().toISOString(), + isFinal, + }, + }; + this._sendToGateway(message); + } + + /** + * Send a status update to the Gateway. + */ + private _sendStatus(status: StatusMessage['status'], message?: string): void { + const statusMessage: StatusMessage = { + type: 'status', + sessionId: this._sessionId, + status, + message, + }; + this._sendToGateway(statusMessage); + } + + /** + * Stop the bot - leave meeting, close browser, disconnect from Gateway. */ async stop(): Promise { if (this._isShuttingDown) { @@ -148,6 +274,13 @@ export class BotOrchestrator { } finally { // Close browser await this._closeBrowser(); + + // Close Gateway connection + if (this._gatewayWs) { + this._gatewayWs.close(1000, 'Bot stopping'); + this._gatewayWs = null; + } + this._setState('disconnected'); } } @@ -192,6 +325,9 @@ export class BotOrchestrator { // Initialize procedures this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName); this._captionsProcedure = new CaptionsProcedure(this._page, this._logger, (entry) => { + // Send transcript to Gateway + this._sendTranscript(entry.speaker, entry.text, entry.isFinal); + // Also notify local callbacks this._callbacks.onTranscript(entry); }); this._audioProcedure = new AudioProcedure(this._page, this._logger); @@ -278,12 +414,25 @@ export class BotOrchestrator { } /** - * Update the bot state and notify callbacks. + * Update the bot state and notify callbacks + Gateway. */ private _setState(state: BotState, message?: string): void { this._state = state; this._logger.info(`State changed: ${state}${message ? ` - ${message}` : ''}`); this._callbacks.onStateChange(state, message); + + // Send status to Gateway + const statusMap: Record = { + 'idle': 'connecting', + 'launching': 'connecting', + 'navigating': 'connecting', + 'in_lobby': 'in_lobby', + 'in_meeting': 'joined', + 'leaving': 'left', + 'error': 'error', + 'disconnected': 'left', + }; + this._sendStatus(statusMap[state], message); } /** diff --git a/src/index.ts b/src/index.ts index 6b5a3fe..3852b73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,8 @@ async function main(): Promise { // Start HTTP server httpServer = new HttpServer({ - onJoinRequest: async (sessionId, meetingUrl, botName) => { - await sessionManager.createSession(sessionId, meetingUrl, botName); + onJoinRequest: async (sessionId, meetingUrl, botName, instanceId) => { + await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId); }, onLeaveRequest: async (sessionId) => { await sessionManager.endSession(sessionId); diff --git a/src/server/httpServer.ts b/src/server/httpServer.ts index 21697db..776d8ac 100644 --- a/src/server/httpServer.ts +++ b/src/server/httpServer.ts @@ -4,7 +4,7 @@ import { logger } from '../utils/logger'; import { config } from '../config'; export interface HttpServerCallbacks { - onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string) => Promise; + onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string) => Promise; onLeaveRequest: (sessionId: string) => Promise; onStatusRequest: (sessionId: string) => { state: string; error?: string } | null; } @@ -77,14 +77,14 @@ export class HttpServer { // Deploy a new bot this._app.post('/api/bot', async (req: Request, res: Response) => { try { - const { sessionId, meetingUrl, botName } = req.body; + const { sessionId, meetingUrl, botName, instanceId } = req.body; if (!sessionId || !meetingUrl) { res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' }); return; } - await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName); + await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId); res.json({ success: true, diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 0c93924..2808504 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -1,59 +1,40 @@ import { v4 as uuidv4 } from 'uuid'; -import { BotOrchestrator, OrchestratorCallbacks } from './bot/orchestrator'; -import { GatewayClient } from './server/gatewayClient'; +import { BotOrchestrator, OrchestratorCallbacks, OrchestratorOptions } from './bot/orchestrator'; import { BotSession, BotState, TranscriptEntry } from './types'; import { logger } from './utils/logger'; import { config } from './config'; /** * Manages all active bot sessions. - * Coordinates between the Gateway client and bot orchestrators. + * Each session connects to the Gateway independently via WebSocket. */ export class SessionManager { private _sessions: Map = new Map(); - private _gatewayClient: GatewayClient | null = null; constructor() {} /** - * Initialize the session manager and connect to the Gateway. + * Initialize the session manager. */ async initialize(): Promise { logger.info('Initializing SessionManager...'); - - // Create Gateway client - this._gatewayClient = new GatewayClient(config.gatewayWsUrl, { - onJoinMeeting: (sessionId, meetingUrl, botName) => { - this.createSession(sessionId, meetingUrl, botName); - }, - onLeaveMeeting: (sessionId) => { - this.endSession(sessionId); - }, - onPlayAudio: (sessionId, audioData, format) => { - this.playAudio(sessionId, audioData, format); - }, - onDisconnect: () => { - logger.warn('Gateway disconnected'); - }, - }); - - // Connect to Gateway - try { - await this._gatewayClient.connect(); - logger.info('Connected to Gateway'); - } catch (error) { - logger.error('Failed to connect to Gateway:', error); - // Continue without Gateway - can still use HTTP API - } + logger.info(`Gateway WebSocket URL: ${config.gatewayWsUrl}`); + // Sessions connect to Gateway individually when created } /** * Create a new bot session and join the meeting. + * + * @param sessionId - Unique session ID + * @param meetingUrl - Teams meeting URL + * @param botName - Display name for the bot + * @param instanceId - Feature instance ID (for Gateway routing) */ async createSession( sessionId: string, meetingUrl: string, - botName?: string + botName?: string, + instanceId?: string ): Promise { if (this._sessions.has(sessionId)) { logger.warn(`Session ${sessionId} already exists`); @@ -74,11 +55,18 @@ export class SessionManager { }, }; + // Options for Gateway connection + const options: OrchestratorOptions = { + gatewayWsUrl: config.gatewayWsUrl, + instanceId: instanceId || 'default', + }; + const orchestrator = new BotOrchestrator( sessionId, meetingUrl, botName || config.botName, - callbacks + callbacks, + options ); this._sessions.set(sessionId, orchestrator); @@ -147,7 +135,7 @@ export class SessionManager { } /** - * Shutdown all sessions and disconnect from Gateway. + * Shutdown all sessions. */ async shutdown(): Promise { logger.info('Shutting down SessionManager...'); @@ -156,11 +144,6 @@ export class SessionManager { const sessionIds = Array.from(this._sessions.keys()); await Promise.all(sessionIds.map((id) => this.endSession(id))); - // Disconnect from Gateway - if (this._gatewayClient) { - this._gatewayClient.disconnect(); - } - logger.info('SessionManager shutdown complete'); } @@ -170,11 +153,6 @@ export class SessionManager { private _handleStateChange(sessionId: string, state: BotState, message?: string): void { logger.info(`Session ${sessionId} state: ${state}${message ? ` - ${message}` : ''}`); - if (this._gatewayClient) { - const status = this._gatewayClient.mapStateToStatus(state); - this._gatewayClient.sendStatus(sessionId, status, message); - } - // Clean up if disconnected or error if (state === 'disconnected' || state === 'error') { this._sessions.delete(sessionId); @@ -186,15 +164,7 @@ export class SessionManager { */ private _handleTranscript(sessionId: string, entry: TranscriptEntry): void { logger.debug(`Session ${sessionId} transcript: [${entry.speaker}] ${entry.text}`); - - if (this._gatewayClient) { - this._gatewayClient.sendTranscript( - sessionId, - entry.speaker, - entry.text, - entry.isFinal - ); - } + // Transcripts are sent to Gateway by the orchestrator directly } /** @@ -202,9 +172,6 @@ export class SessionManager { */ private _handleError(sessionId: string, error: Error): void { logger.error(`Session ${sessionId} error:`, error); - - if (this._gatewayClient) { - this._gatewayClient.sendStatus(sessionId, 'error', error.message); - } + // Errors are sent to Gateway by the orchestrator directly } }