service-teams-browser-bot/src/server/httpServer.ts
ValueOn AG 9a2994672c feat: add debugMode flag to gate screenshots, filter bot own captions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 23:52:50 +01:00

246 lines
8.1 KiB
TypeScript

import express, { Express, Request, Response } from 'express';
import { Server } from 'http';
import path from 'path';
import fs from 'fs';
import { logger } from '../utils/logger';
import { config } from '../config';
import { runAuthTests, runSingleVariant, getVariantIds } from '../bot/authTestProcedure';
export interface HttpServerCallbacks {
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, transferMode?: string, debugMode?: boolean) => 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, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode } = req.body;
if (!sessionId || !meetingUrl) {
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
return;
}
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode);
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' });
});
// Get list of available test variants
this._app.get('/api/bot/test-auth/variants', (_req: Request, res: Response) => {
res.json(getVariantIds());
});
// Run a SINGLE test variant (stays within Azure 240s timeout)
this._app.post('/api/bot/test-auth/variant', async (req: Request, res: Response) => {
try {
const { variantId, meetingUrl, botAccountEmail, botAccountPassword } = req.body;
if (!meetingUrl || !variantId) {
res.status(400).json({ error: 'Missing required fields: variantId, meetingUrl' });
return;
}
logger.info(`Running single auth test variant: ${variantId} for ${meetingUrl}`);
const result = await runSingleVariant(variantId, meetingUrl, botAccountEmail, botAccountPassword);
res.json(result);
} catch (error) {
logger.error('Error running single variant:', error);
res.status(500).json({ error: (error as Error).message });
}
});
// Run ALL auth detection tests (legacy — may timeout on Azure with many variants)
this._app.post('/api/bot/test-auth', async (req: Request, res: Response) => {
try {
const { meetingUrl, botAccountEmail, botAccountPassword } = req.body;
if (!meetingUrl) {
res.status(400).json({ error: 'Missing required field: meetingUrl' });
return;
}
const hasEmail = !!botAccountEmail;
const hasPassword = !!botAccountPassword;
logger.info(`Starting auth detection tests for: ${meetingUrl} (credentials: email=${hasEmail}, password=${hasPassword})`);
const results = await runAuthTests(meetingUrl, botAccountEmail, botAccountPassword);
(results as any).credentialsReceived = { hasEmail, hasPassword };
res.json(results);
} catch (error) {
logger.error('Error running auth tests:', error);
res.status(500).json({ error: (error as Error).message });
}
});
// List screenshots for a session
// Filename format: {sessionId}-{step}-{timestamp}.png
this._app.get('/api/bot/screenshots/:sessionId', (req: Request, res: Response) => {
try {
const { sessionId } = req.params;
const screenshotDir = config.screenshotDir;
if (!fs.existsSync(screenshotDir)) {
res.json({ screenshots: [] });
return;
}
const files = fs.readdirSync(screenshotDir)
.filter(f => f.startsWith(`${sessionId}-`) && f.endsWith('.png'))
.sort();
const screenshots = files.map(name => {
const stats = fs.statSync(path.join(screenshotDir, name));
const withoutExt = name.replace('.png', '');
const parts = withoutExt.split('-');
// sessionId is a UUID (5 parts joined by -), step follows, timestamp is last
const timestamp = parseInt(parts[parts.length - 1], 10) || 0;
const step = parts.slice(5, -1).join('-');
return { name, step, timestamp, sizeBytes: stats.size };
});
res.json({ screenshots });
} catch (error) {
logger.error('Error listing screenshots:', error);
res.status(500).json({ error: (error as Error).message });
}
});
// Serve a single screenshot file
this._app.get('/api/bot/screenshots/file/:filename', (req: Request, res: Response) => {
try {
const { filename } = req.params;
if (!filename.endsWith('.png') || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
const filepath = path.join(config.screenshotDir, filename);
if (!fs.existsSync(filepath)) {
res.status(404).json({ error: 'Screenshot not found' });
return;
}
res.type('image/png').sendFile(path.resolve(filepath));
} catch (error) {
logger.error('Error serving screenshot:', error);
res.status(500).json({ error: (error as Error).message });
}
});
}
}