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
}
}