From a483aa6def60840a3acb1f5d4441c4104fa4f101 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 16 Feb 2026 09:02:42 +0100
Subject: [PATCH] fix: auth join via direct URL (skip launcher), sequential
audio queue, improve AI prompt (less floskel, stricter response rules)
Co-authored-by: Cursor
---
src/bot/audioProcedure.ts | 40 ++++++++++++++++++++++++++++++++++--
src/bot/joinProcedure.ts | 18 ++++++++++++++++
src/bot/orchestrator.ts | 43 +++++++++++++++++++++++----------------
3 files changed, 82 insertions(+), 19 deletions(-)
diff --git a/src/bot/audioProcedure.ts b/src/bot/audioProcedure.ts
index 5b242f9..8f32732 100644
--- a/src/bot/audioProcedure.ts
+++ b/src/bot/audioProcedure.ts
@@ -20,6 +20,8 @@ export class AudioProcedure {
private _logger: Logger;
private _audioContext: boolean = false;
private _initScriptInjected: boolean = false;
+ private _audioQueue: Array<{ audioData: string; format: 'mp3' | 'wav' | 'pcm' }> = [];
+ private _isPlaying: boolean = false;
constructor(page: Page, logger: Logger) {
this._page = page;
@@ -113,13 +115,47 @@ export class AudioProcedure {
}
/**
- * Play audio in the browser.
- * Audio is piped into the MediaStreamDestination that Teams uses as mic input.
+ * Queue audio for sequential playback.
+ * Audio is never played in parallel -- each clip waits for the previous one to finish.
*
* @param audioData Base64 encoded audio data
* @param format Audio format (mp3, wav, pcm)
*/
async playAudio(audioData: string, format: 'mp3' | 'wav' | 'pcm'): Promise {
+ // Add to queue
+ this._audioQueue.push({ audioData, format });
+ this._logger.info(`Audio queued (queue size: ${this._audioQueue.length}, playing: ${this._isPlaying})`);
+
+ // If not currently playing, start processing the queue
+ if (!this._isPlaying) {
+ await this._processAudioQueue();
+ }
+ }
+
+ /**
+ * Process the audio queue sequentially.
+ */
+ private async _processAudioQueue(): Promise {
+ if (this._isPlaying) return;
+ this._isPlaying = true;
+
+ while (this._audioQueue.length > 0) {
+ const item = this._audioQueue.shift()!;
+ try {
+ await this._playAudioInternal(item.audioData, item.format);
+ } catch (error) {
+ this._logger.error('Error playing queued audio:', error);
+ }
+ }
+
+ this._isPlaying = false;
+ }
+
+ /**
+ * Internal: Play audio in the browser (single clip, no queuing).
+ * Audio is piped into the MediaStreamDestination that Teams uses as mic input.
+ */
+ private async _playAudioInternal(audioData: string, format: 'mp3' | 'wav' | 'pcm'): Promise {
if (!this._audioContext) {
await this.initialize();
}
diff --git a/src/bot/joinProcedure.ts b/src/bot/joinProcedure.ts
index 7a64fb6..5d59a7d 100644
--- a/src/bot/joinProcedure.ts
+++ b/src/bot/joinProcedure.ts
@@ -54,6 +54,24 @@ export class JoinProcedure {
await this._handleLauncherDialog();
}
+ /**
+ * Check if a launcher dialog is present and handle it.
+ * Used for authenticated joins where we navigate directly to the meeting URL
+ * but Teams may still show the launcher.
+ */
+ async handleLauncherIfPresent(): Promise {
+ try {
+ const launcherButton = await this._page.$('button[data-tid="joinOnWeb"]');
+ if (launcherButton) {
+ this._logger.info('Launcher dialog found after direct navigation, clicking "Continue on this browser"');
+ await launcherButton.click();
+ await this._page.waitForTimeout(2000);
+ }
+ } catch {
+ // No launcher - that's fine for authenticated joins
+ }
+ }
+
/**
* Handle the launcher dialog that asks how to join.
* Primary selector: button[data-tid="joinOnWeb"] (confirmed working in Recall.ai).
diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts
index 1e20f90..389bf96 100644
--- a/src/bot/orchestrator.ts
+++ b/src/bot/orchestrator.ts
@@ -168,25 +168,34 @@ export class BotOrchestrator {
this._setState('navigating');
- // Navigate to meeting and handle launcher
- await this._joinProcedure.startMeetingLauncherFlow(this._meetingUrl);
+ if (authenticate) {
+ // AUTHENTICATED JOIN: Navigate directly to the original meeting URL
+ // within the Teams v2 web app context. The launcher (/dl/launcher/) always
+ // redirects to the "light-meetings" anonymous experience regardless of auth.
+ // Instead, navigate directly to the original meeting URL -- Teams v2 will
+ // recognize the auth session and show the authenticated pre-join screen.
+ this._logger.info(`Authenticated: navigating directly to meeting URL: ${this._meetingUrl}`);
+ await this._page!.goto(this._meetingUrl, {
+ waitUntil: 'domcontentloaded',
+ timeout: 30000,
+ });
- // After navigation, check if Teams put us on the anonymous page despite auth
- if (authenticate && this._page) {
- const currentUrl = this._page.url();
- if (currentUrl.includes('anon=true') || currentUrl.includes('light-meetings/launch')) {
- this._logger.warn(`Teams redirected to anonymous mode despite auth. URL: ${currentUrl}`);
- // Strip anon params and re-navigate
- const cleanUrl = currentUrl
- .replace(/[&?]anon=true/gi, '')
- .replace(/%26anon%3Dtrue/gi, '');
- this._logger.info(`Re-navigating without anon: ${cleanUrl}`);
- await this._page.goto(cleanUrl, {
- waitUntil: 'domcontentloaded',
- timeout: 30000,
- });
- await this._page.waitForTimeout(3000);
+ // Teams may show the launcher dialog even for direct URLs -- handle it
+ await this._joinProcedure.handleLauncherIfPresent();
+
+ // Wait for the pre-join page to stabilize
+ await this._page!.waitForTimeout(3000);
+
+ // Verify we're on an authenticated page (no "Type your name" input)
+ const pageText = await this._page!.evaluate(() => document.body?.innerText?.substring(0, 500) || '');
+ if (pageText.includes('Enter the name') || pageText.includes('Type your name')) {
+ this._logger.warn('Still on anonymous page after auth navigation - auth session may not have transferred');
+ } else {
+ this._logger.info('On authenticated pre-join page (no name input required)');
}
+ } else {
+ // ANONYMOUS JOIN: Use the launcher flow (resolves URL, adds anon params)
+ await this._joinProcedure.startMeetingLauncherFlow(this._meetingUrl);
}
// Set virtual background if configured (must be done on pre-join screen, before "Join now")