Add Gateway WebSocket integration and CI/CD pipeline

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-13 22:55:00 +01:00
parent 043349f529
commit dced747666
6 changed files with 452 additions and 67 deletions

69
.github/workflows/build-deploy.yml vendored Normal file
View file

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

200
AZURE_SETUP.md Normal file
View file

@ -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/<SUBSCRIPTION_ID>/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 <GITHUB_USERNAME> \
--password <GITHUB_PAT>
```
---
## 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

View file

@ -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<void> {
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<void> {
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<void> {
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<BotState, StatusMessage['status']> = {
'idle': 'connecting',
'launching': 'connecting',
'navigating': 'connecting',
'in_lobby': 'in_lobby',
'in_meeting': 'joined',
'leaving': 'left',
'error': 'error',
'disconnected': 'left',
};
this._sendStatus(statusMap[state], message);
}
/**

View file

@ -19,8 +19,8 @@ async function main(): Promise<void> {
// 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);

View file

@ -4,7 +4,7 @@ import { logger } from '../utils/logger';
import { config } from '../config';
export interface HttpServerCallbacks {
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string) => Promise<void>;
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string) => Promise<void>;
onLeaveRequest: (sessionId: string) => Promise<void>;
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,

View file

@ -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<string, BotOrchestrator> = new Map();
private _gatewayClient: GatewayClient | null = null;
constructor() {}
/**
* Initialize the session manager and connect to the Gateway.
* Initialize the session manager.
*/
async initialize(): Promise<void> {
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<void> {
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<void> {
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
}
}