disable auth flow: bot joins anonymously with system bot display name
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9e24861bab
commit
b8bb5affa9
3 changed files with 47 additions and 282 deletions
|
|
@ -9,40 +9,37 @@ import { resolveLaunchUrl, getMeetingLaunchUrl } from './meetingUrlParser';
|
||||||
*
|
*
|
||||||
* Teams web UI uses `id` attributes (not `data-tid`) for many interactive elements
|
* Teams web UI uses `id` attributes (not `data-tid`) for many interactive elements
|
||||||
* since the 2025 redesign. Selectors updated accordingly.
|
* 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 {
|
export class JoinProcedure {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
private _logger: Logger;
|
private _logger: Logger;
|
||||||
private _botName: string;
|
private _botName: string;
|
||||||
private _isAuthenticated: boolean;
|
|
||||||
private _meetingUrl: string;
|
|
||||||
|
|
||||||
constructor(page: Page, logger: Logger, botName: string, isAuthenticated: boolean = false, meetingUrl: string = '') {
|
constructor(page: Page, logger: Logger, botName: string) {
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this._logger = logger;
|
this._logger = logger;
|
||||||
this._botName = botName;
|
this._botName = botName;
|
||||||
this._isAuthenticated = isAuthenticated;
|
|
||||||
this._meetingUrl = meetingUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to the meeting URL and handle the launcher dialog.
|
* Navigate to the meeting URL and handle the launcher dialog.
|
||||||
*
|
*
|
||||||
* Teams meeting URLs redirect through several hops. We resolve the redirect
|
* Teams meeting URLs redirect through several hops. We resolve the redirect
|
||||||
* and add params (suppressPrompt, msLaunch=false, etc.) to skip the
|
* and add params (suppressPrompt, msLaunch=false, anon=true) to skip the
|
||||||
* "Open in Teams app?" native dialog. Then we click "Continue on this browser".
|
* "Open in Teams app?" native dialog. Then we click "Continue on this browser".
|
||||||
*
|
|
||||||
* For authenticated joins, anon=true is omitted and the name input is skipped.
|
|
||||||
*/
|
*/
|
||||||
async startMeetingLauncherFlow(meetingUrl: string): Promise<void> {
|
async startMeetingLauncherFlow(meetingUrl: string): Promise<void> {
|
||||||
// Resolve the meeting URL redirect and add suppressPrompt params
|
// Resolve the meeting URL redirect and add suppressPrompt params
|
||||||
let launchUrl: string;
|
let launchUrl: string;
|
||||||
try {
|
try {
|
||||||
launchUrl = await resolveLaunchUrl(meetingUrl, this._isAuthenticated);
|
launchUrl = await resolveLaunchUrl(meetingUrl);
|
||||||
this._logger.info(`Resolved launch URL: ${launchUrl} (authenticated: ${this._isAuthenticated})`);
|
this._logger.info(`Resolved launch URL: ${launchUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._logger.warn(`Could not resolve launch URL, using fallback: ${error}`);
|
this._logger.warn(`Could not resolve launch URL, using fallback: ${error}`);
|
||||||
launchUrl = getMeetingLaunchUrl(meetingUrl, this._isAuthenticated);
|
launchUrl = getMeetingLaunchUrl(meetingUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._logger.info(`Navigating to meeting: ${launchUrl}`);
|
this._logger.info(`Navigating to meeting: ${launchUrl}`);
|
||||||
|
|
@ -58,19 +55,18 @@ export class JoinProcedure {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a launcher dialog is present and handle it.
|
* Check if a launcher dialog is present and handle it.
|
||||||
* Used for authenticated joins where we navigate directly to the meeting URL
|
* Teams may show the launcher when navigating directly to a meeting URL.
|
||||||
* but Teams may still show the launcher.
|
|
||||||
*/
|
*/
|
||||||
async handleLauncherIfPresent(): Promise<void> {
|
async handleLauncherIfPresent(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const launcherButton = await this._page.$('button[data-tid="joinOnWeb"]');
|
const launcherButton = await this._page.$('button[data-tid="joinOnWeb"]');
|
||||||
if (launcherButton) {
|
if (launcherButton) {
|
||||||
this._logger.info('Launcher dialog found after direct navigation, clicking "Continue on this browser"');
|
this._logger.info('Launcher dialog found, clicking "Continue on this browser"');
|
||||||
await launcherButton.click();
|
await launcherButton.click();
|
||||||
await this._page.waitForTimeout(2000);
|
await this._page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No launcher - that's fine for authenticated joins
|
// No launcher - that's fine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,100 +124,13 @@ export class JoinProcedure {
|
||||||
* Fill in the bot name and click "Join now" to enter the lobby.
|
* Fill in the bot name and click "Join now" to enter the lobby.
|
||||||
*/
|
*/
|
||||||
async joinMeetingLobbyFlow(): Promise<void> {
|
async joinMeetingLobbyFlow(): Promise<void> {
|
||||||
this._logger.info(`Starting lobby join flow... (authenticated: ${this._isAuthenticated})`);
|
this._logger.info('Starting lobby join flow...');
|
||||||
|
|
||||||
if (this._isAuthenticated) {
|
// Enter bot name in the name input field, then click "Join now"
|
||||||
// Authenticated join: Auth cookies are set but we're on the light-meetings
|
await this._enterBotName();
|
||||||
// page (Teams blocks /v2/ for headless browsers). The light-meetings page
|
|
||||||
// REQUIRES a name to be entered — without it, "Join now" fails with
|
|
||||||
// "All promises were rejected". We enter the bot name AND rely on auth
|
|
||||||
// cookies for identification. Teams will show the user as authenticated.
|
|
||||||
this._logger.info('Authenticated join on light-meetings: entering name (required by light-meetings)...');
|
|
||||||
await this._enterBotName();
|
|
||||||
} else {
|
|
||||||
// Anonymous join: enter bot name in the name input field
|
|
||||||
await this._enterBotName();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click "Join now"
|
|
||||||
await this._clickJoinNow();
|
await this._clickJoinNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the authenticated pre-join page to be ready.
|
|
||||||
*
|
|
||||||
* Verification: The authenticated page does NOT have a name input field.
|
|
||||||
* If a name input (placeholder="Type your name") exists, we're still on the
|
|
||||||
* anonymous page and need to wait for the redirect to complete.
|
|
||||||
*
|
|
||||||
* Retries 5 times, every 5 seconds.
|
|
||||||
*/
|
|
||||||
private async _waitForAuthenticatedPreJoinPage(): Promise<void> {
|
|
||||||
const maxRetries = 5;
|
|
||||||
const retryIntervalMs = 5000;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
const url = this._page.url();
|
|
||||||
const hasNameInput = await this._page.$('input[placeholder="Type your name"]');
|
|
||||||
const hasJoinButton = await this._page.$('#prejoin-join-button, button[data-tid="prejoin-join-button"], button:has-text("Join now")');
|
|
||||||
|
|
||||||
if (hasJoinButton && !hasNameInput) {
|
|
||||||
this._logger.info(`Authenticated pre-join page confirmed (attempt ${attempt}/${maxRetries}): Join button present, no name input. URL: ${url.substring(0, 100)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameStatus = hasNameInput ? 'name input FOUND (wrong page)' : 'no name input';
|
|
||||||
const joinStatus = hasJoinButton ? 'Join button found' : 'no Join button';
|
|
||||||
this._logger.info(`Waiting for authenticated pre-join page (attempt ${attempt}/${maxRetries}): ${nameStatus}, ${joinStatus}. URL: ${url.substring(0, 100)}`);
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
await this._page.waitForTimeout(retryIntervalMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: The automatic redirect to /v2/ did not happen (common in headless browsers).
|
|
||||||
// Manually navigate to the /v2/ authenticated pre-join URL with auth cookies.
|
|
||||||
// Format: https://teams.microsoft.com/v2/?meetingjoin=true#/meet/{meetingId}?p={passcode}
|
|
||||||
if (this._meetingUrl) {
|
|
||||||
const v2Url = this._buildV2MeetingUrl(this._meetingUrl);
|
|
||||||
this._logger.info(`Redirect to /v2/ did not happen. Navigating manually to: ${v2Url}`);
|
|
||||||
await this._page.goto(v2Url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
||||||
|
|
||||||
// Wait for the /v2/ page to load and check again
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
||||||
await this._page.waitForTimeout(5000);
|
|
||||||
const hasNameInput = await this._page.$('input[placeholder="Type your name"]');
|
|
||||||
const hasJoinButton = await this._page.$('#prejoin-join-button, button[data-tid="prejoin-join-button"], button:has-text("Join now")');
|
|
||||||
const url = this._page.url();
|
|
||||||
|
|
||||||
if (hasJoinButton && !hasNameInput) {
|
|
||||||
this._logger.info(`Authenticated pre-join page confirmed after manual nav (attempt ${attempt}/3). URL: ${url.substring(0, 100)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._logger.info(`After manual nav (attempt ${attempt}/3): hasJoin=${!!hasJoinButton}, hasName=${!!hasNameInput}. URL: ${url.substring(0, 100)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._logger.warn('Could not confirm authenticated pre-join page. Proceeding anyway...');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the authenticated /v2/ meeting URL from the original meeting URL.
|
|
||||||
* Input: https://teams.microsoft.com/meet/36438888781520?p=5fGqrujxzewPFjJacW
|
|
||||||
* Output: https://teams.microsoft.com/v2/?meetingjoin=true#/meet/36438888781520?p=5fGqrujxzewPFjJacW
|
|
||||||
*/
|
|
||||||
private _buildV2MeetingUrl(meetingUrl: string): string {
|
|
||||||
try {
|
|
||||||
const url = new URL(meetingUrl);
|
|
||||||
const pathAndQuery = url.pathname + url.search;
|
|
||||||
return `https://teams.microsoft.com/v2/?meetingjoin=true#${pathAndQuery}`;
|
|
||||||
} catch {
|
|
||||||
this._logger.warn(`Could not parse meeting URL: ${meetingUrl}`);
|
|
||||||
return `https://teams.microsoft.com/v2/?meetingjoin=true`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter the bot name in the name input field.
|
* Enter the bot name in the name input field.
|
||||||
* Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai).
|
* Primary selector: input[placeholder="Type your name"] (confirmed by Recall.ai).
|
||||||
|
|
|
||||||
|
|
@ -66,23 +66,16 @@ export function isValidMeetingUrl(url: string): boolean {
|
||||||
*
|
*
|
||||||
* Teams meeting URLs redirect through several hops. The final URL needs specific
|
* 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.
|
* search params to skip the "Open in Teams app?" dialog in the browser.
|
||||||
|
*
|
||||||
|
* Always joins as anonymous (anon=true). See Teamsbot-Auth-Join-Learnings.md
|
||||||
|
* for details on why authenticated joins are disabled.
|
||||||
*/
|
*/
|
||||||
export async function resolveLaunchUrl(meetingUrl: string, isAuthenticated: boolean = false): Promise<string> {
|
export async function resolveLaunchUrl(meetingUrl: string): Promise<string> {
|
||||||
const trimmed = meetingUrl.trim();
|
const trimmed = meetingUrl.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(trimmed, { redirect: 'follow' });
|
const response = await fetch(trimmed, { redirect: 'follow' });
|
||||||
let resolvedUrlStr = response.url;
|
const resolvedUrlStr = response.url;
|
||||||
|
|
||||||
// For authenticated joins: strip anon=true from everywhere in the URL
|
|
||||||
// Teams redirects embed anon=true in the inner url= parameter (URL-encoded)
|
|
||||||
if (isAuthenticated) {
|
|
||||||
resolvedUrlStr = resolvedUrlStr
|
|
||||||
.replace(/[&?]anon=true/gi, '')
|
|
||||||
.replace(/%26anon%3Dtrue/gi, '')
|
|
||||||
.replace(/%26anon%3Dfalse/gi, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedUrl = new URL(resolvedUrlStr);
|
const resolvedUrl = new URL(resolvedUrlStr);
|
||||||
|
|
||||||
// Add params to suppress the native app launcher dialog
|
// Add params to suppress the native app launcher dialog
|
||||||
|
|
@ -91,40 +84,30 @@ export async function resolveLaunchUrl(meetingUrl: string, isAuthenticated: bool
|
||||||
resolvedUrl.searchParams.set('directDl', 'true');
|
resolvedUrl.searchParams.set('directDl', 'true');
|
||||||
resolvedUrl.searchParams.set('enableMobilePage', 'true');
|
resolvedUrl.searchParams.set('enableMobilePage', 'true');
|
||||||
resolvedUrl.searchParams.set('suppressPrompt', 'true');
|
resolvedUrl.searchParams.set('suppressPrompt', 'true');
|
||||||
|
resolvedUrl.searchParams.set('anon', 'true');
|
||||||
// Only add anon=true for anonymous joins
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
resolvedUrl.searchParams.set('anon', 'true');
|
|
||||||
} else {
|
|
||||||
// Ensure anon is removed from outer params too
|
|
||||||
resolvedUrl.searchParams.delete('anon');
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedUrl.toString();
|
return resolvedUrl.toString();
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: add params to the original URL
|
// Fallback: add params to the original URL
|
||||||
return _addLaunchParams(trimmed, isAuthenticated);
|
return _addLaunchParams(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback: adds launch params directly to the meeting URL without resolving redirects.
|
* Fallback: adds launch params directly to the meeting URL without resolving redirects.
|
||||||
*/
|
*/
|
||||||
function _addLaunchParams(url: string, isAuthenticated: boolean = false): string {
|
function _addLaunchParams(url: string): string {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
urlObj.searchParams.set('msLaunch', 'false');
|
urlObj.searchParams.set('msLaunch', 'false');
|
||||||
urlObj.searchParams.set('suppressPrompt', 'true');
|
urlObj.searchParams.set('suppressPrompt', 'true');
|
||||||
urlObj.searchParams.set('directDl', 'true');
|
urlObj.searchParams.set('directDl', 'true');
|
||||||
urlObj.searchParams.set('enableMobilePage', 'true');
|
urlObj.searchParams.set('enableMobilePage', 'true');
|
||||||
if (!isAuthenticated) {
|
urlObj.searchParams.set('anon', 'true');
|
||||||
urlObj.searchParams.set('anon', 'true');
|
|
||||||
}
|
|
||||||
return urlObj.toString();
|
return urlObj.toString();
|
||||||
} catch {
|
} catch {
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
const anonParam = isAuthenticated ? '' : '&anon=true';
|
return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true&anon=true`;
|
||||||
return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true${anonParam}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +115,6 @@ function _addLaunchParams(url: string, isAuthenticated: boolean = false): string
|
||||||
* Converts a meeting URL to the web app launch URL.
|
* Converts a meeting URL to the web app launch URL.
|
||||||
* @deprecated Use resolveLaunchUrl() instead for proper redirect resolution.
|
* @deprecated Use resolveLaunchUrl() instead for proper redirect resolution.
|
||||||
*/
|
*/
|
||||||
export function getMeetingLaunchUrl(url: string, isAuthenticated: boolean = false): string {
|
export function getMeetingLaunchUrl(url: string): string {
|
||||||
return _addLaunchParams(url, isAuthenticated);
|
return _addLaunchParams(url);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,39 +86,27 @@ export class BotOrchestrator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the bot - connect to Gateway, launch browser, authenticate (if configured), join meeting, enable captions.
|
* Start the bot - connect to Gateway, launch browser, join meeting, enable captions.
|
||||||
|
*
|
||||||
|
* NOTE: Authentication is disabled. The bot always joins as an anonymous guest
|
||||||
|
* with the configured bot name (typically the system bot's display name, e.g. "Nyla Larsson").
|
||||||
|
* See Teamsbot-Auth-Join-Learnings.md for details on why and how to re-enable.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (!isValidMeetingUrl(this._meetingUrl)) {
|
if (!isValidMeetingUrl(this._meetingUrl)) {
|
||||||
throw new Error(`Invalid meeting URL: ${this._meetingUrl}`);
|
throw new Error(`Invalid meeting URL: ${this._meetingUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let useAuthentication = !!(this._options.botAccountEmail && this._options.botAccountPassword);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._setState('launching');
|
this._setState('launching');
|
||||||
|
|
||||||
// Connect to Gateway WebSocket first
|
// Connect to Gateway WebSocket first
|
||||||
await this._connectToGateway();
|
await this._connectToGateway();
|
||||||
|
|
||||||
// Try joining (authenticated first, then anonymous fallback)
|
// Join meeting as anonymous guest with configured bot name
|
||||||
await this._attemptJoin(useAuthentication);
|
await this._attemptJoin();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If authenticated join failed, retry as anonymous
|
|
||||||
if (useAuthentication) {
|
|
||||||
this._logger.warn(`Authenticated join failed: ${(error as Error).message}. Retrying as anonymous guest...`);
|
|
||||||
try {
|
|
||||||
await this._cleanup();
|
|
||||||
await this._attemptJoin(false);
|
|
||||||
return;
|
|
||||||
} catch (retryError) {
|
|
||||||
this._logger.error('Anonymous fallback also failed:', retryError);
|
|
||||||
this._setState('error', (retryError as Error).message);
|
|
||||||
await this._takeScreenshot('error-fallback');
|
|
||||||
throw retryError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._logger.error('Error starting bot:', error);
|
this._logger.error('Error starting bot:', error);
|
||||||
this._setState('error', (error as Error).message);
|
this._setState('error', (error as Error).message);
|
||||||
await this._takeScreenshot('error');
|
await this._takeScreenshot('error');
|
||||||
|
|
@ -127,121 +115,27 @@ export class BotOrchestrator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to join a meeting (authenticated or anonymous).
|
* Join a meeting as anonymous guest with the configured bot name.
|
||||||
|
*
|
||||||
|
* NOTE: Authentication is disabled. See Teamsbot-Auth-Join-Learnings.md.
|
||||||
|
* The bot name (e.g. "Nyla Larsson") comes from the system bot's display name,
|
||||||
|
* configured in the Gateway. This provides a consistent identity without
|
||||||
|
* requiring Microsoft authentication.
|
||||||
*/
|
*/
|
||||||
private async _attemptJoin(authenticate: boolean): Promise<void> {
|
private async _attemptJoin(): Promise<void> {
|
||||||
// Launch browser
|
// Launch browser
|
||||||
await this._launchBrowser();
|
await this._launchBrowser();
|
||||||
|
|
||||||
// Update JoinProcedure with correct auth state and meeting URL
|
|
||||||
this._joinProcedure = new JoinProcedure(this._page!, this._logger, this._botName, authenticate, this._meetingUrl);
|
|
||||||
|
|
||||||
this._setState('navigating');
|
this._setState('navigating');
|
||||||
|
|
||||||
// STEP 1: Navigate to meeting URL and click "Continue on this browser"
|
// STEP 1: Navigate to meeting URL and click "Continue on this browser"
|
||||||
// This is the same for both authenticated and anonymous joins.
|
await this._joinProcedure!.startMeetingLauncherFlow(this._meetingUrl);
|
||||||
await this._joinProcedure.startMeetingLauncherFlow(this._meetingUrl);
|
|
||||||
|
|
||||||
// STEP 2: For authenticated joins, click "Sign in" on the pre-join page
|
// STEP 2: Enter bot name and click "Join now"
|
||||||
// instead of entering a name. The "Sign in" link is at the bottom of the
|
await this._joinProcedure!.joinMeetingLobbyFlow();
|
||||||
// anonymous pre-join page. Clicking it triggers the Microsoft login flow,
|
|
||||||
// which redirects back to an authenticated pre-join page within Teams v2.
|
|
||||||
if (authenticate) {
|
|
||||||
this._logger.info('Authenticated join: waiting for pre-join page to load, then clicking "Sign in"...');
|
|
||||||
|
|
||||||
// Wait for the pre-join page to fully load.
|
|
||||||
// After "Continue on this browser", Teams loads the light-meetings pre-join page.
|
|
||||||
// This can take 5-15 seconds and may show mic/camera permission overlays.
|
|
||||||
// The "Sign in" link appears at the bottom of the page once it's loaded.
|
|
||||||
|
|
||||||
// Wait for "Sign in" link to appear (up to 20 seconds)
|
|
||||||
let signInClicked = false;
|
|
||||||
const signInSelector = 'a:has-text("Sign in"), button:has-text("Sign in"), a:has-text("Anmelden"), button:has-text("Anmelden")';
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._logger.info('Waiting for "Sign in" link to appear on pre-join page...');
|
|
||||||
await this._page!.waitForSelector(signInSelector, { timeout: 20000, state: 'visible' });
|
|
||||||
|
|
||||||
// Click it
|
|
||||||
const signInLink = await this._page!.$(signInSelector);
|
|
||||||
if (signInLink) {
|
|
||||||
await signInLink.click();
|
|
||||||
this._logger.info('Clicked "Sign in" link on pre-join page');
|
|
||||||
signInClicked = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
this._logger.info('"Sign in" not found via waitForSelector, trying DOM scan...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: scan DOM for sign-in link
|
|
||||||
if (!signInClicked) {
|
|
||||||
// The page might have loaded but the selector didn't match exactly
|
|
||||||
signInClicked = await this._page!.evaluate(() => {
|
|
||||||
// Look for any link/button with "Sign in" or "Anmelden" text
|
|
||||||
const allElements = document.querySelectorAll('a, button, span[role="link"]');
|
|
||||||
for (let i = 0; i < allElements.length; i++) {
|
|
||||||
const el = allElements[i] as HTMLElement;
|
|
||||||
const text = el.innerText?.trim() || '';
|
|
||||||
if (text === 'Sign in' || text === 'Anmelden') {
|
|
||||||
el.click();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (signInClicked) {
|
|
||||||
this._logger.info('Clicked "Sign in" via DOM evaluation fallback');
|
|
||||||
} else {
|
|
||||||
this._logger.warn('Could not find "Sign in" link on pre-join page');
|
|
||||||
// Log page content for debugging
|
|
||||||
const pageText = await this._page!.evaluate(() => document.body?.innerText?.substring(0, 500) || '');
|
|
||||||
this._logger.warn(`Pre-join page content: ${pageText.substring(0, 300)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signInClicked) {
|
|
||||||
// Clicking "Sign in" on the Teams pre-join page opens an INLINE LOGIN MODAL
|
|
||||||
// directly on the same page (no URL change). The modal shows an email input,
|
|
||||||
// then password, then "Stay signed in?" — all on the light-meetings page.
|
|
||||||
// After completing login, Teams redirects to /v2/ with the "Join now" button.
|
|
||||||
// We pass skipNavigation=true so authProcedure does NOT navigate away.
|
|
||||||
const { AuthProcedure } = await import('./authProcedure');
|
|
||||||
const authProcedure = new AuthProcedure(this._page!, this._logger);
|
|
||||||
const authSuccess = await authProcedure.authenticateWithMicrosoft(
|
|
||||||
this._options.botAccountEmail!,
|
|
||||||
this._options.botAccountPassword!,
|
|
||||||
true // skipNavigation: preserve Teams return URL
|
|
||||||
);
|
|
||||||
|
|
||||||
if (authSuccess) {
|
|
||||||
this._logger.info('Authentication via "Sign in" link succeeded');
|
|
||||||
|
|
||||||
// Auth cookies are now set. Teams /v2/ blocks headless browsers,
|
|
||||||
// so we stay on the light-meetings page and join from here.
|
|
||||||
// The auth cookies will identify us to Teams even via light-meetings.
|
|
||||||
// We skip entering a name and just click "Join now" directly.
|
|
||||||
this._logger.info('Auth complete. Staying on light-meetings, will join with auth cookies...');
|
|
||||||
|
|
||||||
// Wait for the page to settle after the auth redirect chain
|
|
||||||
await this._page!.waitForTimeout(3000);
|
|
||||||
const settledUrl = this._page!.url();
|
|
||||||
this._logger.info(`Post-auth settled URL: ${settledUrl.substring(0, 100)}`);
|
|
||||||
} else {
|
|
||||||
this._logger.warn('Authentication via "Sign in" failed - continuing as anonymous');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._logger.warn('Could not find "Sign in" link - continuing as anonymous');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background image is managed via the user profile's default background.
|
|
||||||
// No background setup needed during the join flow.
|
|
||||||
|
|
||||||
// Join the meeting
|
|
||||||
await this._joinProcedure.joinMeetingLobbyFlow();
|
|
||||||
|
|
||||||
// Check if we're in lobby
|
// Check if we're in lobby
|
||||||
const inLobby = await this._joinProcedure.isInMeetingLobby({ waitForSeconds: 10 });
|
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 10 });
|
||||||
if (inLobby) {
|
if (inLobby) {
|
||||||
this._setState('in_lobby');
|
this._setState('in_lobby');
|
||||||
this._logger.info('Bot is in lobby, waiting to be admitted...');
|
this._logger.info('Bot is in lobby, waiting to be admitted...');
|
||||||
|
|
@ -251,7 +145,7 @@ export class BotOrchestrator {
|
||||||
await this._waitForMeetingAdmission();
|
await this._waitForMeetingAdmission();
|
||||||
|
|
||||||
this._setState('in_meeting');
|
this._setState('in_meeting');
|
||||||
this._logger.info(`Bot joined the meeting! (authenticated: ${authenticate})`);
|
this._logger.info(`Bot joined the meeting as "${this._botName}"`);
|
||||||
|
|
||||||
// Dismiss any post-join permission modals (e.g. "Manage windows on all displays")
|
// Dismiss any post-join permission modals (e.g. "Manage windows on all displays")
|
||||||
await this._joinProcedure!.dismissBrowserPermissionModals();
|
await this._joinProcedure!.dismissBrowserPermissionModals();
|
||||||
|
|
@ -266,26 +160,6 @@ export class BotOrchestrator {
|
||||||
await this._enableChat();
|
await this._enableChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up browser for retry (close browser without full shutdown).
|
|
||||||
*/
|
|
||||||
private async _cleanup(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (this._page) await this._page.close().catch(() => {});
|
|
||||||
if (this._context) await this._context.close().catch(() => {});
|
|
||||||
if (this._browser) await this._browser.close().catch(() => {});
|
|
||||||
this._page = null;
|
|
||||||
this._context = null;
|
|
||||||
this._browser = null;
|
|
||||||
this._joinProcedure = null;
|
|
||||||
this._captionsProcedure = null;
|
|
||||||
this._audioProcedure = null;
|
|
||||||
this._chatProcedure = null;
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to the Gateway WebSocket for this session.
|
* Connect to the Gateway WebSocket for this session.
|
||||||
*/
|
*/
|
||||||
|
|
@ -589,9 +463,8 @@ export class BotOrchestrator {
|
||||||
if (!window.chrome.runtime) { window.chrome.runtime = {}; }
|
if (!window.chrome.runtime) { window.chrome.runtime = {}; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize procedures
|
// Initialize procedures (always anonymous join)
|
||||||
const isAuthenticated = !!(this._options.botAccountEmail && this._options.botAccountPassword);
|
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
|
||||||
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName, isAuthenticated);
|
|
||||||
this._captionsProcedure = new CaptionsProcedure(
|
this._captionsProcedure = new CaptionsProcedure(
|
||||||
this._page,
|
this._page,
|
||||||
this._logger,
|
this._logger,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue