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,