Add Gateway WebSocket integration and CI/CD pipeline
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
043349f529
commit
dced747666
6 changed files with 452 additions and 67 deletions
69
.github/workflows/build-deploy.yml
vendored
Normal file
69
.github/workflows/build-deploy.yml
vendored
Normal 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
200
AZURE_SETUP.md
Normal 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
|
||||||
|
|
@ -3,10 +3,11 @@ import { Logger } from 'winston';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { createSessionLogger } from '../utils/logger';
|
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 { JoinProcedure } from './joinProcedure';
|
||||||
import { CaptionsProcedure } from './captionsProcedure';
|
import { CaptionsProcedure } from './captionsProcedure';
|
||||||
import { AudioProcedure } from './audioProcedure';
|
import { AudioProcedure } from './audioProcedure';
|
||||||
|
|
@ -18,12 +19,19 @@ export interface OrchestratorCallbacks {
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrchestratorOptions {
|
||||||
|
gatewayWsUrl: string;
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates the entire bot lifecycle:
|
* Orchestrates the entire bot lifecycle:
|
||||||
|
* - Connects to Gateway via WebSocket
|
||||||
* - Launches browser
|
* - Launches browser
|
||||||
* - Joins meeting
|
* - Joins meeting
|
||||||
* - Enables captions
|
* - Enables captions
|
||||||
* - Handles audio playback
|
* - Sends transcripts to Gateway
|
||||||
|
* - Handles audio playback from Gateway
|
||||||
* - Leaves meeting
|
* - Leaves meeting
|
||||||
*/
|
*/
|
||||||
export class BotOrchestrator {
|
export class BotOrchestrator {
|
||||||
|
|
@ -32,10 +40,12 @@ export class BotOrchestrator {
|
||||||
private _botName: string;
|
private _botName: string;
|
||||||
private _logger: Logger;
|
private _logger: Logger;
|
||||||
private _callbacks: OrchestratorCallbacks;
|
private _callbacks: OrchestratorCallbacks;
|
||||||
|
private _options: OrchestratorOptions;
|
||||||
|
|
||||||
private _browser: Browser | null = null;
|
private _browser: Browser | null = null;
|
||||||
private _context: BrowserContext | null = null;
|
private _context: BrowserContext | null = null;
|
||||||
private _page: Page | null = null;
|
private _page: Page | null = null;
|
||||||
|
private _gatewayWs: WebSocket | null = null;
|
||||||
|
|
||||||
private _joinProcedure: JoinProcedure | null = null;
|
private _joinProcedure: JoinProcedure | null = null;
|
||||||
private _captionsProcedure: CaptionsProcedure | null = null;
|
private _captionsProcedure: CaptionsProcedure | null = null;
|
||||||
|
|
@ -48,12 +58,14 @@ export class BotOrchestrator {
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
meetingUrl: string,
|
meetingUrl: string,
|
||||||
botName: string,
|
botName: string,
|
||||||
callbacks: OrchestratorCallbacks
|
callbacks: OrchestratorCallbacks,
|
||||||
|
options: OrchestratorOptions
|
||||||
) {
|
) {
|
||||||
this._sessionId = sessionId;
|
this._sessionId = sessionId;
|
||||||
this._meetingUrl = meetingUrl;
|
this._meetingUrl = meetingUrl;
|
||||||
this._botName = botName || config.botName;
|
this._botName = botName || config.botName;
|
||||||
this._callbacks = callbacks;
|
this._callbacks = callbacks;
|
||||||
|
this._options = options;
|
||||||
this._logger = createSessionLogger(sessionId);
|
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> {
|
async start(): Promise<void> {
|
||||||
if (!isValidMeetingUrl(this._meetingUrl)) {
|
if (!isValidMeetingUrl(this._meetingUrl)) {
|
||||||
|
|
@ -76,6 +88,9 @@ export class BotOrchestrator {
|
||||||
try {
|
try {
|
||||||
this._setState('launching');
|
this._setState('launching');
|
||||||
|
|
||||||
|
// Connect to Gateway WebSocket first
|
||||||
|
await this._connectToGateway();
|
||||||
|
|
||||||
// Launch browser
|
// Launch browser
|
||||||
await this._launchBrowser();
|
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> {
|
async stop(): Promise<void> {
|
||||||
if (this._isShuttingDown) {
|
if (this._isShuttingDown) {
|
||||||
|
|
@ -148,6 +274,13 @@ export class BotOrchestrator {
|
||||||
} finally {
|
} finally {
|
||||||
// Close browser
|
// Close browser
|
||||||
await this._closeBrowser();
|
await this._closeBrowser();
|
||||||
|
|
||||||
|
// Close Gateway connection
|
||||||
|
if (this._gatewayWs) {
|
||||||
|
this._gatewayWs.close(1000, 'Bot stopping');
|
||||||
|
this._gatewayWs = null;
|
||||||
|
}
|
||||||
|
|
||||||
this._setState('disconnected');
|
this._setState('disconnected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +325,9 @@ export class BotOrchestrator {
|
||||||
// Initialize procedures
|
// Initialize procedures
|
||||||
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
|
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
|
||||||
this._captionsProcedure = new CaptionsProcedure(this._page, this._logger, (entry) => {
|
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._callbacks.onTranscript(entry);
|
||||||
});
|
});
|
||||||
this._audioProcedure = new AudioProcedure(this._page, this._logger);
|
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 {
|
private _setState(state: BotState, message?: string): void {
|
||||||
this._state = state;
|
this._state = state;
|
||||||
this._logger.info(`State changed: ${state}${message ? ` - ${message}` : ''}`);
|
this._logger.info(`State changed: ${state}${message ? ` - ${message}` : ''}`);
|
||||||
this._callbacks.onStateChange(state, 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
httpServer = new HttpServer({
|
httpServer = new HttpServer({
|
||||||
onJoinRequest: async (sessionId, meetingUrl, botName) => {
|
onJoinRequest: async (sessionId, meetingUrl, botName, instanceId) => {
|
||||||
await sessionManager.createSession(sessionId, meetingUrl, botName);
|
await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId);
|
||||||
},
|
},
|
||||||
onLeaveRequest: async (sessionId) => {
|
onLeaveRequest: async (sessionId) => {
|
||||||
await sessionManager.endSession(sessionId);
|
await sessionManager.endSession(sessionId);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { logger } from '../utils/logger';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
|
||||||
export interface HttpServerCallbacks {
|
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>;
|
onLeaveRequest: (sessionId: string) => Promise<void>;
|
||||||
onStatusRequest: (sessionId: string) => { state: string; error?: string } | null;
|
onStatusRequest: (sessionId: string) => { state: string; error?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
@ -77,14 +77,14 @@ export class HttpServer {
|
||||||
// Deploy a new bot
|
// Deploy a new bot
|
||||||
this._app.post('/api/bot', async (req: Request, res: Response) => {
|
this._app.post('/api/bot', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, meetingUrl, botName } = req.body;
|
const { sessionId, meetingUrl, botName, instanceId } = req.body;
|
||||||
|
|
||||||
if (!sessionId || !meetingUrl) {
|
if (!sessionId || !meetingUrl) {
|
||||||
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
|
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName);
|
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,40 @@
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { BotOrchestrator, OrchestratorCallbacks } from './bot/orchestrator';
|
import { BotOrchestrator, OrchestratorCallbacks, OrchestratorOptions } from './bot/orchestrator';
|
||||||
import { GatewayClient } from './server/gatewayClient';
|
|
||||||
import { BotSession, BotState, TranscriptEntry } from './types';
|
import { BotSession, BotState, TranscriptEntry } from './types';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from './utils/logger';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages all active bot sessions.
|
* 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 {
|
export class SessionManager {
|
||||||
private _sessions: Map<string, BotOrchestrator> = new Map();
|
private _sessions: Map<string, BotOrchestrator> = new Map();
|
||||||
private _gatewayClient: GatewayClient | null = null;
|
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the session manager and connect to the Gateway.
|
* Initialize the session manager.
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
logger.info('Initializing SessionManager...');
|
logger.info('Initializing SessionManager...');
|
||||||
|
logger.info(`Gateway WebSocket URL: ${config.gatewayWsUrl}`);
|
||||||
// Create Gateway client
|
// Sessions connect to Gateway individually when created
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new bot session and join the meeting.
|
* 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(
|
async createSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
meetingUrl: string,
|
meetingUrl: string,
|
||||||
botName?: string
|
botName?: string,
|
||||||
|
instanceId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._sessions.has(sessionId)) {
|
if (this._sessions.has(sessionId)) {
|
||||||
logger.warn(`Session ${sessionId} already exists`);
|
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(
|
const orchestrator = new BotOrchestrator(
|
||||||
sessionId,
|
sessionId,
|
||||||
meetingUrl,
|
meetingUrl,
|
||||||
botName || config.botName,
|
botName || config.botName,
|
||||||
callbacks
|
callbacks,
|
||||||
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
this._sessions.set(sessionId, orchestrator);
|
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> {
|
async shutdown(): Promise<void> {
|
||||||
logger.info('Shutting down SessionManager...');
|
logger.info('Shutting down SessionManager...');
|
||||||
|
|
@ -156,11 +144,6 @@ export class SessionManager {
|
||||||
const sessionIds = Array.from(this._sessions.keys());
|
const sessionIds = Array.from(this._sessions.keys());
|
||||||
await Promise.all(sessionIds.map((id) => this.endSession(id)));
|
await Promise.all(sessionIds.map((id) => this.endSession(id)));
|
||||||
|
|
||||||
// Disconnect from Gateway
|
|
||||||
if (this._gatewayClient) {
|
|
||||||
this._gatewayClient.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('SessionManager shutdown complete');
|
logger.info('SessionManager shutdown complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,11 +153,6 @@ export class SessionManager {
|
||||||
private _handleStateChange(sessionId: string, state: BotState, message?: string): void {
|
private _handleStateChange(sessionId: string, state: BotState, message?: string): void {
|
||||||
logger.info(`Session ${sessionId} state: ${state}${message ? ` - ${message}` : ''}`);
|
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
|
// Clean up if disconnected or error
|
||||||
if (state === 'disconnected' || state === 'error') {
|
if (state === 'disconnected' || state === 'error') {
|
||||||
this._sessions.delete(sessionId);
|
this._sessions.delete(sessionId);
|
||||||
|
|
@ -186,15 +164,7 @@ export class SessionManager {
|
||||||
*/
|
*/
|
||||||
private _handleTranscript(sessionId: string, entry: TranscriptEntry): void {
|
private _handleTranscript(sessionId: string, entry: TranscriptEntry): void {
|
||||||
logger.debug(`Session ${sessionId} transcript: [${entry.speaker}] ${entry.text}`);
|
logger.debug(`Session ${sessionId} transcript: [${entry.speaker}] ${entry.text}`);
|
||||||
|
// Transcripts are sent to Gateway by the orchestrator directly
|
||||||
if (this._gatewayClient) {
|
|
||||||
this._gatewayClient.sendTranscript(
|
|
||||||
sessionId,
|
|
||||||
entry.speaker,
|
|
||||||
entry.text,
|
|
||||||
entry.isFinal
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -202,9 +172,6 @@ export class SessionManager {
|
||||||
*/
|
*/
|
||||||
private _handleError(sessionId: string, error: Error): void {
|
private _handleError(sessionId: string, error: Error): void {
|
||||||
logger.error(`Session ${sessionId} error:`, error);
|
logger.error(`Session ${sessionId} error:`, error);
|
||||||
|
// Errors are sent to Gateway by the orchestrator directly
|
||||||
if (this._gatewayClient) {
|
|
||||||
this._gatewayClient.sendStatus(sessionId, 'error', error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue