From 31c196978b825c734ee612004b259770c13cc920 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 15 Feb 2026 15:19:32 +0100
Subject: [PATCH] fix: audio routing via MediaStream, auth context-destroyed
handling, session cleanup
Co-authored-by: Cursor
---
src/bot/audioProcedure.ts | 27 +++++++++++++++++++++++---
src/bot/authProcedure.ts | 41 +++++++++++++++++++++++++++++++++++++++
src/sessionManager.ts | 30 ++++++++++++++++++++++++----
3 files changed, 91 insertions(+), 7 deletions(-)
diff --git a/src/bot/audioProcedure.ts b/src/bot/audioProcedure.ts
index 97b2da1..7927657 100644
--- a/src/bot/audioProcedure.ts
+++ b/src/bot/audioProcedure.ts
@@ -29,9 +29,28 @@ export class AudioProcedure {
await this._page.evaluate(() => {
// Create a global audio context
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
- (window as any).__audioContext = new AudioContext();
+ const ctx = new AudioContext();
+ (window as any).__audioContext = ctx;
(window as any).__audioQueue = [];
(window as any).__isPlaying = false;
+
+ // Create a MediaStream destination so audio is routed into the
+ // browser's virtual microphone (picked up by Teams) instead of
+ // the default speaker output (ctx.destination).
+ const streamDest = ctx.createMediaStreamDestination();
+ (window as any).__audioStreamDest = streamDest;
+
+ // Expose the stream so headless Chromium can pipe it as mic input.
+ // navigator.mediaDevices.getUserMedia will be overridden to return this stream.
+ const audioStream = streamDest.stream;
+ const originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
+ // If requesting audio only, return our TTS stream
+ if (constraints && constraints.audio && !constraints.video) {
+ return audioStream;
+ }
+ return originalGetUserMedia(constraints);
+ };
});
this._audioContext = true;
@@ -83,10 +102,12 @@ export class AudioProcedure {
audioBuffer = await ctx.decodeAudioData(bytes.buffer);
}
- // Create source and play
+ // Create source and play through the MediaStream destination
+ // so audio is routed into the Teams microphone input, not speakers
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
- source.connect(ctx.destination);
+ const streamDest = (window as any).__audioStreamDest as MediaStreamAudioDestinationNode;
+ source.connect(streamDest || ctx.destination);
source.start(0);
// Return a promise that resolves when playback ends
diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts
index ed137ac..b7f6953 100644
--- a/src/bot/authProcedure.ts
+++ b/src/bot/authProcedure.ts
@@ -197,9 +197,43 @@ export class AuthProcedure {
/**
* Verify that authentication was successful.
* Checks if we're on a Microsoft/Teams page with an authenticated session.
+ *
+ * Note: After successful login, Microsoft often triggers navigation/redirects
+ * which can destroy the execution context. An "Execution context was destroyed"
+ * error is treated as a successful login (navigation = login worked).
*/
private async _verifyAuthentication(): Promise {
try {
+ // Wait for navigation that indicates login succeeded (redirect to Teams/Office)
+ try {
+ await this._page.waitForNavigation({
+ url: (url) =>
+ url.href.includes('teams.microsoft.com') ||
+ url.href.includes('office.com') ||
+ url.href.includes('myapps.microsoft.com') ||
+ url.href.includes('microsoftonline.com/common/oauth2'),
+ timeout: 15000,
+ });
+ this._logger.info('Navigation detected after login - authentication succeeded');
+ return true;
+ } catch (navError) {
+ const errorMessage = String(navError);
+
+ // "Execution context was destroyed" means the page navigated away
+ // from the login page, which indicates a successful login redirect
+ if (errorMessage.includes('Execution context was destroyed') ||
+ errorMessage.includes('execution context') ||
+ errorMessage.includes('navigation')) {
+ this._logger.info('Execution context destroyed during verification - treating as successful login (page navigated)');
+ // Give the page a moment to settle after navigation
+ await this._page.waitForTimeout(2000);
+ return true;
+ }
+
+ // Timeout - check where we ended up
+ this._logger.debug(`waitForNavigation did not match expected URL: ${navError}`);
+ }
+
const url = this._page.url();
// If we're on Teams or Microsoft portal, we're authenticated
@@ -237,6 +271,13 @@ export class AuthProcedure {
// If we're somewhere else entirely, assume authenticated
return true;
} catch (error) {
+ const errorMessage = String(error);
+ // Catch "Execution context was destroyed" at the top level too
+ if (errorMessage.includes('Execution context was destroyed') ||
+ errorMessage.includes('execution context')) {
+ this._logger.info('Execution context destroyed during verification (top-level) - treating as successful login');
+ return true;
+ }
this._logger.error(`Authentication verification error: ${error}`);
return false;
}
diff --git a/src/sessionManager.ts b/src/sessionManager.ts
index 8dc6828..4b48d5d 100644
--- a/src/sessionManager.ts
+++ b/src/sessionManager.ts
@@ -96,11 +96,13 @@ export class SessionManager {
/**
* End a bot session and leave the meeting.
+ * Robust: handles cases where the session was already cleaned up
+ * (e.g. disconnected state removed it from the map).
*/
async endSession(sessionId: string): Promise {
const orchestrator = this._sessions.get(sessionId);
if (!orchestrator) {
- logger.warn(`Session ${sessionId} not found`);
+ logger.warn(`Session ${sessionId} not found for endSession - may have already been cleaned up`);
return;
}
@@ -108,7 +110,10 @@ export class SessionManager {
try {
await orchestrator.stop();
+ } catch (error) {
+ logger.error(`Error stopping session ${sessionId}:`, error);
} finally {
+ // Always remove from map after explicit end
this._sessions.delete(sessionId);
}
}
@@ -166,14 +171,31 @@ export class SessionManager {
/**
* Handle state changes from orchestrators.
+ *
+ * IMPORTANT: Do NOT delete from _sessions on 'disconnected' state.
+ * The orchestrator may enter 'disconnected' due to a transient WebSocket
+ * drop or browser crash. If we delete here, the Gateway's subsequent
+ * 'leave' command won't find the session in endSession().
+ * Cleanup is done explicitly in endSession() or shutdown().
+ * Only auto-remove on terminal 'error' state after a delay so the
+ * Gateway still has time to call endSession() first.
*/
private _handleStateChange(sessionId: string, state: BotState, message?: string): void {
logger.info(`Session ${sessionId} state: ${state}${message ? ` - ${message}` : ''}`);
- // Clean up if disconnected or error
- if (state === 'disconnected' || state === 'error') {
- this._sessions.delete(sessionId);
+ if (state === 'error') {
+ // Give Gateway a grace period to call endSession(), then auto-cleanup
+ setTimeout(() => {
+ if (this._sessions.has(sessionId)) {
+ const orch = this._sessions.get(sessionId);
+ if (orch && orch.state === 'error') {
+ logger.info(`Auto-cleaning stale error session ${sessionId}`);
+ this._sessions.delete(sessionId);
+ }
+ }
+ }, 30000); // 30s grace period
}
+ // 'disconnected' state: do NOT delete - let endSession() handle it
}
/**