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 <cursoragent@cursor.com>
This commit is contained in:
parent
61219ce5a2
commit
36bf5269ac
3 changed files with 356 additions and 222 deletions
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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"]');
|
||||
// Check if this is a caption element (.fui-ChatMessageCompact)
|
||||
const captionMessage = element.querySelector('.fui-ChatMessageCompact');
|
||||
if (!captionMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 authorElement = captionMessage.querySelector('span[data-tid="author"]');
|
||||
const contentElement = captionMessage.querySelector('span[data-tid="closed-caption-text"]');
|
||||
|
||||
const speaker = speakerEl?.textContent?.trim() || 'Unknown';
|
||||
const text = textEl?.textContent?.trim() || entry.textContent?.trim() || '';
|
||||
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() ?? '';
|
||||
|
||||
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
|
||||
(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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue