diff --git a/src/bot/audioProcedure.ts b/src/bot/audioProcedure.ts index 2d995b6..81d46ab 100644 --- a/src/bot/audioProcedure.ts +++ b/src/bot/audioProcedure.ts @@ -24,6 +24,10 @@ export type AudioProcedureOptions = { avatarBgColor?: string; /** Hex/CSS color of the centered display label (default: dark blue). */ avatarTextColor?: string; + /** Base64-encoded image/video data for custom bot avatar. */ + avatarMediaData?: string; + /** MIME type of the avatar media (e.g. image/png, video/mp4). */ + avatarMediaType?: string; }; export class AudioProcedure { @@ -33,6 +37,8 @@ export class AudioProcedure { private _displayLabel: string; private _avatarBgColor: string; private _avatarTextColor: string; + private _avatarMediaData?: string; + private _avatarMediaType?: string; private _audioContext: boolean = false; private _initScriptInjected: boolean = false; private _audioQueue: Array<{ audioData: string; format: 'mp3' | 'wav' | 'pcm' }> = []; @@ -46,6 +52,8 @@ export class AudioProcedure { this._displayLabel = (options?.displayLabel || 'Bot').trim() || 'Bot'; this._avatarBgColor = (options?.avatarBgColor || '').trim() || '#a8d4f0'; this._avatarTextColor = (options?.avatarTextColor || '').trim() || '#1a3552'; + this._avatarMediaData = options?.avatarMediaData; + this._avatarMediaType = options?.avatarMediaType; } /** @@ -69,6 +77,8 @@ export class AudioProcedure { displayLabel: this._displayLabel, avatarBgColor: this._avatarBgColor, avatarTextColor: this._avatarTextColor, + avatarMediaData: this._avatarMediaData, + avatarMediaType: this._avatarMediaType, }); this._initScriptInjected = true; @@ -85,6 +95,8 @@ export class AudioProcedure { displayLabel: this._displayLabel, avatarBgColor: this._avatarBgColor, avatarTextColor: this._avatarTextColor, + avatarMediaData: this._avatarMediaData, + avatarMediaType: this._avatarMediaType, }; for (const frame of this._page.frames()) { try { diff --git a/src/bot/mediaGetUserMediaPatch.ts b/src/bot/mediaGetUserMediaPatch.ts index 6988342..5737b07 100644 --- a/src/bot/mediaGetUserMediaPatch.ts +++ b/src/bot/mediaGetUserMediaPatch.ts @@ -12,6 +12,10 @@ export type MediaGetUserMediaPatchOptions = { avatarBgColor?: string; /** Hex/CSS color of the centered display label (default: dark blue). */ avatarTextColor?: string; + /** Base64-encoded image/video data for custom bot avatar. */ + avatarMediaData?: string; + /** MIME type of the avatar media (e.g. image/png, video/mp4). */ + avatarMediaType?: string; }; export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => { @@ -19,6 +23,8 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => const { useCanvasVideo, displayLabel } = opts; const avatarBgColor = opts.avatarBgColor || '#a8d4f0'; const avatarTextColor = opts.avatarTextColor || '#1a3552'; + const avatarMediaData = opts.avatarMediaData || ''; + const avatarMediaType = opts.avatarMediaType || ''; const w: any = window as any; if (!w.__gumChromium) { @@ -169,11 +175,9 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => return; } - // Low fps: the avatar is intentionally STATIC. The interval still ticks so - // captureStream() in headless Chromium gets fresh frames (some Chromium - // builds pause the track if no new frames arrive), but each tick draws an - // identical image — no animation, no flicker. - const _fps = 2; + const _hasCustomMedia = !!(avatarMediaData && avatarMediaType); + const _isCustomVideo = _hasCustomMedia && avatarMediaType.startsWith('video/'); + const _fps = _isCustomVideo ? 15 : 2; w.__startBotAvatarStream = () => { if ( w.__botAvatarStreamStarted @@ -196,8 +200,8 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => w.__botAvatarStreamStarted = true; w.__botAvatarDisplayLabel = displayLabel; const canvas = document.createElement('canvas'); - canvas.width = 640; - canvas.height = 360; + canvas.width = _hasCustomMedia ? 1280 : 640; + canvas.height = _hasCustomMedia ? 720 : 360; canvas.setAttribute('data-poweron-avatar', '1'); // Render at a real size so the compositor produces frames in headless mode. // captureStream() in headless Chromium can stall when the canvas is 0/invisible. @@ -206,10 +210,10 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => (document.body || document.documentElement).appendChild(canvas); w.__botAvatarCanvas = canvas; const c2d = canvas.getContext('2d'); - const draw = () => { - if (!c2d) { - return; - } + const _isVideo = avatarMediaType.startsWith('video/'); + + const _drawFallback = () => { + if (!c2d) return; const wPx = canvas.width; const hPx = canvas.height; c2d.fillStyle = avatarBgColor; @@ -221,6 +225,58 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => const line = (w.__botAvatarDisplayLabel || displayLabel).toString().slice(0, 72); c2d.fillText(line, wPx / 2, hPx / 2); }; + + let _mediaReady = false; + let _mediaElement: any = null; + + if (avatarMediaData && avatarMediaType) { + const dataUrl = 'data:' + avatarMediaType + ';base64,' + avatarMediaData; + if (_isVideo) { + const video = document.createElement('video'); + video.src = dataUrl; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.style.display = 'none'; + (document.body || document.documentElement).appendChild(video); + video.play().catch(() => { /* autoplay blocked — fall back to static */ }); + video.addEventListener('playing', () => { _mediaReady = true; }, { once: true }); + _mediaElement = video; + } else { + const img = new Image(); + img.src = dataUrl; + img.onload = () => { _mediaReady = true; }; + _mediaElement = img; + } + } + + const draw = () => { + if (!c2d) return; + if (_mediaReady && _mediaElement) { + try { + const cW = canvas.width; + const cH = canvas.height; + const srcW = _isVideo + ? (_mediaElement as HTMLVideoElement).videoWidth || cW + : (_mediaElement as HTMLImageElement).naturalWidth || cW; + const srcH = _isVideo + ? (_mediaElement as HTMLVideoElement).videoHeight || cH + : (_mediaElement as HTMLImageElement).naturalHeight || cH; + const scale = Math.min(cW / srcW, cH / srcH); + const dW = srcW * scale; + const dH = srcH * scale; + const dX = (cW - dW) / 2; + const dY = (cH - dH) / 2; + c2d.fillStyle = avatarBgColor; + c2d.fillRect(0, 0, cW, cH); + c2d.drawImage(_mediaElement, dX, dY, dW, dH); + return; + } catch { + // corrupted frame — fall through to fallback + } + } + _drawFallback(); + }; draw(); // Capture at fps for compositor-driven frames AND also push manual frames // via requestFrame() each tick for headless reliability. @@ -230,9 +286,7 @@ export const poweronMediaPatchInstall = (opts: MediaGetUserMediaPatchOptions) => if (w.__botAvatarVideoTrack) { w.__botAvatarVideoTrack.enabled = true; try { - // 'detail' = static / low-motion content -> WebRTC uses lower - // bitrate + preserves text sharpness instead of motion smoothing. - w.__botAvatarVideoTrack.contentHint = 'detail'; + w.__botAvatarVideoTrack.contentHint = _isCustomVideo ? 'motion' : 'detail'; } catch { // ignore } diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index becd907..9f0eff9 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -35,6 +35,10 @@ export interface OrchestratorOptions { botAccountPassword?: string; transferMode?: string; debugMode?: boolean; + /** Base64-encoded image/video data for custom bot avatar. */ + avatarMediaData?: string; + /** MIME type of the avatar media (e.g. image/png, video/mp4). */ + avatarMediaType?: string; } /** @@ -253,11 +257,13 @@ export class BotOrchestrator { this._startCanvasRebindAfterJoin(); } - // Enable transcript capture (captions or audio based on transferMode) - await this._enableTranscriptCapture(); - - // Enable chat monitoring - await this._enableChat(); + // Anonymous bots cannot activate Teams captions — skip caption wait, + // only start audio capture + chat in parallel. + await Promise.all([ + this._enableAudioCapture(), + this._enableChat(), + ]); + this._logger.info('Skipping captions for anonymous bot (no permission in Teams)'); await this._takeScreenshot('anon-step5-ready', this._isDebugMode); // Send greeting in meeting chat @@ -460,8 +466,10 @@ export class BotOrchestrator { await this._ensureCameraOnInMeeting(); this._startCanvasRebindAfterJoin(); } - await this._enableTranscriptCapture(); - await this._enableChat(); + await Promise.all([ + this._enableTranscriptCapture(), + this._enableChat(), + ]); await this._sendJoinGreeting(); } @@ -1164,6 +1172,8 @@ export class BotOrchestrator { displayLabel: this._botName, avatarBgColor: config.botAvatarBgColor, avatarTextColor: config.botAvatarTextColor, + avatarMediaData: this._options.avatarMediaData, + avatarMediaType: this._options.avatarMediaType, }); this._teamsActions = new TeamsActionsService(this._page, this._logger); this._chatProcedure = new ChatProcedure( diff --git a/src/index.ts b/src/index.ts index 5efc754..e1b605d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,8 @@ async function main(): Promise { // Start HTTP server httpServer = new HttpServer({ - onJoinRequest: async (sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode) => { - await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode); + onJoinRequest: async (sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode, avatarMediaData, avatarMediaType) => { + await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode, avatarMediaData, avatarMediaType); }, onLeaveRequest: async (sessionId) => { await sessionManager.endSession(sessionId); diff --git a/src/server/httpServer.ts b/src/server/httpServer.ts index 087d457..5264509 100644 --- a/src/server/httpServer.ts +++ b/src/server/httpServer.ts @@ -7,7 +7,7 @@ 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; + onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, transferMode?: string, debugMode?: boolean, avatarMediaData?: string, avatarMediaType?: string) => Promise; onLeaveRequest: (sessionId: string) => Promise; onStatusRequest: (sessionId: string) => { state: string; error?: string } | null; } @@ -62,7 +62,7 @@ export class HttpServer { } private _setupMiddleware(): void { - this._app.use(express.json()); + this._app.use(express.json({ limit: '50mb' })); // Request logging this._app.use((req, res, next) => { @@ -80,16 +80,16 @@ export class HttpServer { // 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; + const { sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode, avatarMediaData, avatarMediaType } = req.body; - logger.debug(`POST /api/bot: sessionId=${sessionId}, hasEmail=${!!botAccountEmail}, hasPassword=${!!botAccountPassword}, transferMode=${transferMode}, debugMode=${debugMode}`); + logger.debug(`POST /api/bot: sessionId=${sessionId}, hasEmail=${!!botAccountEmail}, hasPassword=${!!botAccountPassword}, transferMode=${transferMode}, debugMode=${debugMode}, hasAvatar=${!!avatarMediaData}`); 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); + await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, transferMode, debugMode, avatarMediaData, avatarMediaType); res.json({ success: true, diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 5219a39..fcd32e5 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -43,6 +43,8 @@ export class SessionManager { botAccountPassword?: string, transferMode?: string, debugMode?: boolean, + avatarMediaData?: string, + avatarMediaType?: string, ): Promise { if (this._sessions.has(sessionId)) { logger.warn(`Session ${sessionId} already exists`); @@ -78,6 +80,8 @@ export class SessionManager { botAccountPassword: botAccountPassword, transferMode: transferMode, debugMode: debugMode, + avatarMediaData: avatarMediaData, + avatarMediaType: avatarMediaType, }; const orchestrator = new BotOrchestrator(