import { Page } from 'playwright'; import { Logger } from 'winston'; import { config } from '../config'; import { resolveLaunchUrl, getMeetingLaunchUrl } from './meetingUrlParser'; /** * Handles the Teams meeting join flow. * Based on Recall.ai's open-source implementation. * * 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; constructor(page: Page, logger: Logger, botName: string) { this._page = page; this._logger = logger; this._botName = botName; } /** * 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, anon=true) to skip the * "Open in Teams app?" native dialog. Then we click "Continue on this browser". */ async startMeetingLauncherFlow(meetingUrl: string): Promise { // Resolve the meeting URL redirect and add suppressPrompt params let launchUrl: string; try { 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._logger.info(`Navigating to meeting: ${launchUrl}`); await this._page.goto(launchUrl, { waitUntil: 'domcontentloaded', timeout: config.timeouts.pageLoad, }); // Handle "Continue on this browser" button await this._handleLauncherDialog(); } /** * Check if a launcher dialog is present and handle it. * 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, clicking "Continue on this browser"'); await launcherButton.click(); await this._page.waitForTimeout(2000); } } catch { // No launcher - that's fine } } /** * Handle the launcher dialog that asks how to join. * Primary selector: button[data-tid="joinOnWeb"] (confirmed working in Recall.ai). * Falls back to text-based selectors if the data-tid changes. */ private async _handleLauncherDialog(): Promise { this._logger.info('Looking for launcher dialog...'); // Primary selector - confirmed working by Recall.ai (Jan 2025) const primarySelector = 'button[data-tid="joinOnWeb"]'; try { await this._page.waitForSelector(primarySelector, { timeout: 30000 }); this._logger.info(`Found launcher button: ${primarySelector}`); await this._page.click(primarySelector); this._logger.info('Clicked "Continue on this browser" button'); return; } catch { this._logger.info('Primary launcher selector not found, trying fallbacks...'); } // Fallback selectors const fallbackSelectors = [ 'button:has-text("Continue on this browser")', 'button:has-text("Join on the web instead")', 'a:has-text("Continue on this browser")', 'a:has-text("Join on the web")', 'button:has-text("Use web app instead")', ]; for (const selector of fallbackSelectors) { try { const element = await this._page.$(selector); if (element) { this._logger.info(`Found launcher button (fallback): ${selector}`); await element.click(); await this._page.waitForTimeout(2000); return; } } catch { // Continue to next selector } } // Log diagnostic info for debugging const currentUrl = this._page.url(); const title = await this._page.title(); this._logger.info(`No launcher dialog found (URL: ${currentUrl}, Title: ${title}). May already be on join page.`); } /** * Fill in the bot name and click "Join now" to enter the lobby. */ async joinMeetingLobbyFlow(): Promise { this._logger.info('Starting lobby join flow...'); // Enter bot name in the name input field, then click "Join now" await this._enterBotName(); await this._clickJoinNow(); } /** * Enter the bot name in the name input field. * Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai). */ private async _enterBotName(): Promise { this._logger.info(`Entering bot name: ${this._botName}`); // Primary selector - confirmed working by Recall.ai (Jan 2025) const primarySelector = 'input[placeholder="Type your name"]'; try { await this._page.waitForSelector(primarySelector, { timeout: 15000 }); await this._page.fill(primarySelector, this._botName); this._logger.info('Bot name entered'); return; } catch { this._logger.info('Primary name input selector not found, trying fallbacks...'); } // Fallback selectors const fallbackSelectors = [ 'input[data-tid="prejoin-display-name-input"]', 'input[placeholder*="name" i]', 'input[placeholder*="Name"]', 'input[aria-label*="name" i]', '#username', ]; for (const selector of fallbackSelectors) { try { const input = await this._page.$(selector); if (input) { await input.fill(this._botName); this._logger.info(`Bot name entered (fallback: ${selector})`); return; } } catch { // Continue } } // Log diagnostic info const currentUrl = this._page.url(); const title = await this._page.title(); this._logger.warn(`Could not find name input field (URL: ${currentUrl}, Title: ${title})`); } /** * Click the "Join now" button. * Primary selector: button:has-text("Join now") (confirmed by Recall.ai). * * IMPORTANT: Teams may show a "no audio/video" modal that blocks the Join button. * This happens when getUserMedia doesn't return real-looking devices. * We handle this by dismissing the modal first. */ private async _clickJoinNow(): Promise { this._logger.info('Clicking Join now...'); const maxRetries = 5; const retryIntervalMs = 5000; const joinSelectors = [ '#prejoin-join-button', // Teams v2 stable ID 'button[data-tid="prejoin-join-button"]', // Teams v2 data-tid 'button:has-text("Join now")', // Text-based (light-meetings) 'button:has-text("Join meeting")', // Alternative text ]; const combinedSelector = joinSelectors.join(', '); for (let attempt = 1; attempt <= maxRetries; attempt++) { // Dismiss any "no audio/video" modal that may be blocking await this._dismissNoAudioVideoModal(); try { await this._page.waitForSelector(combinedSelector, { timeout: 5000, state: 'visible' }); const button = await this._page.$(combinedSelector); if (button) { await button.click(); this._logger.info(`Clicked "Join now" button (attempt ${attempt}/${maxRetries})`); await this._page.waitForTimeout(2000); await this._dismissNoAudioVideoModal(); return; } } catch { // Button not found this attempt } // Fallback: any button with "Join" text const fallbackSelectors = ['button:has-text("Join")', '[data-tid="joinButton"]']; for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button && await button.isVisible()) { await button.click(); this._logger.info(`Clicked join button fallback: ${selector} (attempt ${attempt}/${maxRetries})`); await this._page.waitForTimeout(2000); await this._dismissNoAudioVideoModal(); return; } } catch { // Continue } } const url = this._page.url(); this._logger.info(`Join button not found (attempt ${attempt}/${maxRetries}). URL: ${url.substring(0, 100)}`); if (attempt < maxRetries) { await this._page.waitForTimeout(retryIntervalMs); } } // All retries exhausted — throw with diagnostic info const currentUrl = this._page.url(); const title = await this._page.title(); const bodyText = await this._page.evaluate(() => document.body?.innerText?.substring(0, 500) || '(empty)' ); throw new Error( `Could not find Join button. URL: ${currentUrl}, Title: ${title}, ` + `Visible text (first 500 chars): ${bodyText}` ); } /** * Dismiss the "Are you sure you don't want audio or video?" modal. * Teams shows this when it can't access camera/mic devices. * We click "Continue without audio or video" to proceed. */ private async _dismissNoAudioVideoModal(): Promise { const modalSelectors = [ 'button:has-text("Continue without audio or video")', 'button:has-text("Ohne Audio oder Video fortfahren")', 'button:has-text("Continue without")', 'button:has-text("Ohne Audio")', ]; for (const selector of modalSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); this._logger.info(`Dismissed no-audio modal: ${selector}`); await this._page.waitForTimeout(1000); return; } } catch { // Continue } } // No modal found - that's fine, it means devices were detected properly } /** * Dismiss the "Manage windows on all your displays" permission dialog. * Teams v2 shows this after joining. Must click "Allow" to proceed. * Note: This is a browser permission prompt handled by Playwright's context permissions, * but Teams may also show its own UI overlay. */ async dismissBrowserPermissionModals(): Promise { const permissionSelectors = [ 'button:has-text("Allow")', 'button:has-text("Erlauben")', 'button:has-text("Zulassen")', ]; for (const selector of permissionSelectors) { try { const button = await this._page.$(selector); if (button) { // Only click if it looks like a permission dialog (not a meeting button) const text = await button.evaluate(el => el.closest('[role="dialog"]')?.textContent || ''); if (text.includes('manage') || text.includes('Manage') || text.includes('display') || text.includes('window')) { await button.click(); this._logger.info(`Dismissed browser permission modal: ${selector}`); await this._page.waitForTimeout(1000); return; } } } catch { // Continue } } } /** * Check if the bot is currently in the lobby (waiting to be admitted). * Teams shows various lobby messages depending on the meeting state: * - "Someone will let you in shortly" (meeting active, waiting for admit) * - "Someone will let you in when the meeting starts" (meeting not started yet) * - "waiting for someone to let you in" (alternative wording) */ async isInMeetingLobby(options: { waitForSeconds?: number } = {}): Promise { const timeout = (options.waitForSeconds || 5) * 1000; // Check for any lobby text variant using page.evaluate for reliability try { const inLobby = await this._page.evaluate(() => { const bodyText = document.body?.innerText || ''; const lobbyIndicators = [ 'Someone will let you in shortly', 'Someone will let you in when the meeting starts', 'will let you in', 'waiting for someone to let you in', 'Someone in the meeting should let you in', ]; return lobbyIndicators.some(text => bodyText.includes(text)); }); if (inLobby) return true; } catch { // Page may not be ready } // Primary: text-based check with waitFor (waits up to timeout) try { await this._page.getByText('will let you in').waitFor({ timeout, state: 'visible', }); return true; } catch { // Not found within timeout } // Fallback: data-tid selectors try { await this._page.waitForSelector('[data-tid="lobby-screen"], [data-tid="waiting-screen"]', { timeout: 1000, state: 'visible', }); return true; } catch { return false; } } /** * Check if the bot is currently in the meeting (admitted from lobby). * Primary selector: button[id="hangup-button"] (confirmed by Recall.ai). * Note: Teams uses `id` (not `data-tid`) for the hangup button since 2025 redesign. * * For authenticated joins, Teams v2 sometimes renders differently. * Additional fallback: check the URL for meeting patterns and DOM for call UI. */ async isInMeeting(options: { waitForSeconds?: number } = {}): Promise { const timeout = (options.waitForSeconds || 5) * 1000; // Primary selectors - known meeting UI elements const inMeetingSelectors = [ 'button[id="hangup-button"]', 'button[id="callingButtons-showMoreBtn"]', // Fallbacks with data-tid (older Teams versions) '[data-tid="hangup-button"]', '[data-tid="call-composite"]', 'button[aria-label*="Leave"]', '[data-tid="callingButtons-showMoreBtn"]', // Teams v2 (2025+) additional selectors '[data-tid="call-controls"]', '[data-tid="meeting-composite"]', 'div[data-tid="video-gallery"]', 'button[aria-label*="Hang up"]', 'button[aria-label*="leave" i]', // Mic/Camera toggle buttons are only visible in an active call 'button[id="microphone-button"]', 'button[data-tid="toggle-mute"]', '[data-tid="microphone-button"]', ]; try { await this._page.waitForSelector(inMeetingSelectors.join(', '), { timeout, state: 'visible', }); return true; } catch { // Selector-based detection failed, try DOM evaluation as fallback } // Fallback: evaluate the page for meeting indicators try { const inMeeting = await this._page.evaluate(() => { // Check for call-related aria roles and meeting elements const bodyText = document.body?.innerText || ''; const meetingIndicators = [ 'Leave', // Leave button text 'Mute', // Mic mute button 'Unmute', // Mic unmute button 'Turn off camera', // Camera control 'Turn on camera', 'Share', // Share screen ]; const found = meetingIndicators.filter(ind => bodyText.includes(ind)); // Need at least 2 meeting indicators to confirm we're in a meeting return found.length >= 2; }); if (inMeeting) { this._logger.info('Detected meeting via DOM text analysis (fallback)'); return true; } } catch { // Page may not be ready } return false; } /** * Leave the meeting gracefully. * Primary selector: button[id="hangup-button"] (confirmed by Recall.ai). */ async leaveMeetingFlow(): Promise { this._logger.info('Leaving meeting...'); // Primary selector - confirmed by Recall.ai (Jan 2025) const primarySelector = 'button[id="hangup-button"]'; try { await this._page.waitForSelector(primarySelector, { timeout: 5000 }); await this._page.click(primarySelector); this._logger.info('Clicked leave button'); await this._page.waitForTimeout(2000); return; } catch { this._logger.info('Primary leave selector not found, trying fallbacks...'); } // Fallback selectors const fallbackSelectors = [ '[data-tid="hangup-button"]', 'button[aria-label*="Leave"]', 'button:has-text("Leave")', '[data-tid="call-hangup"]', ]; for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); this._logger.info(`Clicked leave button (fallback: ${selector})`); await this._page.waitForTimeout(2000); return; } } catch { // Continue } } this._logger.warn('Could not find leave button, closing page'); } }