484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
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<void> {
|
|
// 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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');
|
|
}
|
|
}
|