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);
}