Initial commit: Browser-based Teams Meeting Bot
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
043349f529
19 changed files with 2229 additions and 0 deletions
18
.env.sample
Normal file
18
.env.sample
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Service Configuration
|
||||||
|
PORT=4100
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Gateway WebSocket Connection
|
||||||
|
GATEWAY_WS_URL=wss://gateway-int.poweron-center.net/api/teamsbot/ws
|
||||||
|
|
||||||
|
# Bot Configuration
|
||||||
|
BOT_NAME=PowerOn AI
|
||||||
|
BOT_HEADLESS=true
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_DIR=./output/logs
|
||||||
|
|
||||||
|
# Screenshots (for debugging)
|
||||||
|
SCREENSHOT_DIR=./output/screenshots
|
||||||
|
SCREENSHOT_ON_ERROR=true
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Output directories
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
screenshots/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Test
|
||||||
|
coverage/
|
||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.41.0-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built files and dependencies
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Create output directories
|
||||||
|
RUN mkdir -p output/logs output/screenshots
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV BOT_HEADLESS=true
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 4100
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:4100/health || exit 1
|
||||||
|
|
||||||
|
# Run
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
173
README.md
Normal file
173
README.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# Teams Browser Bot Service
|
||||||
|
|
||||||
|
Browser-based Microsoft Teams Meeting Bot using Playwright. This service joins Teams meetings via the web interface, captures live captions, and plays TTS audio responses.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser-based Architecture │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ ┌───────────────────────────────┐ │
|
||||||
|
│ │ Gateway │ │ Browser Bot Service │ │
|
||||||
|
│ │ (Python) │ │ (Node.js + Playwright) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ - STT (Google) │ WebSocket │ - Headless Chrome │ │
|
||||||
|
│ │ - AI (OpenAI) │◄──────────────────►│ - Teams Web App │ │
|
||||||
|
│ │ - TTS (Google) │ Transcripts │ - Meeting join flow │ │
|
||||||
|
│ │ - Session Mgmt │ + TTS Audio │ - Captions scraping │ │
|
||||||
|
│ │ │ │ - Audio playback │ │
|
||||||
|
│ └───────────────────┘ └───────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-tenant support**: Can join any Teams meeting (not limited to own tenant)
|
||||||
|
- **Browser-based**: Uses Teams web app, no Graph Communications SDK needed
|
||||||
|
- **Captions scraping**: Captures live captions for transcription
|
||||||
|
- **Audio playback**: Plays TTS audio through the browser into the meeting
|
||||||
|
- **WebSocket integration**: Real-time communication with Gateway
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Docker (for production deployment)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
npx playwright install chromium
|
||||||
|
|
||||||
|
# Copy and configure environment
|
||||||
|
cp .env.sample .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Or build image only
|
||||||
|
docker build -t teams-browser-bot .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PORT` | HTTP server port | `4100` |
|
||||||
|
| `GATEWAY_WS_URL` | Gateway WebSocket URL | `wss://gateway-int.poweron-center.net/api/teamsbot/ws` |
|
||||||
|
| `BOT_NAME` | Display name in meetings | `PowerOn AI` |
|
||||||
|
| `BOT_HEADLESS` | Run browser headless | `true` |
|
||||||
|
| `LOG_LEVEL` | Logging level | `info` |
|
||||||
|
| `SCREENSHOT_ON_ERROR` | Take screenshots on errors | `true` |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy Bot
|
||||||
|
```
|
||||||
|
POST /api/bot
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sessionId": "uuid",
|
||||||
|
"meetingUrl": "https://teams.microsoft.com/meet/...",
|
||||||
|
"botName": "PowerOn AI"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Leave Meeting
|
||||||
|
```
|
||||||
|
POST /api/bot/:sessionId/leave
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Status
|
||||||
|
```
|
||||||
|
GET /api/bot/:sessionId/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Protocol
|
||||||
|
|
||||||
|
### Gateway → Bot
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Join a meeting
|
||||||
|
{ type: "joinMeeting", sessionId: "uuid", meetingUrl: "...", botName?: "..." }
|
||||||
|
|
||||||
|
// Leave meeting
|
||||||
|
{ type: "leaveMeeting", sessionId: "uuid" }
|
||||||
|
|
||||||
|
// Play audio
|
||||||
|
{ type: "playAudio", sessionId: "uuid", audio: { format: "mp3", data: "base64..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot → Gateway
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Transcript
|
||||||
|
{ type: "transcript", sessionId: "uuid", transcript: { speaker: "...", text: "...", timestamp: "...", isFinal: true } }
|
||||||
|
|
||||||
|
// Status
|
||||||
|
{ type: "status", sessionId: "uuid", status: "joined" | "in_lobby" | "left" | "error", message?: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Meeting URL Formats
|
||||||
|
|
||||||
|
Supports both classic and new (short) URL formats:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Classic format
|
||||||
|
https://teams.microsoft.com/l/meetup-join/19%3ameeting_xxx/0?context=...
|
||||||
|
|
||||||
|
# New format (since 2025)
|
||||||
|
https://teams.microsoft.com/meet/36438888781520?p=5fGqrujxzewPFjJacW
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Azure Container Instance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create resource group
|
||||||
|
az group create --name rg-teams-bot --location westeurope
|
||||||
|
|
||||||
|
# Create container instance
|
||||||
|
az container create \
|
||||||
|
--resource-group rg-teams-bot \
|
||||||
|
--name teams-browser-bot \
|
||||||
|
--image <your-registry>/teams-browser-bot:latest \
|
||||||
|
--cpu 2 \
|
||||||
|
--memory 4 \
|
||||||
|
--ports 4100 \
|
||||||
|
--environment-variables \
|
||||||
|
GATEWAY_WS_URL=wss://gateway-int.poweron-center.net/api/teamsbot/ws \
|
||||||
|
BOT_NAME="PowerOn AI"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
- Logs are written to `output/logs/`
|
||||||
|
- Screenshots (on error) are saved to `output/screenshots/`
|
||||||
|
- Set `BOT_HEADLESS=false` for local debugging with visible browser
|
||||||
|
|
||||||
|
## Based On
|
||||||
|
|
||||||
|
- [Recall.ai Microsoft Teams Meeting Bot](https://github.com/recallai/microsoft-teams-meeting-bot)
|
||||||
|
- [Recall.ai Blog: How to build a Microsoft Teams Bot](https://www.recall.ai/blog/how-to-build-a-microsoft-teams-bot)
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
teams-browser-bot:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4100:4100"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=4100
|
||||||
|
- GATEWAY_WS_URL=${GATEWAY_WS_URL:-wss://gateway-int.poweron-center.net/api/teamsbot/ws}
|
||||||
|
- BOT_NAME=${BOT_NAME:-PowerOn AI}
|
||||||
|
- BOT_HEADLESS=true
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- SCREENSHOT_ON_ERROR=true
|
||||||
|
volumes:
|
||||||
|
- ./output:/app/output
|
||||||
|
restart: unless-stopped
|
||||||
|
# Required for Playwright/Chrome
|
||||||
|
shm_size: '2gb'
|
||||||
|
security_opt:
|
||||||
|
- seccomp:unconfined
|
||||||
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "service-teams-browser-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Browser-based Teams Meeting Bot using Playwright",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"lint": "eslint src/**/*.ts",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "^1.41.0",
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"dotenv": "^16.4.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"eslint": "^8.56.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/bot/audioProcedure.ts
Normal file
137
src/bot/audioProcedure.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Page } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles audio playback in the Teams meeting.
|
||||||
|
* Injects TTS audio into the browser to be played through the meeting.
|
||||||
|
*/
|
||||||
|
export class AudioProcedure {
|
||||||
|
private _page: Page;
|
||||||
|
private _logger: Logger;
|
||||||
|
private _audioContext: boolean = false;
|
||||||
|
|
||||||
|
constructor(page: Page, logger: Logger) {
|
||||||
|
this._page = page;
|
||||||
|
this._logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the audio context in the browser.
|
||||||
|
* Must be called after user interaction (joining meeting counts).
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this._audioContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info('Initializing audio context...');
|
||||||
|
|
||||||
|
await this._page.evaluate(() => {
|
||||||
|
// Create a global audio context
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
(window as any).__audioContext = new AudioContext();
|
||||||
|
(window as any).__audioQueue = [];
|
||||||
|
(window as any).__isPlaying = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._audioContext = true;
|
||||||
|
this._logger.info('Audio context initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play audio in the browser.
|
||||||
|
* The audio will be heard by other meeting participants.
|
||||||
|
*
|
||||||
|
* @param audioData Base64 encoded audio data
|
||||||
|
* @param format Audio format (mp3, wav, pcm)
|
||||||
|
*/
|
||||||
|
async playAudio(audioData: string, format: 'mp3' | 'wav' | 'pcm'): Promise<void> {
|
||||||
|
if (!this._audioContext) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info(`Playing audio (format: ${format}, size: ${audioData.length} bytes base64)`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._page.evaluate(async ({ audioData, format }) => {
|
||||||
|
const ctx = (window as any).__audioContext as AudioContext;
|
||||||
|
|
||||||
|
// Resume context if suspended
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
await ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 to ArrayBuffer
|
||||||
|
const binaryString = atob(audioData);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioBuffer: AudioBuffer;
|
||||||
|
|
||||||
|
if (format === 'pcm') {
|
||||||
|
// PCM: Assume 16-bit mono 16kHz
|
||||||
|
const pcmData = new Int16Array(bytes.buffer);
|
||||||
|
audioBuffer = ctx.createBuffer(1, pcmData.length, 16000);
|
||||||
|
const channelData = audioBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < pcmData.length; i++) {
|
||||||
|
channelData[i] = pcmData[i] / 32768; // Convert to float
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MP3/WAV: Use decodeAudioData
|
||||||
|
audioBuffer = await ctx.decodeAudioData(bytes.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create source and play
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
source.start(0);
|
||||||
|
|
||||||
|
// Return a promise that resolves when playback ends
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
source.onended = () => resolve();
|
||||||
|
});
|
||||||
|
}, { audioData, format });
|
||||||
|
|
||||||
|
this._logger.info('Audio playback completed');
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error playing audio:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop any currently playing audio.
|
||||||
|
*/
|
||||||
|
async stopAudio(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this._page.evaluate(() => {
|
||||||
|
const ctx = (window as any).__audioContext as AudioContext;
|
||||||
|
if (ctx) {
|
||||||
|
ctx.suspend();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up audio resources.
|
||||||
|
*/
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this._page.evaluate(() => {
|
||||||
|
const ctx = (window as any).__audioContext as AudioContext;
|
||||||
|
if (ctx) {
|
||||||
|
ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Page might be closed
|
||||||
|
}
|
||||||
|
this._audioContext = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/bot/captionsProcedure.ts
Normal file
242
src/bot/captionsProcedure.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { Page } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import { TranscriptEntry } from '../types';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles enabling and scraping captions from Teams meetings.
|
||||||
|
* Based on Recall.ai's open-source implementation.
|
||||||
|
*/
|
||||||
|
export class CaptionsProcedure {
|
||||||
|
private _page: Page;
|
||||||
|
private _logger: Logger;
|
||||||
|
private _onTranscript: (entry: TranscriptEntry) => void;
|
||||||
|
private _isSubscribed: boolean = false;
|
||||||
|
private _lastCaptionText: string = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
page: Page,
|
||||||
|
logger: Logger,
|
||||||
|
onTranscript: (entry: TranscriptEntry) => void
|
||||||
|
) {
|
||||||
|
this._page = page;
|
||||||
|
this._logger = logger;
|
||||||
|
this._onTranscript = onTranscript;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable live captions in the meeting.
|
||||||
|
* Opens the "More" menu and clicks "Turn on live captions".
|
||||||
|
*/
|
||||||
|
async enableCaptionsFlow(): Promise<void> {
|
||||||
|
this._logger.info('Enabling live captions...');
|
||||||
|
|
||||||
|
// First, open the "More actions" menu
|
||||||
|
await this._openMoreMenu();
|
||||||
|
|
||||||
|
// Then click on "Turn on live captions"
|
||||||
|
await this._clickEnableCaptions();
|
||||||
|
|
||||||
|
this._logger.info('Live captions enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the "More actions" (...) menu in the call controls.
|
||||||
|
*/
|
||||||
|
private async _openMoreMenu(): Promise<void> {
|
||||||
|
const moreMenuSelectors = [
|
||||||
|
'[data-tid="callingButtons-showMoreBtn"]',
|
||||||
|
'button[aria-label*="More actions"]',
|
||||||
|
'button[aria-label*="More"]',
|
||||||
|
'[data-tid="more-button"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of moreMenuSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
await this._page.waitForTimeout(1000);
|
||||||
|
this._logger.info('Opened more menu');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not find More actions menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Turn on live captions" option.
|
||||||
|
*/
|
||||||
|
private async _clickEnableCaptions(): Promise<void> {
|
||||||
|
const captionsSelectors = [
|
||||||
|
'button:has-text("Turn on live captions")',
|
||||||
|
'button:has-text("Live captions")',
|
||||||
|
'[data-tid="captions-toggle"]',
|
||||||
|
'button[aria-label*="captions"]',
|
||||||
|
'button[aria-label*="Captions"]',
|
||||||
|
// Menu item selectors
|
||||||
|
'[role="menuitem"]:has-text("captions")',
|
||||||
|
'[role="menuitemcheckbox"]:has-text("captions")',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of captionsSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
await this._page.waitForTimeout(1000);
|
||||||
|
this._logger.info('Clicked enable captions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try clicking away to close menu if captions not found
|
||||||
|
await this._page.keyboard.press('Escape');
|
||||||
|
this._logger.warn('Could not find captions option - may already be enabled or not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the captions DOM for updates.
|
||||||
|
* Emits transcript events when new captions appear.
|
||||||
|
*/
|
||||||
|
async subscribeToCaptions(): Promise<void> {
|
||||||
|
if (this._isSubscribed) {
|
||||||
|
this._logger.warn('Already subscribed to captions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isSubscribed = true;
|
||||||
|
this._logger.info('Subscribing to captions...');
|
||||||
|
|
||||||
|
// Set up a MutationObserver in the browser to watch for caption changes
|
||||||
|
await this._page.evaluate(() => {
|
||||||
|
// Store captions data on window for retrieval
|
||||||
|
(window as any).__captionsBuffer = [];
|
||||||
|
(window as any).__lastCaptionId = 0;
|
||||||
|
|
||||||
|
// Function to extract caption text
|
||||||
|
const extractCaptions = () => {
|
||||||
|
// Common caption container selectors in Teams
|
||||||
|
const captionSelectors = [
|
||||||
|
'[data-tid="closed-captions-renderer"]',
|
||||||
|
'[data-tid="captions-container"]',
|
||||||
|
'.captions-container',
|
||||||
|
'[class*="caption"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of captionSelectors) {
|
||||||
|
const container = document.querySelector(selector);
|
||||||
|
if (container) {
|
||||||
|
// Find individual caption entries
|
||||||
|
const entries = container.querySelectorAll('[data-tid="caption-entry"], [class*="caption-line"], [class*="captionLine"]');
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const speakerEl = entry.querySelector('[data-tid="caption-speaker"], [class*="speaker"]');
|
||||||
|
const textEl = entry.querySelector('[data-tid="caption-text"], [class*="text"]');
|
||||||
|
|
||||||
|
const speaker = speakerEl?.textContent?.trim() || 'Unknown';
|
||||||
|
const text = textEl?.textContent?.trim() || entry.textContent?.trim() || '';
|
||||||
|
|
||||||
|
if (text && text.length > 0) {
|
||||||
|
const captionId = `${speaker}-${text}-${index}`;
|
||||||
|
if (captionId !== (window as any).__lastCaptionId) {
|
||||||
|
(window as any).__lastCaptionId = captionId;
|
||||||
|
(window as any).__captionsBuffer.push({
|
||||||
|
speaker,
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
isFinal: true, // Teams captions are typically final
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up observer
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
extractCaptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe the entire body for changes (captions can appear anywhere)
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial extraction
|
||||||
|
extractCaptions();
|
||||||
|
|
||||||
|
// Store observer reference for cleanup
|
||||||
|
(window as any).__captionsObserver = observer;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling for new captions
|
||||||
|
this._pollCaptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the browser for new captions and emit them.
|
||||||
|
*/
|
||||||
|
private async _pollCaptions(): Promise<void> {
|
||||||
|
while (this._isSubscribed) {
|
||||||
|
try {
|
||||||
|
const captions = await this._page.evaluate(() => {
|
||||||
|
const buffer = (window as any).__captionsBuffer || [];
|
||||||
|
(window as any).__captionsBuffer = [];
|
||||||
|
return buffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const caption of captions) {
|
||||||
|
// Deduplicate based on text
|
||||||
|
if (caption.text !== this._lastCaptionText) {
|
||||||
|
this._lastCaptionText = caption.text;
|
||||||
|
this._onTranscript({
|
||||||
|
speaker: caption.speaker,
|
||||||
|
text: caption.text,
|
||||||
|
timestamp: new Date(caption.timestamp),
|
||||||
|
isFinal: caption.isFinal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Page might be closed
|
||||||
|
if (this._isSubscribed) {
|
||||||
|
this._logger.error('Error polling captions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll every 500ms
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop watching for captions.
|
||||||
|
*/
|
||||||
|
async unsubscribe(): Promise<void> {
|
||||||
|
this._isSubscribed = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._page.evaluate(() => {
|
||||||
|
if ((window as any).__captionsObserver) {
|
||||||
|
(window as any).__captionsObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Page might already be closed
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info('Unsubscribed from captions');
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/bot/joinProcedure.ts
Normal file
291
src/bot/joinProcedure.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { Page } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { getMeetingLaunchUrl } from './meetingUrlParser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the Teams meeting join flow.
|
||||||
|
* Based on Recall.ai's open-source implementation.
|
||||||
|
*/
|
||||||
|
export class JoinProcedure {
|
||||||
|
private _page: Page;
|
||||||
|
private _logger: Logger;
|
||||||
|
private _botName: string;
|
||||||
|
|
||||||
|
constructor(page: Page, logger: Logger, botName: string) {
|
||||||
|
this._page = page;
|
||||||
|
this._logger = logger;
|
||||||
|
this._botName = botName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the meeting URL and handle the launcher dialog.
|
||||||
|
* Teams shows a "How do you want to join?" dialog first.
|
||||||
|
*/
|
||||||
|
async startMeetingLauncherFlow(meetingUrl: string): Promise<void> {
|
||||||
|
const launchUrl = getMeetingLaunchUrl(meetingUrl);
|
||||||
|
this._logger.info(`Navigating to meeting: ${launchUrl}`);
|
||||||
|
|
||||||
|
await this._page.goto(launchUrl, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: config.timeouts.pageLoad,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the page to stabilize
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Handle "Continue on this browser" or similar prompts
|
||||||
|
await this._handleLauncherDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the launcher dialog that asks how to join.
|
||||||
|
* We want to select "Continue on this browser" / "Join on the web instead"
|
||||||
|
*/
|
||||||
|
private async _handleLauncherDialog(): Promise<void> {
|
||||||
|
this._logger.info('Looking for launcher dialog...');
|
||||||
|
|
||||||
|
// Common selectors for "Continue on browser" button
|
||||||
|
const browserJoinSelectors = [
|
||||||
|
'button:has-text("Continue on this browser")',
|
||||||
|
'button:has-text("Join on the web instead")',
|
||||||
|
'a:has-text("Continue on this browser")',
|
||||||
|
'a:has-text("Join on the web")',
|
||||||
|
'[data-tid="joinOnWeb"]',
|
||||||
|
'[data-tid="prejoin-join-button"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of browserJoinSelectors) {
|
||||||
|
try {
|
||||||
|
const element = await this._page.$(selector);
|
||||||
|
if (element) {
|
||||||
|
this._logger.info(`Found launcher button: ${selector}`);
|
||||||
|
await element.click();
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue to next selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info('No launcher dialog found, may already be on join page');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in the bot name and click "Join now" to enter the lobby.
|
||||||
|
*/
|
||||||
|
async joinMeetingLobbyFlow(): Promise<void> {
|
||||||
|
this._logger.info('Starting lobby join flow...');
|
||||||
|
|
||||||
|
// Wait for the pre-join screen
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Handle microphone/camera permissions - we want them OFF
|
||||||
|
await this._disableMediaDevices();
|
||||||
|
|
||||||
|
// Enter the bot name
|
||||||
|
await this._enterBotName();
|
||||||
|
|
||||||
|
// Click "Join now"
|
||||||
|
await this._clickJoinNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable microphone and camera toggles.
|
||||||
|
*/
|
||||||
|
private async _disableMediaDevices(): Promise<void> {
|
||||||
|
this._logger.info('Disabling media devices...');
|
||||||
|
|
||||||
|
// Microphone toggle selectors
|
||||||
|
const micSelectors = [
|
||||||
|
'[data-tid="toggle-mute"]',
|
||||||
|
'[aria-label*="microphone"]',
|
||||||
|
'[aria-label*="Microphone"]',
|
||||||
|
'button[id*="microphone"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Camera toggle selectors
|
||||||
|
const cameraSelectors = [
|
||||||
|
'[data-tid="toggle-video"]',
|
||||||
|
'[aria-label*="camera"]',
|
||||||
|
'[aria-label*="Camera"]',
|
||||||
|
'button[id*="camera"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to turn off microphone (click if it's ON)
|
||||||
|
for (const selector of micSelectors) {
|
||||||
|
try {
|
||||||
|
const mic = await this._page.$(selector);
|
||||||
|
if (mic) {
|
||||||
|
const ariaPressed = await mic.getAttribute('aria-pressed');
|
||||||
|
const ariaChecked = await mic.getAttribute('aria-checked');
|
||||||
|
if (ariaPressed === 'true' || ariaChecked === 'true') {
|
||||||
|
await mic.click();
|
||||||
|
this._logger.info('Disabled microphone');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to turn off camera
|
||||||
|
for (const selector of cameraSelectors) {
|
||||||
|
try {
|
||||||
|
const camera = await this._page.$(selector);
|
||||||
|
if (camera) {
|
||||||
|
const ariaPressed = await camera.getAttribute('aria-pressed');
|
||||||
|
const ariaChecked = await camera.getAttribute('aria-checked');
|
||||||
|
if (ariaPressed === 'true' || ariaChecked === 'true') {
|
||||||
|
await camera.click();
|
||||||
|
this._logger.info('Disabled camera');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter the bot name in the name input field.
|
||||||
|
*/
|
||||||
|
private async _enterBotName(): Promise<void> {
|
||||||
|
this._logger.info(`Entering bot name: ${this._botName}`);
|
||||||
|
|
||||||
|
const nameSelectors = [
|
||||||
|
'input[data-tid="prejoin-display-name-input"]',
|
||||||
|
'input[placeholder*="name"]',
|
||||||
|
'input[placeholder*="Name"]',
|
||||||
|
'input[aria-label*="name"]',
|
||||||
|
'#username',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of nameSelectors) {
|
||||||
|
try {
|
||||||
|
const input = await this._page.$(selector);
|
||||||
|
if (input) {
|
||||||
|
await input.fill(this._botName);
|
||||||
|
this._logger.info('Bot name entered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.warn('Could not find name input field');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Join now" button.
|
||||||
|
*/
|
||||||
|
private async _clickJoinNow(): Promise<void> {
|
||||||
|
this._logger.info('Clicking Join now...');
|
||||||
|
|
||||||
|
const joinSelectors = [
|
||||||
|
'button[data-tid="prejoin-join-button"]',
|
||||||
|
'button:has-text("Join now")',
|
||||||
|
'button:has-text("Join meeting")',
|
||||||
|
'[data-tid="joinButton"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of joinSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
this._logger.info('Clicked join button');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not find Join button');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the bot is currently in the lobby (waiting to be admitted).
|
||||||
|
*/
|
||||||
|
async isInMeetingLobby(options: { waitForSeconds?: number } = {}): Promise<boolean> {
|
||||||
|
const timeout = (options.waitForSeconds || 5) * 1000;
|
||||||
|
|
||||||
|
const lobbySelectors = [
|
||||||
|
'[data-tid="lobby-screen"]',
|
||||||
|
':has-text("waiting for someone to let you in")',
|
||||||
|
':has-text("Someone in the meeting should let you in soon")',
|
||||||
|
'[data-tid="waiting-screen"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._page.waitForSelector(lobbySelectors.join(', '), {
|
||||||
|
timeout,
|
||||||
|
state: 'visible',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the bot is currently in the meeting (admitted from lobby).
|
||||||
|
*/
|
||||||
|
async isInMeeting(options: { waitForSeconds?: number } = {}): Promise<boolean> {
|
||||||
|
const timeout = (options.waitForSeconds || 5) * 1000;
|
||||||
|
|
||||||
|
// Indicators that we're in the meeting
|
||||||
|
const inMeetingSelectors = [
|
||||||
|
'[data-tid="call-composite"]',
|
||||||
|
'[data-tid="meeting-roster"]',
|
||||||
|
'[data-tid="hangup-button"]',
|
||||||
|
'button[aria-label*="Leave"]',
|
||||||
|
'[data-tid="callingButtons-showMoreBtn"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._page.waitForSelector(inMeetingSelectors.join(', '), {
|
||||||
|
timeout,
|
||||||
|
state: 'visible',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the meeting gracefully.
|
||||||
|
*/
|
||||||
|
async leaveMeetingFlow(): Promise<void> {
|
||||||
|
this._logger.info('Leaving meeting...');
|
||||||
|
|
||||||
|
const leaveSelectors = [
|
||||||
|
'[data-tid="hangup-button"]',
|
||||||
|
'button[aria-label*="Leave"]',
|
||||||
|
'button:has-text("Leave")',
|
||||||
|
'[data-tid="call-hangup"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of leaveSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
this._logger.info('Clicked leave button');
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.warn('Could not find leave button, closing page');
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/bot/meetingUrlParser.ts
Normal file
83
src/bot/meetingUrlParser.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { ParsedMeetingUrl } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses Teams meeting URLs into a standardized format.
|
||||||
|
* Supports both classic and new (short) URL formats.
|
||||||
|
*
|
||||||
|
* Classic format:
|
||||||
|
* https://teams.microsoft.com/l/meetup-join/19%3ameeting_xxx/0?context=...
|
||||||
|
*
|
||||||
|
* New format (since 2025):
|
||||||
|
* https://teams.microsoft.com/meet/36438888781520?p=5fGqrujxzewPFjJacW
|
||||||
|
*/
|
||||||
|
export function parseMeetingUrl(url: string): ParsedMeetingUrl {
|
||||||
|
const trimmedUrl = url.trim();
|
||||||
|
|
||||||
|
// Check for new short format: /meet/{meetingId}?p={passcode}
|
||||||
|
const shortMatch = trimmedUrl.match(/teams\.microsoft\.com\/meet\/([^?]+)(?:\?p=([^&]+))?/);
|
||||||
|
if (shortMatch) {
|
||||||
|
return {
|
||||||
|
type: 'short',
|
||||||
|
originalUrl: trimmedUrl,
|
||||||
|
meetingId: shortMatch[1],
|
||||||
|
passcode: shortMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for classic format: /l/meetup-join/{encoded-meeting-info}
|
||||||
|
const classicMatch = trimmedUrl.match(/teams\.microsoft\.com\/l\/meetup-join\/([^/]+)/);
|
||||||
|
if (classicMatch) {
|
||||||
|
return {
|
||||||
|
type: 'classic',
|
||||||
|
originalUrl: trimmedUrl,
|
||||||
|
meetingId: classicMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown format - return as-is
|
||||||
|
return {
|
||||||
|
type: 'classic',
|
||||||
|
originalUrl: trimmedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a URL is a valid Teams meeting URL.
|
||||||
|
*/
|
||||||
|
export function isValidMeetingUrl(url: string): boolean {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedUrl = url.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Must be a teams.microsoft.com URL
|
||||||
|
if (!trimmedUrl.includes('teams.microsoft.com')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be either /meet/ or /l/meetup-join/
|
||||||
|
return trimmedUrl.includes('/meet/') || trimmedUrl.includes('/l/meetup-join/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a meeting URL to the web app launch URL.
|
||||||
|
* Teams web app requires a specific format to join meetings.
|
||||||
|
*/
|
||||||
|
export function getMeetingLaunchUrl(url: string): string {
|
||||||
|
const parsed = parseMeetingUrl(url);
|
||||||
|
|
||||||
|
// For short URLs, we can use them directly
|
||||||
|
if (parsed.type === 'short') {
|
||||||
|
return parsed.originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For classic URLs, ensure we're using the web version
|
||||||
|
// Add ?anon=true to skip sign-in prompt for anonymous join
|
||||||
|
let launchUrl = parsed.originalUrl;
|
||||||
|
if (!launchUrl.includes('anon=')) {
|
||||||
|
launchUrl += (launchUrl.includes('?') ? '&' : '?') + 'anon=true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return launchUrl;
|
||||||
|
}
|
||||||
311
src/bot/orchestrator.ts
Normal file
311
src/bot/orchestrator.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { Browser, BrowserContext, Page, chromium } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
import { config } from '../config';
|
||||||
|
import { createSessionLogger } from '../utils/logger';
|
||||||
|
import { BotSession, BotState, TranscriptEntry } from '../types';
|
||||||
|
import { JoinProcedure } from './joinProcedure';
|
||||||
|
import { CaptionsProcedure } from './captionsProcedure';
|
||||||
|
import { AudioProcedure } from './audioProcedure';
|
||||||
|
import { isValidMeetingUrl } from './meetingUrlParser';
|
||||||
|
|
||||||
|
export interface OrchestratorCallbacks {
|
||||||
|
onStateChange: (state: BotState, message?: string) => void;
|
||||||
|
onTranscript: (entry: TranscriptEntry) => void;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the entire bot lifecycle:
|
||||||
|
* - Launches browser
|
||||||
|
* - Joins meeting
|
||||||
|
* - Enables captions
|
||||||
|
* - Handles audio playback
|
||||||
|
* - Leaves meeting
|
||||||
|
*/
|
||||||
|
export class BotOrchestrator {
|
||||||
|
private _sessionId: string;
|
||||||
|
private _meetingUrl: string;
|
||||||
|
private _botName: string;
|
||||||
|
private _logger: Logger;
|
||||||
|
private _callbacks: OrchestratorCallbacks;
|
||||||
|
|
||||||
|
private _browser: Browser | null = null;
|
||||||
|
private _context: BrowserContext | null = null;
|
||||||
|
private _page: Page | null = null;
|
||||||
|
|
||||||
|
private _joinProcedure: JoinProcedure | null = null;
|
||||||
|
private _captionsProcedure: CaptionsProcedure | null = null;
|
||||||
|
private _audioProcedure: AudioProcedure | null = null;
|
||||||
|
|
||||||
|
private _state: BotState = 'idle';
|
||||||
|
private _isShuttingDown: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
sessionId: string,
|
||||||
|
meetingUrl: string,
|
||||||
|
botName: string,
|
||||||
|
callbacks: OrchestratorCallbacks
|
||||||
|
) {
|
||||||
|
this._sessionId = sessionId;
|
||||||
|
this._meetingUrl = meetingUrl;
|
||||||
|
this._botName = botName || config.botName;
|
||||||
|
this._callbacks = callbacks;
|
||||||
|
this._logger = createSessionLogger(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionId(): string {
|
||||||
|
return this._sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): BotState {
|
||||||
|
return this._state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the bot - launch browser, join meeting, enable captions.
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (!isValidMeetingUrl(this._meetingUrl)) {
|
||||||
|
throw new Error(`Invalid meeting URL: ${this._meetingUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._setState('launching');
|
||||||
|
|
||||||
|
// Launch browser
|
||||||
|
await this._launchBrowser();
|
||||||
|
|
||||||
|
this._setState('navigating');
|
||||||
|
|
||||||
|
// Navigate to meeting and handle launcher
|
||||||
|
await this._joinProcedure!.startMeetingLauncherFlow(this._meetingUrl);
|
||||||
|
|
||||||
|
// Join the meeting (enter lobby)
|
||||||
|
await this._joinProcedure!.joinMeetingLobbyFlow();
|
||||||
|
|
||||||
|
// Check if we're in lobby
|
||||||
|
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 10 });
|
||||||
|
if (inLobby) {
|
||||||
|
this._setState('in_lobby');
|
||||||
|
this._logger.info('Bot is in lobby, waiting to be admitted...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait to be admitted to the meeting
|
||||||
|
await this._waitForMeetingAdmission();
|
||||||
|
|
||||||
|
this._setState('in_meeting');
|
||||||
|
this._logger.info('Bot joined the meeting!');
|
||||||
|
|
||||||
|
// Initialize audio
|
||||||
|
await this._audioProcedure!.initialize();
|
||||||
|
|
||||||
|
// Enable and subscribe to captions
|
||||||
|
await this._enableCaptions();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error starting bot:', error);
|
||||||
|
this._setState('error', (error as Error).message);
|
||||||
|
await this._takeScreenshot('error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the bot - leave meeting, close browser.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this._isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isShuttingDown = true;
|
||||||
|
this._logger.info('Stopping bot...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._setState('leaving');
|
||||||
|
|
||||||
|
// Unsubscribe from captions
|
||||||
|
if (this._captionsProcedure) {
|
||||||
|
await this._captionsProcedure.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up audio
|
||||||
|
if (this._audioProcedure) {
|
||||||
|
await this._audioProcedure.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the meeting
|
||||||
|
if (this._joinProcedure && this._state !== 'error') {
|
||||||
|
await this._joinProcedure.leaveMeetingFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error during shutdown:', error);
|
||||||
|
} finally {
|
||||||
|
// Close browser
|
||||||
|
await this._closeBrowser();
|
||||||
|
this._setState('disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play audio in the meeting.
|
||||||
|
*/
|
||||||
|
async playAudio(audioData: string, format: 'mp3' | 'wav' | 'pcm'): Promise<void> {
|
||||||
|
if (this._state !== 'in_meeting' || !this._audioProcedure) {
|
||||||
|
this._logger.warn('Cannot play audio - not in meeting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._audioProcedure.playAudio(audioData, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the browser and create a new page.
|
||||||
|
*/
|
||||||
|
private async _launchBrowser(): Promise<void> {
|
||||||
|
this._logger.info('Launching browser...');
|
||||||
|
|
||||||
|
this._browser = await chromium.launch({
|
||||||
|
headless: config.botHeadless,
|
||||||
|
args: [
|
||||||
|
'--use-fake-ui-for-media-stream', // Auto-accept media permissions
|
||||||
|
'--use-fake-device-for-media-stream', // Use fake devices
|
||||||
|
'--disable-web-security',
|
||||||
|
'--disable-features=IsolateOrigins,site-per-process',
|
||||||
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this._context = await this._browser.newContext({
|
||||||
|
permissions: ['microphone', 'camera'],
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
|
||||||
|
this._page = await this._context.newPage();
|
||||||
|
|
||||||
|
// Initialize procedures
|
||||||
|
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
|
||||||
|
this._captionsProcedure = new CaptionsProcedure(this._page, this._logger, (entry) => {
|
||||||
|
this._callbacks.onTranscript(entry);
|
||||||
|
});
|
||||||
|
this._audioProcedure = new AudioProcedure(this._page, this._logger);
|
||||||
|
|
||||||
|
// Handle page errors
|
||||||
|
this._page.on('pageerror', (error) => {
|
||||||
|
this._logger.error('Page error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page close
|
||||||
|
this._page.on('close', () => {
|
||||||
|
if (!this._isShuttingDown) {
|
||||||
|
this._logger.warn('Page closed unexpectedly');
|
||||||
|
this._setState('disconnected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._logger.info('Browser launched');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the browser.
|
||||||
|
*/
|
||||||
|
private async _closeBrowser(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this._page) {
|
||||||
|
await this._page.close();
|
||||||
|
}
|
||||||
|
if (this._context) {
|
||||||
|
await this._context.close();
|
||||||
|
}
|
||||||
|
if (this._browser) {
|
||||||
|
await this._browser.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error closing browser:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._page = null;
|
||||||
|
this._context = null;
|
||||||
|
this._browser = null;
|
||||||
|
this._logger.info('Browser closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the bot to be admitted from the lobby.
|
||||||
|
*/
|
||||||
|
private async _waitForMeetingAdmission(): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timeout = config.timeouts.lobbyWait;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
// Check if we're in the meeting
|
||||||
|
const inMeeting = await this._joinProcedure!.isInMeeting({ waitForSeconds: 5 });
|
||||||
|
if (inMeeting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if still in lobby
|
||||||
|
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 2 });
|
||||||
|
if (!inLobby) {
|
||||||
|
// Might have been rejected or meeting ended
|
||||||
|
throw new Error('Bot was removed from lobby or meeting ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info('Still waiting in lobby...');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timeout waiting to be admitted from lobby');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable captions and start scraping.
|
||||||
|
*/
|
||||||
|
private async _enableCaptions(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this._captionsProcedure!.enableCaptionsFlow();
|
||||||
|
await this._captionsProcedure!.subscribeToCaptions();
|
||||||
|
this._logger.info('Captions enabled and subscribed');
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.warn('Could not enable captions:', error);
|
||||||
|
// Continue without captions - not a fatal error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the bot state and notify callbacks.
|
||||||
|
*/
|
||||||
|
private _setState(state: BotState, message?: string): void {
|
||||||
|
this._state = state;
|
||||||
|
this._logger.info(`State changed: ${state}${message ? ` - ${message}` : ''}`);
|
||||||
|
this._callbacks.onStateChange(state, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a screenshot for debugging.
|
||||||
|
*/
|
||||||
|
private async _takeScreenshot(name: string): Promise<void> {
|
||||||
|
if (!config.screenshotOnError || !this._page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const screenshotDir = config.screenshotDir;
|
||||||
|
if (!fs.existsSync(screenshotDir)) {
|
||||||
|
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${this._sessionId}-${name}-${Date.now()}.png`;
|
||||||
|
const filepath = path.join(screenshotDir, filename);
|
||||||
|
await this._page.screenshot({ path: filepath, fullPage: true });
|
||||||
|
this._logger.info(`Screenshot saved: ${filepath}`);
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error taking screenshot:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/config.ts
Normal file
36
src/config.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Service
|
||||||
|
port: parseInt(process.env.PORT || '4100', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
gatewayWsUrl: process.env.GATEWAY_WS_URL || 'wss://gateway-int.poweron-center.net/api/teamsbot/ws',
|
||||||
|
|
||||||
|
// Bot
|
||||||
|
botName: process.env.BOT_NAME || 'PowerOn AI',
|
||||||
|
botHeadless: process.env.BOT_HEADLESS !== 'false',
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
logDir: process.env.LOG_DIR || './output/logs',
|
||||||
|
|
||||||
|
// Screenshots
|
||||||
|
screenshotDir: process.env.SCREENSHOT_DIR || './output/screenshots',
|
||||||
|
screenshotOnError: process.env.SCREENSHOT_ON_ERROR === 'true',
|
||||||
|
|
||||||
|
// Timeouts (in milliseconds)
|
||||||
|
timeouts: {
|
||||||
|
lobbyWait: 120000, // 2 minutes waiting in lobby
|
||||||
|
joinTimeout: 30000, // 30 seconds to join
|
||||||
|
captionsEnable: 10000, // 10 seconds to enable captions
|
||||||
|
pageLoad: 30000, // 30 seconds for page load
|
||||||
|
},
|
||||||
|
|
||||||
|
// Teams URLs
|
||||||
|
teamsBaseUrl: 'https://teams.microsoft.com',
|
||||||
|
};
|
||||||
74
src/index.ts
Normal file
74
src/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { SessionManager } from './sessionManager';
|
||||||
|
import { HttpServer } from './server/httpServer';
|
||||||
|
import { logger } from './utils/logger';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
let sessionManager: SessionManager;
|
||||||
|
let httpServer: HttpServer;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
logger.info('Starting Teams Browser Bot Service...');
|
||||||
|
logger.info(`Environment: ${config.nodeEnv}`);
|
||||||
|
logger.info(`Port: ${config.port}`);
|
||||||
|
logger.info(`Gateway URL: ${config.gatewayWsUrl}`);
|
||||||
|
logger.info(`Headless: ${config.botHeadless}`);
|
||||||
|
|
||||||
|
// Initialize session manager
|
||||||
|
sessionManager = new SessionManager();
|
||||||
|
await sessionManager.initialize();
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
httpServer = new HttpServer({
|
||||||
|
onJoinRequest: async (sessionId, meetingUrl, botName) => {
|
||||||
|
await sessionManager.createSession(sessionId, meetingUrl, botName);
|
||||||
|
},
|
||||||
|
onLeaveRequest: async (sessionId) => {
|
||||||
|
await sessionManager.endSession(sessionId);
|
||||||
|
},
|
||||||
|
onStatusRequest: (sessionId) => {
|
||||||
|
return sessionManager.getSessionStatus(sessionId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await httpServer.start();
|
||||||
|
|
||||||
|
logger.info('Teams Browser Bot Service started successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
async function shutdown(signal: string): Promise<void> {
|
||||||
|
logger.info(`Received ${signal}, shutting down...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (httpServer) {
|
||||||
|
await httpServer.stop();
|
||||||
|
}
|
||||||
|
if (sessionManager) {
|
||||||
|
await sessionManager.shutdown();
|
||||||
|
}
|
||||||
|
logger.info('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during shutdown:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
// Handle uncaught errors
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('Uncaught exception:', error);
|
||||||
|
shutdown('uncaughtException');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the service
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.error('Failed to start service:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
238
src/server/gatewayClient.ts
Normal file
238
src/server/gatewayClient.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import {
|
||||||
|
GatewayToBot,
|
||||||
|
BotToGateway,
|
||||||
|
TranscriptMessage,
|
||||||
|
StatusMessage,
|
||||||
|
BotState
|
||||||
|
} from '../types';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
export interface GatewayClientCallbacks {
|
||||||
|
onJoinMeeting: (sessionId: string, meetingUrl: string, botName?: string) => void;
|
||||||
|
onLeaveMeeting: (sessionId: string) => void;
|
||||||
|
onPlayAudio: (sessionId: string, audioData: string, format: 'mp3' | 'wav' | 'pcm') => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket client that connects to the Gateway.
|
||||||
|
* Receives commands (join, leave, play audio) and sends events (transcript, status).
|
||||||
|
*/
|
||||||
|
export class GatewayClient {
|
||||||
|
private _wsUrl: string;
|
||||||
|
private _ws: WebSocket | null = null;
|
||||||
|
private _callbacks: GatewayClientCallbacks;
|
||||||
|
private _logger: Logger;
|
||||||
|
private _reconnectAttempts: number = 0;
|
||||||
|
private _maxReconnectAttempts: number = 10;
|
||||||
|
private _reconnectDelay: number = 1000;
|
||||||
|
private _isConnecting: boolean = false;
|
||||||
|
private _shouldReconnect: boolean = true;
|
||||||
|
|
||||||
|
constructor(wsUrl: string, callbacks: GatewayClientCallbacks) {
|
||||||
|
this._wsUrl = wsUrl;
|
||||||
|
this._callbacks = callbacks;
|
||||||
|
this._logger = logger.child({ component: 'GatewayClient' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the Gateway WebSocket.
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this._isConnecting || (this._ws && this._ws.readyState === WebSocket.OPEN)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isConnecting = true;
|
||||||
|
this._shouldReconnect = true;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._logger.info(`Connecting to Gateway: ${this._wsUrl}`);
|
||||||
|
|
||||||
|
this._ws = new WebSocket(this._wsUrl);
|
||||||
|
|
||||||
|
this._ws.on('open', () => {
|
||||||
|
this._logger.info('Connected to Gateway');
|
||||||
|
this._isConnecting = false;
|
||||||
|
this._reconnectAttempts = 0;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._ws.on('message', (data) => {
|
||||||
|
this._handleMessage(data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
this._ws.on('close', (code, reason) => {
|
||||||
|
this._logger.warn(`Gateway connection closed: ${code} - ${reason}`);
|
||||||
|
this._isConnecting = false;
|
||||||
|
this._ws = null;
|
||||||
|
this._callbacks.onDisconnect();
|
||||||
|
|
||||||
|
if (this._shouldReconnect) {
|
||||||
|
this._scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._ws.on('error', (error) => {
|
||||||
|
this._logger.error('Gateway WebSocket error:', error);
|
||||||
|
this._isConnecting = false;
|
||||||
|
|
||||||
|
if (this._reconnectAttempts === 0) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the Gateway.
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
this._shouldReconnect = false;
|
||||||
|
|
||||||
|
if (this._ws) {
|
||||||
|
this._ws.close(1000, 'Client disconnecting');
|
||||||
|
this._ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a transcript to the Gateway.
|
||||||
|
*/
|
||||||
|
sendTranscript(
|
||||||
|
sessionId: string,
|
||||||
|
speaker: string,
|
||||||
|
text: string,
|
||||||
|
isFinal: boolean = true
|
||||||
|
): void {
|
||||||
|
const message: TranscriptMessage = {
|
||||||
|
type: 'transcript',
|
||||||
|
sessionId,
|
||||||
|
transcript: {
|
||||||
|
speaker,
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
isFinal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this._send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a status update to the Gateway.
|
||||||
|
*/
|
||||||
|
sendStatus(
|
||||||
|
sessionId: string,
|
||||||
|
status: StatusMessage['status'],
|
||||||
|
message?: string
|
||||||
|
): void {
|
||||||
|
const statusMessage: StatusMessage = {
|
||||||
|
type: 'status',
|
||||||
|
sessionId,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._send(statusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map BotState to StatusMessage status.
|
||||||
|
*/
|
||||||
|
mapStateToStatus(state: BotState): StatusMessage['status'] {
|
||||||
|
switch (state) {
|
||||||
|
case 'launching':
|
||||||
|
case 'navigating':
|
||||||
|
return 'connecting';
|
||||||
|
case 'in_lobby':
|
||||||
|
return 'in_lobby';
|
||||||
|
case 'in_meeting':
|
||||||
|
return 'joined';
|
||||||
|
case 'leaving':
|
||||||
|
case 'disconnected':
|
||||||
|
return 'left';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'connecting';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming messages from the Gateway.
|
||||||
|
*/
|
||||||
|
private _handleMessage(data: string): void {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data) as GatewayToBot;
|
||||||
|
|
||||||
|
this._logger.debug('Received message:', { type: message.type });
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'joinMeeting':
|
||||||
|
this._callbacks.onJoinMeeting(
|
||||||
|
message.sessionId,
|
||||||
|
message.meetingUrl,
|
||||||
|
message.botName
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'leaveMeeting':
|
||||||
|
this._callbacks.onLeaveMeeting(message.sessionId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'playAudio':
|
||||||
|
this._callbacks.onPlayAudio(
|
||||||
|
message.sessionId,
|
||||||
|
message.audio.data,
|
||||||
|
message.audio.format
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
this._logger.warn('Unknown message type:', (message as any).type);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error parsing Gateway message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the Gateway.
|
||||||
|
*/
|
||||||
|
private _send(message: BotToGateway): void {
|
||||||
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this._logger.warn('Cannot send message - not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._ws.send(JSON.stringify(message));
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error('Error sending message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a reconnection attempt.
|
||||||
|
*/
|
||||||
|
private _scheduleReconnect(): void {
|
||||||
|
if (this._reconnectAttempts >= this._maxReconnectAttempts) {
|
||||||
|
this._logger.error('Max reconnection attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._reconnectAttempts++;
|
||||||
|
const delay = this._reconnectDelay * Math.pow(2, this._reconnectAttempts - 1);
|
||||||
|
|
||||||
|
this._logger.info(`Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts})`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect().catch((error) => {
|
||||||
|
this._logger.error('Reconnection failed:', error);
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/server/httpServer.ts
Normal file
140
src/server/httpServer.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import express, { Express, Request, Response } from 'express';
|
||||||
|
import { Server } from 'http';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
export interface HttpServerCallbacks {
|
||||||
|
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string) => Promise<void>;
|
||||||
|
onLeaveRequest: (sessionId: string) => Promise<void>;
|
||||||
|
onStatusRequest: (sessionId: string) => { state: string; error?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP server for the Bot Launcher API.
|
||||||
|
* Provides endpoints to deploy/control bots.
|
||||||
|
*/
|
||||||
|
export class HttpServer {
|
||||||
|
private _app: Express;
|
||||||
|
private _server: Server | null = null;
|
||||||
|
private _callbacks: HttpServerCallbacks;
|
||||||
|
|
||||||
|
constructor(callbacks: HttpServerCallbacks) {
|
||||||
|
this._callbacks = callbacks;
|
||||||
|
this._app = express();
|
||||||
|
this._setupMiddleware();
|
||||||
|
this._setupRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP server.
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._server = this._app.listen(config.port, () => {
|
||||||
|
logger.info(`HTTP server listening on port ${config.port}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the HTTP server.
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this._server) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
logger.info('HTTP server stopped');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupMiddleware(): void {
|
||||||
|
this._app.use(express.json());
|
||||||
|
|
||||||
|
// Request logging
|
||||||
|
this._app.use((req, res, next) => {
|
||||||
|
logger.debug(`${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupRoutes(): void {
|
||||||
|
// Health check
|
||||||
|
this._app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deploy a new bot
|
||||||
|
this._app.post('/api/bot', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { sessionId, meetingUrl, botName } = req.body;
|
||||||
|
|
||||||
|
if (!sessionId || !meetingUrl) {
|
||||||
|
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
message: 'Bot deployment started',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deploying bot:', error);
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave meeting
|
||||||
|
this._app.post('/api/bot/:sessionId/leave', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
await this._callbacks.onLeaveRequest(sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
sessionId,
|
||||||
|
message: 'Bot leave initiated',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error leaving meeting:', error);
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get bot status
|
||||||
|
this._app.get('/api/bot/:sessionId/status', (req: Request, res: Response) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const status = this._callbacks.onStatusRequest(sessionId);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
res.status(404).json({ error: 'Session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sessionId,
|
||||||
|
...status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all active bots
|
||||||
|
this._app.get('/api/bots', (req: Request, res: Response) => {
|
||||||
|
// This would need access to the session manager
|
||||||
|
res.json({ message: 'Not implemented' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
210
src/sessionManager.ts
Normal file
210
src/sessionManager.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { BotOrchestrator, OrchestratorCallbacks } from './bot/orchestrator';
|
||||||
|
import { GatewayClient } from './server/gatewayClient';
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bot session and join the meeting.
|
||||||
|
*/
|
||||||
|
async createSession(
|
||||||
|
sessionId: string,
|
||||||
|
meetingUrl: string,
|
||||||
|
botName?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (this._sessions.has(sessionId)) {
|
||||||
|
logger.warn(`Session ${sessionId} already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Creating session ${sessionId} for meeting: ${meetingUrl}`);
|
||||||
|
|
||||||
|
const callbacks: OrchestratorCallbacks = {
|
||||||
|
onStateChange: (state, message) => {
|
||||||
|
this._handleStateChange(sessionId, state, message);
|
||||||
|
},
|
||||||
|
onTranscript: (entry) => {
|
||||||
|
this._handleTranscript(sessionId, entry);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
this._handleError(sessionId, error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const orchestrator = new BotOrchestrator(
|
||||||
|
sessionId,
|
||||||
|
meetingUrl,
|
||||||
|
botName || config.botName,
|
||||||
|
callbacks
|
||||||
|
);
|
||||||
|
|
||||||
|
this._sessions.set(sessionId, orchestrator);
|
||||||
|
|
||||||
|
// Start the bot asynchronously
|
||||||
|
orchestrator.start().catch((error) => {
|
||||||
|
logger.error(`Session ${sessionId} failed to start:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a bot session and leave the meeting.
|
||||||
|
*/
|
||||||
|
async endSession(sessionId: string): Promise<void> {
|
||||||
|
const orchestrator = this._sessions.get(sessionId);
|
||||||
|
if (!orchestrator) {
|
||||||
|
logger.warn(`Session ${sessionId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Ending session ${sessionId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await orchestrator.stop();
|
||||||
|
} finally {
|
||||||
|
this._sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play audio in a session's meeting.
|
||||||
|
*/
|
||||||
|
async playAudio(
|
||||||
|
sessionId: string,
|
||||||
|
audioData: string,
|
||||||
|
format: 'mp3' | 'wav' | 'pcm'
|
||||||
|
): Promise<void> {
|
||||||
|
const orchestrator = this._sessions.get(sessionId);
|
||||||
|
if (!orchestrator) {
|
||||||
|
logger.warn(`Session ${sessionId} not found for audio playback`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await orchestrator.playAudio(audioData, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status of a session.
|
||||||
|
*/
|
||||||
|
getSessionStatus(sessionId: string): { state: string; error?: string } | null {
|
||||||
|
const orchestrator = this._sessions.get(sessionId);
|
||||||
|
if (!orchestrator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: orchestrator.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active session IDs.
|
||||||
|
*/
|
||||||
|
getActiveSessions(): string[] {
|
||||||
|
return Array.from(this._sessions.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown all sessions and disconnect from Gateway.
|
||||||
|
*/
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
logger.info('Shutting down SessionManager...');
|
||||||
|
|
||||||
|
// End all sessions
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle state changes from orchestrators.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle transcripts from orchestrators.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle errors from orchestrators.
|
||||||
|
*/
|
||||||
|
private _handleError(sessionId: string, error: Error): void {
|
||||||
|
logger.error(`Session ${sessionId} error:`, error);
|
||||||
|
|
||||||
|
if (this._gatewayClient) {
|
||||||
|
this._gatewayClient.sendStatus(sessionId, 'error', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/types/index.ts
Normal file
82
src/types/index.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// WebSocket Protocol Types (Gateway <-> Bot)
|
||||||
|
|
||||||
|
export interface PlayAudioMessage {
|
||||||
|
type: 'playAudio';
|
||||||
|
sessionId: string;
|
||||||
|
audio: {
|
||||||
|
format: 'mp3' | 'pcm' | 'wav';
|
||||||
|
data: string; // base64 encoded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptMessage {
|
||||||
|
type: 'transcript';
|
||||||
|
sessionId: string;
|
||||||
|
transcript: {
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
isFinal: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusMessage {
|
||||||
|
type: 'status';
|
||||||
|
sessionId: string;
|
||||||
|
status: 'connecting' | 'in_lobby' | 'joined' | 'left' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinMeetingMessage {
|
||||||
|
type: 'joinMeeting';
|
||||||
|
sessionId: string;
|
||||||
|
meetingUrl: string;
|
||||||
|
botName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveMeetingMessage {
|
||||||
|
type: 'leaveMeeting';
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayToBot = PlayAudioMessage | JoinMeetingMessage | LeaveMeetingMessage;
|
||||||
|
export type BotToGateway = TranscriptMessage | StatusMessage;
|
||||||
|
|
||||||
|
// Bot State
|
||||||
|
export type BotState =
|
||||||
|
| 'idle'
|
||||||
|
| 'launching'
|
||||||
|
| 'navigating'
|
||||||
|
| 'in_lobby'
|
||||||
|
| 'in_meeting'
|
||||||
|
| 'leaving'
|
||||||
|
| 'error'
|
||||||
|
| 'disconnected';
|
||||||
|
|
||||||
|
// Session
|
||||||
|
export interface BotSession {
|
||||||
|
sessionId: string;
|
||||||
|
meetingUrl: string;
|
||||||
|
botName: string;
|
||||||
|
state: BotState;
|
||||||
|
createdAt: Date;
|
||||||
|
joinedAt?: Date;
|
||||||
|
leftAt?: Date;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcript Entry
|
||||||
|
export interface TranscriptEntry {
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
isFinal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meeting URL Types
|
||||||
|
export interface ParsedMeetingUrl {
|
||||||
|
type: 'classic' | 'short';
|
||||||
|
originalUrl: string;
|
||||||
|
meetingId?: string;
|
||||||
|
passcode?: string;
|
||||||
|
}
|
||||||
43
src/utils/logger.ts
Normal file
43
src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import winston from 'winston';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
// Ensure log directory exists
|
||||||
|
if (!fs.existsSync(config.logDir)) {
|
||||||
|
fs.mkdirSync(config.logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ level, message, timestamp, sessionId, ...meta }) => {
|
||||||
|
const sessionPrefix = sessionId ? `[${sessionId}] ` : '';
|
||||||
|
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||||
|
return `${timestamp} ${level.toUpperCase()} ${sessionPrefix}${message}${metaStr}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
level: config.logLevel,
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
logFormat
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(config.logDir, 'error.log'),
|
||||||
|
level: 'error',
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(config.logDir, 'combined.log'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createSessionLogger(sessionId: string) {
|
||||||
|
return logger.child({ sessionId });
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue