disable auth flow: bot joins anonymously with system bot display name

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-16 20:37:08 +01:00
parent 9e24861bab
commit b8bb5affa9
3 changed files with 47 additions and 282 deletions

View file

@ -9,40 +9,37 @@ import { resolveLaunchUrl, getMeetingLaunchUrl } from './meetingUrlParser';
* *
* Teams web UI uses `id` attributes (not `data-tid`) for many interactive elements * Teams web UI uses `id` attributes (not `data-tid`) for many interactive elements
* since the 2025 redesign. Selectors updated accordingly. * 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 { export class JoinProcedure {
private _page: Page; private _page: Page;
private _logger: Logger; private _logger: Logger;
private _botName: string; 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._page = page;
this._logger = logger; this._logger = logger;
this._botName = botName; this._botName = botName;
this._isAuthenticated = isAuthenticated;
this._meetingUrl = meetingUrl;
} }
/** /**
* Navigate to the meeting URL and handle the launcher dialog. * Navigate to the meeting URL and handle the launcher dialog.
* *
* Teams meeting URLs redirect through several hops. We resolve the redirect * 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". * "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<void> { async startMeetingLauncherFlow(meetingUrl: string): Promise<void> {
// Resolve the meeting URL redirect and add suppressPrompt params // Resolve the meeting URL redirect and add suppressPrompt params
let launchUrl: string; let launchUrl: string;
try { try {
launchUrl = await resolveLaunchUrl(meetingUrl, this._isAuthenticated); launchUrl = await resolveLaunchUrl(meetingUrl);
this._logger.info(`Resolved launch URL: ${launchUrl} (authenticated: ${this._isAuthenticated})`); this._logger.info(`Resolved launch URL: ${launchUrl}`);
} catch (error) { } catch (error) {
this._logger.warn(`Could not resolve launch URL, using fallback: ${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}`); this._logger.info(`Navigating to meeting: ${launchUrl}`);
@ -58,19 +55,18 @@ export class JoinProcedure {
/** /**
* Check if a launcher dialog is present and handle it. * Check if a launcher dialog is present and handle it.
* Used for authenticated joins where we navigate directly to the meeting URL * Teams may show the launcher when navigating directly to a meeting URL.
* but Teams may still show the launcher.
*/ */
async handleLauncherIfPresent(): Promise<void> { async handleLauncherIfPresent(): Promise<void> {
try { try {
const launcherButton = await this._page.$('button[data-tid="joinOnWeb"]'); const launcherButton = await this._page.$('button[data-tid="joinOnWeb"]');
if (launcherButton) { 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 launcherButton.click();
await this._page.waitForTimeout(2000); await this._page.waitForTimeout(2000);
} }
} catch { } 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. * Fill in the bot name and click "Join now" to enter the lobby.
*/ */
async joinMeetingLobbyFlow(): Promise<void> { async joinMeetingLobbyFlow(): Promise<void> {
this._logger.info(`Starting lobby join flow... (authenticated: ${this._isAuthenticated})`); this._logger.info('Starting lobby join flow...');
if (this._isAuthenticated) { // Enter bot name in the name input field, then click "Join now"
// Authenticated join: Auth cookies are set but we're on the light-meetings await this._enterBotName();
// 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"
await this._clickJoinNow(); 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<void> {
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. * Enter the bot name in the name input field.
* Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai). * Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai).

View file

@ -66,23 +66,16 @@ export function isValidMeetingUrl(url: string): boolean {
* *
* Teams meeting URLs redirect through several hops. The final URL needs specific * 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. * 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<string> { export async function resolveLaunchUrl(meetingUrl: string): Promise<string> {
const trimmed = meetingUrl.trim(); const trimmed = meetingUrl.trim();
try { try {
const response = await fetch(trimmed, { redirect: 'follow' }); const response = await fetch(trimmed, { redirect: 'follow' });
let resolvedUrlStr = response.url; const 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 resolvedUrl = new URL(resolvedUrlStr); const resolvedUrl = new URL(resolvedUrlStr);
// Add params to suppress the native app launcher dialog // 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('directDl', 'true');
resolvedUrl.searchParams.set('enableMobilePage', 'true'); resolvedUrl.searchParams.set('enableMobilePage', 'true');
resolvedUrl.searchParams.set('suppressPrompt', 'true'); resolvedUrl.searchParams.set('suppressPrompt', 'true');
resolvedUrl.searchParams.set('anon', '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');
}
return resolvedUrl.toString(); return resolvedUrl.toString();
} catch { } catch {
// Fallback: add params to the original URL // 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. * 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 { try {
const urlObj = new URL(url); const urlObj = new URL(url);
urlObj.searchParams.set('msLaunch', 'false'); urlObj.searchParams.set('msLaunch', 'false');
urlObj.searchParams.set('suppressPrompt', 'true'); urlObj.searchParams.set('suppressPrompt', 'true');
urlObj.searchParams.set('directDl', 'true'); urlObj.searchParams.set('directDl', 'true');
urlObj.searchParams.set('enableMobilePage', 'true'); urlObj.searchParams.set('enableMobilePage', 'true');
if (!isAuthenticated) { urlObj.searchParams.set('anon', 'true');
urlObj.searchParams.set('anon', 'true');
}
return urlObj.toString(); return urlObj.toString();
} catch { } catch {
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
const anonParam = isAuthenticated ? '' : '&anon=true'; return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true&anon=true`;
return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true${anonParam}`;
} }
} }
@ -132,6 +115,6 @@ function _addLaunchParams(url: string, isAuthenticated: boolean = false): string
* Converts a meeting URL to the web app launch URL. * Converts a meeting URL to the web app launch URL.
* @deprecated Use resolveLaunchUrl() instead for proper redirect resolution. * @deprecated Use resolveLaunchUrl() instead for proper redirect resolution.
*/ */
export function getMeetingLaunchUrl(url: string, isAuthenticated: boolean = false): string { export function getMeetingLaunchUrl(url: string): string {
return _addLaunchParams(url, isAuthenticated); return _addLaunchParams(url);
} }

View file

@ -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<void> { async start(): Promise<void> {
if (!isValidMeetingUrl(this._meetingUrl)) { if (!isValidMeetingUrl(this._meetingUrl)) {
throw new Error(`Invalid meeting URL: ${this._meetingUrl}`); throw new Error(`Invalid meeting URL: ${this._meetingUrl}`);
} }
let useAuthentication = !!(this._options.botAccountEmail && this._options.botAccountPassword);
try { try {
this._setState('launching'); this._setState('launching');
// Connect to Gateway WebSocket first // Connect to Gateway WebSocket first
await this._connectToGateway(); await this._connectToGateway();
// Try joining (authenticated first, then anonymous fallback) // Join meeting as anonymous guest with configured bot name
await this._attemptJoin(useAuthentication); await this._attemptJoin();
} catch (error) { } 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._logger.error('Error starting bot:', error);
this._setState('error', (error as Error).message); this._setState('error', (error as Error).message);
await this._takeScreenshot('error'); 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<void> { private async _attemptJoin(): Promise<void> {
// Launch browser // Launch browser
await this._launchBrowser(); 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'); this._setState('navigating');
// STEP 1: Navigate to meeting URL and click "Continue on this browser" // 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 // STEP 2: Enter bot name and click "Join now"
// instead of entering a name. The "Sign in" link is at the bottom of the await this._joinProcedure!.joinMeetingLobbyFlow();
// 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();
// Check if we're in lobby // Check if we're in lobby
const inLobby = await this._joinProcedure.isInMeetingLobby({ waitForSeconds: 10 }); const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 10 });
if (inLobby) { if (inLobby) {
this._setState('in_lobby'); this._setState('in_lobby');
this._logger.info('Bot is in lobby, waiting to be admitted...'); this._logger.info('Bot is in lobby, waiting to be admitted...');
@ -251,7 +145,7 @@ export class BotOrchestrator {
await this._waitForMeetingAdmission(); await this._waitForMeetingAdmission();
this._setState('in_meeting'); 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") // Dismiss any post-join permission modals (e.g. "Manage windows on all displays")
await this._joinProcedure!.dismissBrowserPermissionModals(); await this._joinProcedure!.dismissBrowserPermissionModals();
@ -266,26 +160,6 @@ export class BotOrchestrator {
await this._enableChat(); await this._enableChat();
} }
/**
* Clean up browser for retry (close browser without full shutdown).
*/
private async _cleanup(): Promise<void> {
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. * Connect to the Gateway WebSocket for this session.
*/ */
@ -589,9 +463,8 @@ export class BotOrchestrator {
if (!window.chrome.runtime) { window.chrome.runtime = {}; } if (!window.chrome.runtime) { window.chrome.runtime = {}; }
}); });
// Initialize procedures // Initialize procedures (always anonymous join)
const isAuthenticated = !!(this._options.botAccountEmail && this._options.botAccountPassword); this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName, isAuthenticated);
this._captionsProcedure = new CaptionsProcedure( this._captionsProcedure = new CaptionsProcedure(
this._page, this._page,
this._logger, this._logger,