From b8bb5affa9e9311981f057fade07f95cf4c86370 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 16 Feb 2026 20:37:08 +0100 Subject: [PATCH] disable auth flow: bot joins anonymously with system bot display name Co-authored-by: Cursor --- src/bot/joinProcedure.ts | 119 +++---------------------- src/bot/meetingUrlParser.ts | 41 +++------ src/bot/orchestrator.ts | 169 +++++------------------------------- 3 files changed, 47 insertions(+), 282 deletions(-) diff --git a/src/bot/joinProcedure.ts b/src/bot/joinProcedure.ts index b63f9cf..d433f2d 100644 --- a/src/bot/joinProcedure.ts +++ b/src/bot/joinProcedure.ts @@ -9,40 +9,37 @@ import { resolveLaunchUrl, getMeetingLaunchUrl } from './meetingUrlParser'; * * Teams web UI uses `id` attributes (not `data-tid`) for many interactive elements * since the 2025 redesign. Selectors updated accordingly. + * + * NOTE: The bot always joins as an anonymous guest with the configured bot name. + * Authentication is disabled. See Teamsbot-Auth-Join-Learnings.md. */ export class JoinProcedure { private _page: Page; private _logger: Logger; private _botName: string; - private _isAuthenticated: boolean; - private _meetingUrl: string; - constructor(page: Page, logger: Logger, botName: string, isAuthenticated: boolean = false, meetingUrl: string = '') { + constructor(page: Page, logger: Logger, botName: string) { this._page = page; this._logger = logger; this._botName = botName; - this._isAuthenticated = isAuthenticated; - this._meetingUrl = meetingUrl; } /** * Navigate to the meeting URL and handle the launcher dialog. * * Teams meeting URLs redirect through several hops. We resolve the redirect - * and add params (suppressPrompt, msLaunch=false, etc.) to skip the + * and add params (suppressPrompt, msLaunch=false, anon=true) to skip the * "Open in Teams app?" native dialog. Then we click "Continue on this browser". - * - * For authenticated joins, anon=true is omitted and the name input is skipped. */ async startMeetingLauncherFlow(meetingUrl: string): Promise { // Resolve the meeting URL redirect and add suppressPrompt params let launchUrl: string; try { - launchUrl = await resolveLaunchUrl(meetingUrl, this._isAuthenticated); - this._logger.info(`Resolved launch URL: ${launchUrl} (authenticated: ${this._isAuthenticated})`); + launchUrl = await resolveLaunchUrl(meetingUrl); + this._logger.info(`Resolved launch URL: ${launchUrl}`); } catch (error) { this._logger.warn(`Could not resolve launch URL, using fallback: ${error}`); - launchUrl = getMeetingLaunchUrl(meetingUrl, this._isAuthenticated); + launchUrl = getMeetingLaunchUrl(meetingUrl); } this._logger.info(`Navigating to meeting: ${launchUrl}`); @@ -58,19 +55,18 @@ export class JoinProcedure { /** * 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. + * Teams may show the launcher when navigating directly to a meeting URL. */ 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"'); + this._logger.info('Launcher dialog found, clicking "Continue on this browser"'); await launcherButton.click(); await this._page.waitForTimeout(2000); } } catch { - // No launcher - that's fine for authenticated joins + // No launcher - that's fine } } @@ -128,100 +124,13 @@ export class JoinProcedure { * Fill in the bot name and click "Join now" to enter the lobby. */ async joinMeetingLobbyFlow(): Promise { - this._logger.info(`Starting lobby join flow... (authenticated: ${this._isAuthenticated})`); + this._logger.info('Starting lobby join flow...'); - if (this._isAuthenticated) { - // Authenticated join: Auth cookies are set but we're on the light-meetings - // page (Teams blocks /v2/ for headless browsers). The light-meetings page - // REQUIRES a name to be entered — without it, "Join now" fails with - // "All promises were rejected". We enter the bot name AND rely on auth - // cookies for identification. Teams will show the user as authenticated. - this._logger.info('Authenticated join on light-meetings: entering name (required by light-meetings)...'); - await this._enterBotName(); - } else { - // Anonymous join: enter bot name in the name input field - await this._enterBotName(); - } - - // Click "Join now" + // Enter bot name in the name input field, then click "Join now" + await this._enterBotName(); await this._clickJoinNow(); } - /** - * Wait for the authenticated pre-join page to be ready. - * - * Verification: The authenticated page does NOT have a name input field. - * If a name input (placeholder="Type your name") exists, we're still on the - * anonymous page and need to wait for the redirect to complete. - * - * Retries 5 times, every 5 seconds. - */ - private async _waitForAuthenticatedPreJoinPage(): Promise { - const maxRetries = 5; - const retryIntervalMs = 5000; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - const url = this._page.url(); - const hasNameInput = await this._page.$('input[placeholder="Type your name"]'); - const hasJoinButton = await this._page.$('#prejoin-join-button, button[data-tid="prejoin-join-button"], button:has-text("Join now")'); - - if (hasJoinButton && !hasNameInput) { - this._logger.info(`Authenticated pre-join page confirmed (attempt ${attempt}/${maxRetries}): Join button present, no name input. URL: ${url.substring(0, 100)}`); - return; - } - - const nameStatus = hasNameInput ? 'name input FOUND (wrong page)' : 'no name input'; - const joinStatus = hasJoinButton ? 'Join button found' : 'no Join button'; - this._logger.info(`Waiting for authenticated pre-join page (attempt ${attempt}/${maxRetries}): ${nameStatus}, ${joinStatus}. URL: ${url.substring(0, 100)}`); - - if (attempt < maxRetries) { - await this._page.waitForTimeout(retryIntervalMs); - } - } - - // Fallback: The automatic redirect to /v2/ did not happen (common in headless browsers). - // Manually navigate to the /v2/ authenticated pre-join URL with auth cookies. - // Format: https://teams.microsoft.com/v2/?meetingjoin=true#/meet/{meetingId}?p={passcode} - if (this._meetingUrl) { - const v2Url = this._buildV2MeetingUrl(this._meetingUrl); - this._logger.info(`Redirect to /v2/ did not happen. Navigating manually to: ${v2Url}`); - await this._page.goto(v2Url, { waitUntil: 'domcontentloaded', timeout: 30000 }); - - // Wait for the /v2/ page to load and check again - for (let attempt = 1; attempt <= 3; attempt++) { - await this._page.waitForTimeout(5000); - const hasNameInput = await this._page.$('input[placeholder="Type your name"]'); - const hasJoinButton = await this._page.$('#prejoin-join-button, button[data-tid="prejoin-join-button"], button:has-text("Join now")'); - const url = this._page.url(); - - if (hasJoinButton && !hasNameInput) { - this._logger.info(`Authenticated pre-join page confirmed after manual nav (attempt ${attempt}/3). URL: ${url.substring(0, 100)}`); - return; - } - - this._logger.info(`After manual nav (attempt ${attempt}/3): hasJoin=${!!hasJoinButton}, hasName=${!!hasNameInput}. URL: ${url.substring(0, 100)}`); - } - } - - this._logger.warn('Could not confirm authenticated pre-join page. Proceeding anyway...'); - } - - /** - * Build the authenticated /v2/ meeting URL from the original meeting URL. - * Input: https://teams.microsoft.com/meet/36438888781520?p=5fGqrujxzewPFjJacW - * Output: https://teams.microsoft.com/v2/?meetingjoin=true#/meet/36438888781520?p=5fGqrujxzewPFjJacW - */ - private _buildV2MeetingUrl(meetingUrl: string): string { - try { - const url = new URL(meetingUrl); - const pathAndQuery = url.pathname + url.search; - return `https://teams.microsoft.com/v2/?meetingjoin=true#${pathAndQuery}`; - } catch { - this._logger.warn(`Could not parse meeting URL: ${meetingUrl}`); - return `https://teams.microsoft.com/v2/?meetingjoin=true`; - } - } - /** * Enter the bot name in the name input field. * Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai). diff --git a/src/bot/meetingUrlParser.ts b/src/bot/meetingUrlParser.ts index 5d48313..f4c9025 100644 --- a/src/bot/meetingUrlParser.ts +++ b/src/bot/meetingUrlParser.ts @@ -66,23 +66,16 @@ export function isValidMeetingUrl(url: string): boolean { * * Teams meeting URLs redirect through several hops. The final URL needs specific * search params to skip the "Open in Teams app?" dialog in the browser. + * + * Always joins as anonymous (anon=true). See Teamsbot-Auth-Join-Learnings.md + * for details on why authenticated joins are disabled. */ -export async function resolveLaunchUrl(meetingUrl: string, isAuthenticated: boolean = false): Promise { +export async function resolveLaunchUrl(meetingUrl: string): Promise { const trimmed = meetingUrl.trim(); try { const response = await fetch(trimmed, { redirect: 'follow' }); - let resolvedUrlStr = response.url; - - // For authenticated joins: strip anon=true from everywhere in the URL - // Teams redirects embed anon=true in the inner url= parameter (URL-encoded) - if (isAuthenticated) { - resolvedUrlStr = resolvedUrlStr - .replace(/[&?]anon=true/gi, '') - .replace(/%26anon%3Dtrue/gi, '') - .replace(/%26anon%3Dfalse/gi, ''); - } - + const resolvedUrlStr = response.url; const resolvedUrl = new URL(resolvedUrlStr); // Add params to suppress the native app launcher dialog @@ -91,40 +84,30 @@ export async function resolveLaunchUrl(meetingUrl: string, isAuthenticated: bool resolvedUrl.searchParams.set('directDl', 'true'); resolvedUrl.searchParams.set('enableMobilePage', 'true'); resolvedUrl.searchParams.set('suppressPrompt', 'true'); - - // Only add anon=true for anonymous joins - if (!isAuthenticated) { - resolvedUrl.searchParams.set('anon', 'true'); - } else { - // Ensure anon is removed from outer params too - resolvedUrl.searchParams.delete('anon'); - } + resolvedUrl.searchParams.set('anon', 'true'); return resolvedUrl.toString(); } catch { // Fallback: add params to the original URL - return _addLaunchParams(trimmed, isAuthenticated); + return _addLaunchParams(trimmed); } } /** * Fallback: adds launch params directly to the meeting URL without resolving redirects. */ -function _addLaunchParams(url: string, isAuthenticated: boolean = false): string { +function _addLaunchParams(url: string): string { try { const urlObj = new URL(url); urlObj.searchParams.set('msLaunch', 'false'); urlObj.searchParams.set('suppressPrompt', 'true'); urlObj.searchParams.set('directDl', 'true'); urlObj.searchParams.set('enableMobilePage', 'true'); - if (!isAuthenticated) { - urlObj.searchParams.set('anon', 'true'); - } + urlObj.searchParams.set('anon', 'true'); return urlObj.toString(); } catch { const separator = url.includes('?') ? '&' : '?'; - const anonParam = isAuthenticated ? '' : '&anon=true'; - return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true${anonParam}`; + return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true&anon=true`; } } @@ -132,6 +115,6 @@ function _addLaunchParams(url: string, isAuthenticated: boolean = false): string * Converts a meeting URL to the web app launch URL. * @deprecated Use resolveLaunchUrl() instead for proper redirect resolution. */ -export function getMeetingLaunchUrl(url: string, isAuthenticated: boolean = false): string { - return _addLaunchParams(url, isAuthenticated); +export function getMeetingLaunchUrl(url: string): string { + return _addLaunchParams(url); } diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index a9d72f5..4f35e00 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -86,39 +86,27 @@ export class BotOrchestrator { } /** - * Start the bot - connect to Gateway, launch browser, authenticate (if configured), join meeting, enable captions. + * Start the bot - connect to Gateway, launch browser, join meeting, enable captions. + * + * NOTE: Authentication is disabled. The bot always joins as an anonymous guest + * with the configured bot name (typically the system bot's display name, e.g. "Nyla Larsson"). + * See Teamsbot-Auth-Join-Learnings.md for details on why and how to re-enable. */ async start(): Promise { if (!isValidMeetingUrl(this._meetingUrl)) { throw new Error(`Invalid meeting URL: ${this._meetingUrl}`); } - let useAuthentication = !!(this._options.botAccountEmail && this._options.botAccountPassword); - try { this._setState('launching'); // Connect to Gateway WebSocket first await this._connectToGateway(); - // Try joining (authenticated first, then anonymous fallback) - await this._attemptJoin(useAuthentication); + // Join meeting as anonymous guest with configured bot name + await this._attemptJoin(); } catch (error) { - // If authenticated join failed, retry as anonymous - if (useAuthentication) { - this._logger.warn(`Authenticated join failed: ${(error as Error).message}. Retrying as anonymous guest...`); - try { - await this._cleanup(); - await this._attemptJoin(false); - return; - } catch (retryError) { - this._logger.error('Anonymous fallback also failed:', retryError); - this._setState('error', (retryError as Error).message); - await this._takeScreenshot('error-fallback'); - throw retryError; - } - } this._logger.error('Error starting bot:', error); this._setState('error', (error as Error).message); await this._takeScreenshot('error'); @@ -127,121 +115,27 @@ export class BotOrchestrator { } /** - * Attempt to join a meeting (authenticated or anonymous). + * Join a meeting as anonymous guest with the configured bot name. + * + * NOTE: Authentication is disabled. See Teamsbot-Auth-Join-Learnings.md. + * The bot name (e.g. "Nyla Larsson") comes from the system bot's display name, + * configured in the Gateway. This provides a consistent identity without + * requiring Microsoft authentication. */ - private async _attemptJoin(authenticate: boolean): Promise { + private async _attemptJoin(): Promise { // Launch browser await this._launchBrowser(); - // Update JoinProcedure with correct auth state and meeting URL - this._joinProcedure = new JoinProcedure(this._page!, this._logger, this._botName, authenticate, this._meetingUrl); - this._setState('navigating'); // STEP 1: Navigate to meeting URL and click "Continue on this browser" - // This is the same for both authenticated and anonymous joins. - await this._joinProcedure.startMeetingLauncherFlow(this._meetingUrl); + await this._joinProcedure!.startMeetingLauncherFlow(this._meetingUrl); - // STEP 2: For authenticated joins, click "Sign in" on the pre-join page - // instead of entering a name. The "Sign in" link is at the bottom of the - // anonymous pre-join page. Clicking it triggers the Microsoft login flow, - // which redirects back to an authenticated pre-join page within Teams v2. - if (authenticate) { - this._logger.info('Authenticated join: waiting for pre-join page to load, then clicking "Sign in"...'); - - // Wait for the pre-join page to fully load. - // After "Continue on this browser", Teams loads the light-meetings pre-join page. - // This can take 5-15 seconds and may show mic/camera permission overlays. - // The "Sign in" link appears at the bottom of the page once it's loaded. - - // Wait for "Sign in" link to appear (up to 20 seconds) - let signInClicked = false; - const signInSelector = 'a:has-text("Sign in"), button:has-text("Sign in"), a:has-text("Anmelden"), button:has-text("Anmelden")'; - - try { - this._logger.info('Waiting for "Sign in" link to appear on pre-join page...'); - await this._page!.waitForSelector(signInSelector, { timeout: 20000, state: 'visible' }); - - // Click it - const signInLink = await this._page!.$(signInSelector); - if (signInLink) { - await signInLink.click(); - this._logger.info('Clicked "Sign in" link on pre-join page'); - signInClicked = true; - } - } catch { - this._logger.info('"Sign in" not found via waitForSelector, trying DOM scan...'); - } - - // Fallback: scan DOM for sign-in link - if (!signInClicked) { - // The page might have loaded but the selector didn't match exactly - signInClicked = await this._page!.evaluate(() => { - // Look for any link/button with "Sign in" or "Anmelden" text - const allElements = document.querySelectorAll('a, button, span[role="link"]'); - for (let i = 0; i < allElements.length; i++) { - const el = allElements[i] as HTMLElement; - const text = el.innerText?.trim() || ''; - if (text === 'Sign in' || text === 'Anmelden') { - el.click(); - return true; - } - } - return false; - }); - if (signInClicked) { - this._logger.info('Clicked "Sign in" via DOM evaluation fallback'); - } else { - this._logger.warn('Could not find "Sign in" link on pre-join page'); - // Log page content for debugging - const pageText = await this._page!.evaluate(() => document.body?.innerText?.substring(0, 500) || ''); - this._logger.warn(`Pre-join page content: ${pageText.substring(0, 300)}`); - } - } - - if (signInClicked) { - // Clicking "Sign in" on the Teams pre-join page opens an INLINE LOGIN MODAL - // directly on the same page (no URL change). The modal shows an email input, - // then password, then "Stay signed in?" — all on the light-meetings page. - // After completing login, Teams redirects to /v2/ with the "Join now" button. - // We pass skipNavigation=true so authProcedure does NOT navigate away. - const { AuthProcedure } = await import('./authProcedure'); - const authProcedure = new AuthProcedure(this._page!, this._logger); - const authSuccess = await authProcedure.authenticateWithMicrosoft( - this._options.botAccountEmail!, - this._options.botAccountPassword!, - true // skipNavigation: preserve Teams return URL - ); - - if (authSuccess) { - this._logger.info('Authentication via "Sign in" link succeeded'); - - // Auth cookies are now set. Teams /v2/ blocks headless browsers, - // so we stay on the light-meetings page and join from here. - // The auth cookies will identify us to Teams even via light-meetings. - // We skip entering a name and just click "Join now" directly. - this._logger.info('Auth complete. Staying on light-meetings, will join with auth cookies...'); - - // Wait for the page to settle after the auth redirect chain - await this._page!.waitForTimeout(3000); - const settledUrl = this._page!.url(); - this._logger.info(`Post-auth settled URL: ${settledUrl.substring(0, 100)}`); - } else { - this._logger.warn('Authentication via "Sign in" failed - continuing as anonymous'); - } - } else { - this._logger.warn('Could not find "Sign in" link - continuing as anonymous'); - } - } - - // Background image is managed via the user profile's default background. - // No background setup needed during the join flow. - - // Join the meeting - await this._joinProcedure.joinMeetingLobbyFlow(); + // STEP 2: Enter bot name and click "Join now" + await this._joinProcedure!.joinMeetingLobbyFlow(); // Check if we're in lobby - const inLobby = await this._joinProcedure.isInMeetingLobby({ waitForSeconds: 10 }); + 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...'); @@ -251,7 +145,7 @@ export class BotOrchestrator { await this._waitForMeetingAdmission(); this._setState('in_meeting'); - this._logger.info(`Bot joined the meeting! (authenticated: ${authenticate})`); + this._logger.info(`Bot joined the meeting as "${this._botName}"`); // Dismiss any post-join permission modals (e.g. "Manage windows on all displays") await this._joinProcedure!.dismissBrowserPermissionModals(); @@ -266,26 +160,6 @@ export class BotOrchestrator { await this._enableChat(); } - /** - * Clean up browser for retry (close browser without full shutdown). - */ - private async _cleanup(): Promise { - try { - if (this._page) await this._page.close().catch(() => {}); - if (this._context) await this._context.close().catch(() => {}); - if (this._browser) await this._browser.close().catch(() => {}); - this._page = null; - this._context = null; - this._browser = null; - this._joinProcedure = null; - this._captionsProcedure = null; - this._audioProcedure = null; - this._chatProcedure = null; - } catch { - // Ignore cleanup errors - } - } - /** * Connect to the Gateway WebSocket for this session. */ @@ -589,9 +463,8 @@ export class BotOrchestrator { if (!window.chrome.runtime) { window.chrome.runtime = {}; } }); - // Initialize procedures - const isAuthenticated = !!(this._options.botAccountEmail && this._options.botAccountPassword); - this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName, isAuthenticated); + // Initialize procedures (always anonymous join) + this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName); this._captionsProcedure = new CaptionsProcedure( this._page, this._logger,