From 36bf5269ac1e325a879e97bb981b347ef5c3f457 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 15 Feb 2026 01:05:47 +0100 Subject: [PATCH] fix: Update Teams web UI selectors to match 2025 redesign Teams changed from data-tid to id attributes for key elements (hangup-button, callingButtons-showMoreBtn, closed-captions-button). Aligned selectors with Recall.ai confirmed working implementation: - URL resolution with suppressPrompt/msLaunch params - waitForSelector instead of page. for reliable element detection - Captions: MutationObserver with exposeFunction instead of polling - Better error diagnostics (URL, title, visible text in error messages) Co-authored-by: Cursor --- src/bot/captionsProcedure.ts | 265 ++++++++++++++++++++++------------- src/bot/joinProcedure.ts | 248 ++++++++++++++++++-------------- src/bot/meetingUrlParser.ts | 65 ++++++--- 3 files changed, 356 insertions(+), 222 deletions(-) diff --git a/src/bot/captionsProcedure.ts b/src/bot/captionsProcedure.ts index a6f4107..ccd7cd6 100644 --- a/src/bot/captionsProcedure.ts +++ b/src/bot/captionsProcedure.ts @@ -1,11 +1,18 @@ import { Page } from 'playwright'; import { Logger } from 'winston'; import { TranscriptEntry } from '../types'; -import { config } from '../config'; /** * Handles enabling and scraping captions from Teams meetings. * Based on Recall.ai's open-source implementation. + * + * Teams web UI selectors (updated Jan 2025): + * - More button: button[id="callingButtons-showMoreBtn"] + * - Captions button: div[id="closed-captions-button"] + * - Captions container: div[data-tid="closed-caption-renderer-wrapper"] + * - Caption author: span[data-tid="author"] + * - Caption text: span[data-tid="closed-caption-text"] + * - Caption message: .fui-ChatMessageCompact */ export class CaptionsProcedure { private _page: Page; @@ -26,7 +33,7 @@ export class CaptionsProcedure { /** * Enable live captions in the meeting. - * Opens the "More" menu and clicks "Turn on live captions". + * Opens the "More" menu and clicks the captions button. */ async enableCaptionsFlow(): Promise { this._logger.info('Enabling live captions...'); @@ -34,33 +41,51 @@ export class CaptionsProcedure { // First, open the "More actions" menu await this._openMoreMenu(); - // Then click on "Turn on live captions" + // Then click on the captions button await this._clickEnableCaptions(); + // Wait for the captions container to appear + await this._waitForCaptionsContainer(); + this._logger.info('Live captions enabled'); } /** * Open the "More actions" (...) menu in the call controls. + * Primary selector: button[id="callingButtons-showMoreBtn"] (confirmed by Recall.ai). */ private async _openMoreMenu(): Promise { - const moreMenuSelectors = [ + // Primary selector - confirmed by Recall.ai (Jan 2025) + const primarySelector = 'button[id="callingButtons-showMoreBtn"]'; + + try { + await this._page.waitForSelector(primarySelector, { timeout: 120000 }); + this._logger.info('Found "More" button'); + await this._page.click(primarySelector); + this._logger.info('Clicked "More" button'); + return; + } catch { + this._logger.info('Primary more button selector not found, trying fallbacks...'); + } + + // Fallback selectors + const fallbackSelectors = [ '[data-tid="callingButtons-showMoreBtn"]', 'button[aria-label*="More actions"]', 'button[aria-label*="More"]', '[data-tid="more-button"]', ]; - for (const selector of moreMenuSelectors) { + for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); await this._page.waitForTimeout(1000); - this._logger.info('Opened more menu'); + this._logger.info(`Opened more menu (fallback: ${selector})`); return; } - } catch (error) { + } catch { // Continue } } @@ -69,30 +94,43 @@ export class CaptionsProcedure { } /** - * Click the "Turn on live captions" option. + * Click the captions button in the menu. + * Primary selector: div[id="closed-captions-button"] (confirmed by Recall.ai). */ private async _clickEnableCaptions(): Promise { - const captionsSelectors = [ + // Primary selector - confirmed by Recall.ai (Jan 2025) + const primarySelector = 'div[id="closed-captions-button"]'; + + try { + await this._page.waitForSelector(primarySelector, { timeout: 120000 }); + this._logger.info('Found "Captions" button'); + await this._page.click(primarySelector); + this._logger.info('Clicked "Captions" button'); + return; + } catch { + this._logger.info('Primary captions button selector not found, trying fallbacks...'); + } + + // Fallback selectors + const fallbackSelectors = [ 'button:has-text("Turn on live captions")', 'button:has-text("Live captions")', '[data-tid="captions-toggle"]', - 'button[aria-label*="captions"]', - 'button[aria-label*="Captions"]', - // Menu item selectors + 'button[aria-label*="captions" i]', '[role="menuitem"]:has-text("captions")', '[role="menuitemcheckbox"]:has-text("captions")', ]; - for (const selector of captionsSelectors) { + for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); await this._page.waitForTimeout(1000); - this._logger.info('Clicked enable captions'); + this._logger.info(`Clicked enable captions (fallback: ${selector})`); return; } - } catch (error) { + } catch { // Continue } } @@ -103,8 +141,30 @@ export class CaptionsProcedure { } /** - * Start watching the captions DOM for updates. - * Emits transcript events when new captions appear. + * Wait for the captions container to become visible after enabling. + * Primary selector: div[data-tid="closed-caption-renderer-wrapper"] (confirmed by Recall.ai). + */ + private async _waitForCaptionsContainer(): Promise { + const containerSelector = 'div[data-tid="closed-caption-renderer-wrapper"]'; + + try { + await this._page.waitForSelector(containerSelector, { timeout: 120000 }); + this._logger.info('Found captions container'); + } catch { + this._logger.warn('Could not find captions container - captions may not have enabled'); + } + } + + /** + * Start watching the captions DOM for updates using Recall.ai's approach. + * + * Uses page.exposeFunction() + MutationObserver for real-time caption detection. + * Captions in Teams are rendered inside .fui-ChatMessageCompact elements with: + * - span[data-tid="author"] for the speaker name + * - span[data-tid="closed-caption-text"] for the caption text + * + * Teams updates captions in real-time as the user speaks, adding punctuation + * only when the caption is finalized. We use this to detect final captions. */ async subscribeToCaptions(): Promise { if (this._isSubscribed) { @@ -115,110 +175,115 @@ export class CaptionsProcedure { this._isSubscribed = true; this._logger.info('Subscribing to captions...'); - // Set up a MutationObserver in the browser to watch for caption changes + // Expose a callback function from Node.js to the browser context + await this._page.exposeFunction('__onCaptionEvent', (caption: { + speaker: string; + text: string; + timestamp: string; + }) => { + this._handleCaptionEvent(caption); + }); + + // Verify captions container is present + const containerSelector = 'div[data-tid="closed-caption-renderer-wrapper"]'; + try { + await this._page.waitForSelector(containerSelector, { timeout: 120000 }); + } catch { + this._logger.warn('Captions container not found, subscribing anyway'); + } + + this._logger.info('Setting up MutationObserver for captions...'); + + // Set up MutationObserver in the browser (Recall.ai approach) await this._page.evaluate(() => { - // Store captions data on window for retrieval - (window as any).__captionsBuffer = []; - (window as any).__lastCaptionId = 0; + const targetNode = document.querySelector('div[data-tid="closed-caption-renderer-wrapper"]'); + if (!targetNode) { + return; + } - // Function to extract caption text - const extractCaptions = () => { - // Common caption container selectors in Teams - const captionSelectors = [ - '[data-tid="closed-captions-renderer"]', - '[data-tid="captions-container"]', - '.captions-container', - '[class*="caption"]', - ]; + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; - for (const selector of captionSelectors) { - const container = document.querySelector(selector); - if (container) { - // Find individual caption entries - const entries = container.querySelectorAll('[data-tid="caption-entry"], [class*="caption-line"], [class*="captionLine"]'); - - entries.forEach((entry, index) => { - const speakerEl = entry.querySelector('[data-tid="caption-speaker"], [class*="speaker"]'); - const textEl = entry.querySelector('[data-tid="caption-text"], [class*="text"]'); - - const speaker = speakerEl?.textContent?.trim() || 'Unknown'; - const text = textEl?.textContent?.trim() || entry.textContent?.trim() || ''; - - if (text && text.length > 0) { - const captionId = `${speaker}-${text}-${index}`; - if (captionId !== (window as any).__lastCaptionId) { - (window as any).__lastCaptionId = captionId; - (window as any).__captionsBuffer.push({ - speaker, - text, - timestamp: new Date().toISOString(), - isFinal: true, // Teams captions are typically final + // Check if this is a caption element (.fui-ChatMessageCompact) + const captionMessage = element.querySelector('.fui-ChatMessageCompact'); + if (!captionMessage) { + return; + } + + const authorElement = captionMessage.querySelector('span[data-tid="author"]'); + const contentElement = captionMessage.querySelector('span[data-tid="closed-caption-text"]'); + + if (authorElement && contentElement) { + // Watch for real-time updates in the caption text + const textObserver = new MutationObserver(() => { + const speaker = authorElement.textContent?.trim() ?? 'Unknown'; + const text = (contentElement as any).innerText?.trim() ?? ''; + + (window as any).__onCaptionEvent({ + speaker, + text, + timestamp: new Date().toISOString(), + }); + }); + + textObserver.observe(contentElement, { + childList: true, + subtree: true, + characterData: true, }); } } }); - break; } } - }; - - // Set up observer - const observer = new MutationObserver(() => { - extractCaptions(); }); - // Observe the entire body for changes (captions can appear anywhere) - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true, - }); - - // Initial extraction - extractCaptions(); + observer.observe(targetNode, { childList: true, subtree: true }); // Store observer reference for cleanup (window as any).__captionsObserver = observer; }); - // Start polling for new captions - this._pollCaptions(); + this._logger.info('MutationObserver set up for captions'); } /** - * Poll the browser for new captions and emit them. + * Handle a caption event from the browser MutationObserver. + * Teams updates captions in real-time. We detect finalized captions by + * checking for terminal punctuation (. , ! ?). */ - private async _pollCaptions(): Promise { - while (this._isSubscribed) { - try { - const captions = await this._page.evaluate(() => { - const buffer = (window as any).__captionsBuffer || []; - (window as any).__captionsBuffer = []; - return buffer; - }); - - for (const caption of captions) { - // Deduplicate based on text - if (caption.text !== this._lastCaptionText) { - this._lastCaptionText = caption.text; - this._onTranscript({ - speaker: caption.speaker, - text: caption.text, - timestamp: new Date(caption.timestamp), - isFinal: caption.isFinal, - }); - } - } - } catch (error) { - // Page might be closed - if (this._isSubscribed) { - this._logger.error('Error polling captions:', error); - } - } - - // Poll every 500ms - await new Promise(resolve => setTimeout(resolve, 500)); + private _handleCaptionEvent(caption: { speaker: string; text: string; timestamp: string }): void { + if (!this._isSubscribed || !caption.text) { + return; } + + // Teams adds punctuation only to finalized captions + const terminalPunctuationRegex = /[.,!?]/; + if (!terminalPunctuationRegex.test(caption.text)) { + return; // Not finalized yet + } + + // Dedup: strip punctuation and compare to last caption + const punctuationRegex = /[.,'"!?~\-]/g; + const newTextStripped = caption.text.replace(punctuationRegex, ''); + const lastTextStripped = this._lastCaptionText.replace(punctuationRegex, ''); + + if (newTextStripped === lastTextStripped) { + return; // Duplicate + } + + this._lastCaptionText = caption.text; + + this._onTranscript({ + speaker: caption.speaker, + text: caption.text, + timestamp: new Date(caption.timestamp), + isFinal: true, + }); } /** diff --git a/src/bot/joinProcedure.ts b/src/bot/joinProcedure.ts index dbdc22c..c4cb931 100644 --- a/src/bot/joinProcedure.ts +++ b/src/bot/joinProcedure.ts @@ -1,11 +1,14 @@ import { Page } from 'playwright'; import { Logger } from 'winston'; import { config } from '../config'; -import { getMeetingLaunchUrl } from './meetingUrlParser'; +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. */ export class JoinProcedure { private _page: Page; @@ -20,10 +23,22 @@ export class JoinProcedure { /** * Navigate to the meeting URL and handle the launcher dialog. - * Teams shows a "How do you want to join?" dialog first. + * + * Teams meeting URLs redirect through several hops. We resolve the redirect + * and add params (suppressPrompt, msLaunch=false, etc.) to skip the + * "Open in Teams app?" native dialog. Then we click "Continue on this browser". */ async startMeetingLauncherFlow(meetingUrl: string): Promise { - const launchUrl = getMeetingLaunchUrl(meetingUrl); + // 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, { @@ -31,45 +46,58 @@ export class JoinProcedure { timeout: config.timeouts.pageLoad, }); - // Wait for the page to stabilize - await this._page.waitForTimeout(2000); - - // Handle "Continue on this browser" or similar prompts + // Handle "Continue on this browser" button await this._handleLauncherDialog(); } /** * Handle the launcher dialog that asks how to join. - * We want to select "Continue on this browser" / "Join on the web instead" + * 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...'); - // Common selectors for "Continue on browser" button - const browserJoinSelectors = [ + // 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")', - '[data-tid="joinOnWeb"]', - '[data-tid="prejoin-join-button"]', + 'button:has-text("Use web app instead")', ]; - for (const selector of browserJoinSelectors) { + for (const selector of fallbackSelectors) { try { const element = await this._page.$(selector); if (element) { - this._logger.info(`Found launcher button: ${selector}`); + this._logger.info(`Found launcher button (fallback): ${selector}`); await element.click(); await this._page.waitForTimeout(2000); return; } - } catch (error) { + } catch { // Continue to next selector } } - this._logger.info('No launcher dialog found, may already be on join page'); + // 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.`); } /** @@ -78,153 +106,141 @@ export class JoinProcedure { async joinMeetingLobbyFlow(): Promise { this._logger.info('Starting lobby join flow...'); - // Wait for the pre-join screen - await this._page.waitForTimeout(2000); - - // Handle microphone/camera permissions - we want them OFF - await this._disableMediaDevices(); - - // Enter the bot name + // Enter the bot name (this also implicitly waits for the pre-join page to load) await this._enterBotName(); // Click "Join now" await this._clickJoinNow(); } - /** - * Disable microphone and camera toggles. - */ - private async _disableMediaDevices(): Promise { - this._logger.info('Disabling media devices...'); - - // Microphone toggle selectors - const micSelectors = [ - '[data-tid="toggle-mute"]', - '[aria-label*="microphone"]', - '[aria-label*="Microphone"]', - 'button[id*="microphone"]', - ]; - - // Camera toggle selectors - const cameraSelectors = [ - '[data-tid="toggle-video"]', - '[aria-label*="camera"]', - '[aria-label*="Camera"]', - 'button[id*="camera"]', - ]; - - // Try to turn off microphone (click if it's ON) - for (const selector of micSelectors) { - try { - const mic = await this._page.$(selector); - if (mic) { - const ariaPressed = await mic.getAttribute('aria-pressed'); - const ariaChecked = await mic.getAttribute('aria-checked'); - if (ariaPressed === 'true' || ariaChecked === 'true') { - await mic.click(); - this._logger.info('Disabled microphone'); - } - break; - } - } catch (error) { - // Continue - } - } - - // Try to turn off camera - for (const selector of cameraSelectors) { - try { - const camera = await this._page.$(selector); - if (camera) { - const ariaPressed = await camera.getAttribute('aria-pressed'); - const ariaChecked = await camera.getAttribute('aria-checked'); - if (ariaPressed === 'true' || ariaChecked === 'true') { - await camera.click(); - this._logger.info('Disabled camera'); - } - break; - } - } catch (error) { - // Continue - } - } - } - /** * 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}`); - const nameSelectors = [ + // 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"]', + 'input[placeholder*="name" i]', 'input[placeholder*="Name"]', - 'input[aria-label*="name"]', + 'input[aria-label*="name" i]', '#username', ]; - for (const selector of nameSelectors) { + 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'); + this._logger.info(`Bot name entered (fallback: ${selector})`); return; } - } catch (error) { + } catch { // Continue } } - this._logger.warn('Could not find name input field'); + // 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). */ private async _clickJoinNow(): Promise { this._logger.info('Clicking Join now...'); - const joinSelectors = [ + // Primary selector - confirmed working by Recall.ai (Jan 2025) + const primarySelector = 'button:has-text("Join now")'; + + try { + await this._page.waitForSelector(primarySelector, { timeout: 15000 }); + await this._page.click(primarySelector); + this._logger.info('Clicked "Join now" button'); + return; + } catch { + this._logger.info('Primary join button selector not found, trying fallbacks...'); + } + + // Fallback selectors + const fallbackSelectors = [ 'button[data-tid="prejoin-join-button"]', - 'button:has-text("Join now")', 'button:has-text("Join meeting")', + 'button:has-text("Join")', '[data-tid="joinButton"]', ]; - for (const selector of joinSelectors) { + for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); - this._logger.info('Clicked join button'); + this._logger.info(`Clicked join button (fallback: ${selector})`); return; } - } catch (error) { + } catch { // Continue } } - throw new Error('Could not find Join button'); + // Diagnostic info for debugging + 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}` + ); } /** * Check if the bot is currently in the lobby (waiting to be admitted). + * Primary check: text "Someone will let you in shortly" (confirmed by Recall.ai). */ async isInMeetingLobby(options: { waitForSeconds?: number } = {}): Promise { const timeout = (options.waitForSeconds || 5) * 1000; - const lobbySelectors = [ - '[data-tid="lobby-screen"]', + try { + // Primary: text-based check (Recall.ai approach, most reliable) + await this._page.getByText('Someone will let you in shortly').waitFor({ + timeout, + state: 'visible', + }); + return true; + } catch { + // Fallback: try other lobby indicators + } + + // Fallback selectors + const fallbackSelectors = [ ':has-text("waiting for someone to let you in")', ':has-text("Someone in the meeting should let you in soon")', + '[data-tid="lobby-screen"]', '[data-tid="waiting-screen"]', ]; try { - await this._page.waitForSelector(lobbySelectors.join(', '), { - timeout, + await this._page.waitForSelector(fallbackSelectors.join(', '), { + timeout: 1000, // Short timeout since primary already waited state: 'visible', }); return true; @@ -235,15 +251,20 @@ export class JoinProcedure { /** * 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. */ async isInMeeting(options: { waitForSeconds?: number } = {}): Promise { const timeout = (options.waitForSeconds || 5) * 1000; - // Indicators that we're in the meeting + // Primary selector - confirmed by Recall.ai (Jan 2025) + // Note: Teams now uses id="hangup-button" instead of data-tid="hangup-button" const inMeetingSelectors = [ - '[data-tid="call-composite"]', - '[data-tid="meeting-roster"]', + '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"]', ]; @@ -261,27 +282,42 @@ export class JoinProcedure { /** * Leave the meeting gracefully. + * Primary selector: button[id="hangup-button"] (confirmed by Recall.ai). */ async leaveMeetingFlow(): Promise { this._logger.info('Leaving meeting...'); - const leaveSelectors = [ + // 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 leaveSelectors) { + for (const selector of fallbackSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); - this._logger.info('Clicked leave button'); + this._logger.info(`Clicked leave button (fallback: ${selector})`); await this._page.waitForTimeout(2000); return; } - } catch (error) { + } catch { // Continue } } diff --git a/src/bot/meetingUrlParser.ts b/src/bot/meetingUrlParser.ts index 3db4d7e..fc8e4ce 100644 --- a/src/bot/meetingUrlParser.ts +++ b/src/bot/meetingUrlParser.ts @@ -60,24 +60,57 @@ export function isValidMeetingUrl(url: string): boolean { return trimmedUrl.includes('/meet/') || trimmedUrl.includes('/l/meetup-join/'); } +/** + * Resolves the meeting URL by following redirects and adding params to suppress + * the native app launcher dialog. This is the approach used by Recall.ai. + * + * 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. + */ +export async function resolveLaunchUrl(meetingUrl: string): Promise { + const trimmed = meetingUrl.trim(); + + try { + const response = await fetch(trimmed, { redirect: 'follow' }); + const resolvedUrl = new URL(response.url); + + // Add params to suppress the native app launcher dialog + resolvedUrl.searchParams.set('msLaunch', 'false'); + resolvedUrl.searchParams.set('type', 'meetup-join'); + resolvedUrl.searchParams.set('directDl', 'true'); + resolvedUrl.searchParams.set('enableMobilePage', 'true'); + resolvedUrl.searchParams.set('suppressPrompt', 'true'); + + return resolvedUrl.toString(); + } catch { + // Fallback: add params to the original URL + return _addLaunchParams(trimmed); + } +} + +/** + * Fallback: adds launch params directly to the meeting URL without resolving redirects. + */ +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'); + urlObj.searchParams.set('anon', 'true'); + return urlObj.toString(); + } catch { + // If URL parsing fails, append as query string + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true&anon=true`; + } +} + /** * Converts a meeting URL to the web app launch URL. - * Teams web app requires a specific format to join meetings. + * @deprecated Use resolveLaunchUrl() instead for proper redirect resolution. */ export function getMeetingLaunchUrl(url: string): string { - const parsed = parseMeetingUrl(url); - - // For short URLs, we can use them directly - if (parsed.type === 'short') { - return parsed.originalUrl; - } - - // For classic URLs, ensure we're using the web version - // Add ?anon=true to skip sign-in prompt for anonymous join - let launchUrl = parsed.originalUrl; - if (!launchUrl.includes('anon=')) { - launchUrl += (launchUrl.includes('?') ? '&' : '?') + 'anon=true'; - } - - return launchUrl; + return _addLaunchParams(url); }