feat: authenticated bot join, virtual background, language selectors
- New authProcedure.ts: Microsoft login flow (email/password, MFA detection, stay signed in) - New backgroundProcedure.ts: download image URL, upload as Teams virtual background - Orchestrator: authenticate before join when botAccountEmail provided - JoinProcedure: skip name input for authenticated joins - meetingUrlParser: anon=true only for anonymous joins - SessionManager/HttpServer: pass new fields through the chain - Updated Teams caption language selectors for current UI Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
496268e936
commit
79027f190b
8 changed files with 506 additions and 23 deletions
244
src/bot/authProcedure.ts
Normal file
244
src/bot/authProcedure.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { Page } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthProcedure - Handles Microsoft account authentication in the browser.
|
||||||
|
*
|
||||||
|
* Used for dedicated bot accounts (e.g. bot@valueon.ch) to join Teams meetings
|
||||||
|
* as an authenticated user instead of an anonymous guest.
|
||||||
|
*
|
||||||
|
* Benefits of authenticated join:
|
||||||
|
* - Full access to Teams features (language settings, background effects)
|
||||||
|
* - No lobby wait (if user is member of the meeting org)
|
||||||
|
* - Bot name is the account display name
|
||||||
|
* - Can set spoken language for captions
|
||||||
|
*/
|
||||||
|
|
||||||
|
const _LOGIN_URL = 'https://login.microsoftonline.com';
|
||||||
|
const _TEAMS_URL = 'https://teams.microsoft.com';
|
||||||
|
|
||||||
|
export class AuthProcedure {
|
||||||
|
private _page: Page;
|
||||||
|
private _logger: Logger;
|
||||||
|
|
||||||
|
constructor(page: Page, logger: Logger) {
|
||||||
|
this._page = page;
|
||||||
|
this._logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with Microsoft using email + password.
|
||||||
|
* Navigates to Microsoft login, enters credentials, and waits for successful sign-in.
|
||||||
|
*
|
||||||
|
* @returns true if authentication was successful, false otherwise
|
||||||
|
*/
|
||||||
|
async authenticateWithMicrosoft(email: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this._logger.info(`Authenticating as ${email}...`);
|
||||||
|
|
||||||
|
// Navigate to Microsoft login
|
||||||
|
await this._page.goto(_LOGIN_URL, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for email input
|
||||||
|
const emailInput = await this._page.waitForSelector(
|
||||||
|
'input[type="email"], input[name="loginfmt"]',
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
if (!emailInput) {
|
||||||
|
this._logger.error('Could not find email input field');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter email
|
||||||
|
await emailInput.fill(email);
|
||||||
|
this._logger.info('Email entered');
|
||||||
|
|
||||||
|
// Click Next
|
||||||
|
const nextButton = await this._page.$('input[type="submit"], button[type="submit"]');
|
||||||
|
if (nextButton) {
|
||||||
|
await nextButton.click();
|
||||||
|
} else {
|
||||||
|
await this._page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Check for "account not found" error
|
||||||
|
const errorElement = await this._page.$('#usernameError, [data-tid="error"]');
|
||||||
|
if (errorElement) {
|
||||||
|
const errorText = await errorElement.textContent();
|
||||||
|
this._logger.error(`Login error after email: ${errorText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for password input
|
||||||
|
const passwordInput = await this._page.waitForSelector(
|
||||||
|
'input[type="password"], input[name="passwd"]',
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
if (!passwordInput) {
|
||||||
|
this._logger.error('Could not find password input field');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter password
|
||||||
|
await passwordInput.fill(password);
|
||||||
|
this._logger.info('Password entered');
|
||||||
|
|
||||||
|
// Click Sign in
|
||||||
|
const signInButton = await this._page.$('input[type="submit"], button[type="submit"]');
|
||||||
|
if (signInButton) {
|
||||||
|
await signInButton.click();
|
||||||
|
} else {
|
||||||
|
await this._page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
await this._page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check for MFA prompt
|
||||||
|
const mfaDetected = await this._detectMfa();
|
||||||
|
if (mfaDetected) {
|
||||||
|
this._logger.error('MFA prompt detected - cannot authenticate automatically. Please disable MFA for the bot account.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for password error
|
||||||
|
const pwdError = await this._page.$('#passwordError, [data-tid="error"]');
|
||||||
|
if (pwdError) {
|
||||||
|
const errorText = await pwdError.textContent();
|
||||||
|
this._logger.error(`Login error after password: ${errorText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Stay signed in?" prompt
|
||||||
|
await this._handleStaySignedIn();
|
||||||
|
|
||||||
|
// Verify authentication succeeded by checking for Teams or Microsoft landing
|
||||||
|
const isAuthenticated = await this._verifyAuthentication();
|
||||||
|
if (isAuthenticated) {
|
||||||
|
this._logger.info(`Successfully authenticated as ${email}`);
|
||||||
|
} else {
|
||||||
|
this._logger.error('Authentication verification failed - may not be signed in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAuthenticated;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error(`Authentication failed: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect MFA prompts (authenticator app, SMS, phone call).
|
||||||
|
*/
|
||||||
|
private async _detectMfa(): Promise<boolean> {
|
||||||
|
const mfaSelectors = [
|
||||||
|
'#idDiv_SAOTCAS_Description', // Authenticator app prompt
|
||||||
|
'#idDiv_SAOTCC_Description', // Code entry
|
||||||
|
'#idDiv_SAASDS_Description', // SMS verification
|
||||||
|
'[data-tid="phoneVerification"]', // Phone verification
|
||||||
|
'text=Approve sign in request', // Authenticator approval
|
||||||
|
'text=Enter code', // Code entry
|
||||||
|
'text=Verify your identity', // Generic MFA
|
||||||
|
'text=Approve sign-in request', // Authenticator
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of mfaSelectors) {
|
||||||
|
try {
|
||||||
|
const element = await this._page.$(selector);
|
||||||
|
if (element) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue checking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the "Stay signed in?" prompt after successful login.
|
||||||
|
*/
|
||||||
|
private async _handleStaySignedIn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Look for "Stay signed in?" or "Angemeldet bleiben?" prompt
|
||||||
|
const staySignedInSelectors = [
|
||||||
|
'input#idSIButton9', // "Yes" button (Stay signed in)
|
||||||
|
'button#idSIButton9', // Alternative
|
||||||
|
'input[value="Yes"]',
|
||||||
|
'input[value="Ja"]',
|
||||||
|
'button:has-text("Yes")',
|
||||||
|
'button:has-text("Ja")',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of staySignedInSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
this._logger.info('Clicked "Stay signed in" - Yes');
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No prompt found - that's OK, some tenants don't show it
|
||||||
|
this._logger.debug('No "Stay signed in" prompt detected');
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.debug(`Stay signed in handling: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that authentication was successful.
|
||||||
|
* Checks if we're on a Microsoft/Teams page with an authenticated session.
|
||||||
|
*/
|
||||||
|
private async _verifyAuthentication(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const url = this._page.url();
|
||||||
|
|
||||||
|
// If we're on Teams or Microsoft portal, we're authenticated
|
||||||
|
if (url.includes('teams.microsoft.com') ||
|
||||||
|
url.includes('office.com') ||
|
||||||
|
url.includes('microsoftonline.com/common/oauth2') ||
|
||||||
|
url.includes('myapps.microsoft.com')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit and check again (redirects may be in progress)
|
||||||
|
await this._page.waitForTimeout(3000);
|
||||||
|
const finalUrl = this._page.url();
|
||||||
|
|
||||||
|
if (finalUrl.includes('teams.microsoft.com') ||
|
||||||
|
finalUrl.includes('office.com') ||
|
||||||
|
finalUrl.includes('portal.office.com')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any error states
|
||||||
|
const loginPage = finalUrl.includes('login.microsoftonline.com');
|
||||||
|
if (loginPage) {
|
||||||
|
// Still on login page - authentication may have failed
|
||||||
|
const hasError = await this._page.$('[data-tid="error"], #passwordError, #usernameError');
|
||||||
|
if (hasError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Might still be processing
|
||||||
|
await this._page.waitForTimeout(5000);
|
||||||
|
const afterWaitUrl = this._page.url();
|
||||||
|
return !afterWaitUrl.includes('login.microsoftonline.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're somewhere else entirely, assume authenticated
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error(`Authentication verification error: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/bot/backgroundProcedure.ts
Normal file
182
src/bot/backgroundProcedure.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { Page } from 'playwright';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackgroundProcedure - Handles setting a virtual background in Teams pre-join screen.
|
||||||
|
*
|
||||||
|
* Must be called AFTER the bot is on the pre-join screen but BEFORE clicking "Join now".
|
||||||
|
* Only works for authenticated joins (anonymous guests may not have background options).
|
||||||
|
*/
|
||||||
|
export class BackgroundProcedure {
|
||||||
|
private _page: Page;
|
||||||
|
private _logger: Logger;
|
||||||
|
|
||||||
|
constructor(page: Page, logger: Logger) {
|
||||||
|
this._page = page;
|
||||||
|
this._logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a virtual background from a URL on the Teams pre-join screen.
|
||||||
|
*
|
||||||
|
* @param imageUrl - URL of the background image to download and apply
|
||||||
|
* @returns true if background was set successfully
|
||||||
|
*/
|
||||||
|
async setBackgroundFromUrl(imageUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
this._logger.info(`Setting virtual background from: ${imageUrl}`);
|
||||||
|
|
||||||
|
// Download the image to a temp file
|
||||||
|
const tempDir = os.tmpdir();
|
||||||
|
const tempFile = path.join(tempDir, `poweron-bg-${Date.now()}.jpg`);
|
||||||
|
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
this._logger.error(`Failed to download background image: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
fs.writeFileSync(tempFile, buffer);
|
||||||
|
this._logger.info(`Background image downloaded: ${buffer.length} bytes -> ${tempFile}`);
|
||||||
|
|
||||||
|
// Open background effects panel
|
||||||
|
const panelOpened = await this._openBackgroundEffectsPanel();
|
||||||
|
if (!panelOpened) {
|
||||||
|
this._logger.warn('Could not open background effects panel - skipping background');
|
||||||
|
this._cleanup(tempFile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the image
|
||||||
|
const uploaded = await this._uploadBackgroundImage(tempFile);
|
||||||
|
this._cleanup(tempFile);
|
||||||
|
|
||||||
|
if (uploaded) {
|
||||||
|
this._logger.info('Virtual background set successfully');
|
||||||
|
} else {
|
||||||
|
this._logger.warn('Could not upload background image');
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploaded;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error(`Background setup failed: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the background effects panel on the pre-join screen.
|
||||||
|
*/
|
||||||
|
private async _openBackgroundEffectsPanel(): Promise<boolean> {
|
||||||
|
const backgroundButtonSelectors = [
|
||||||
|
'button[data-tid="toggle-background-effect"]',
|
||||||
|
'button[aria-label*="Background" i]',
|
||||||
|
'button[aria-label*="Hintergrund" i]',
|
||||||
|
'button[aria-label*="background filters" i]',
|
||||||
|
'button[aria-label*="Hintergrundeffekte" i]',
|
||||||
|
'#video-background-effects-button',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of backgroundButtonSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
this._logger.info(`Clicked background effects button: ${selector}`);
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue trying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.warn('Background effects button not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a background image via the file input in the background effects panel.
|
||||||
|
*/
|
||||||
|
private async _uploadBackgroundImage(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Look for "Add new" or "+" button to upload custom image
|
||||||
|
const addButtonSelectors = [
|
||||||
|
'button[aria-label*="Add new" i]',
|
||||||
|
'button[aria-label*="Neu hinzufügen" i]',
|
||||||
|
'button[aria-label*="add image" i]',
|
||||||
|
'button[aria-label*="Bild hinzufügen" i]',
|
||||||
|
'button[data-tid="add-background-image"]',
|
||||||
|
'.add-image-button',
|
||||||
|
];
|
||||||
|
|
||||||
|
let addButtonClicked = false;
|
||||||
|
for (const selector of addButtonSelectors) {
|
||||||
|
try {
|
||||||
|
const button = await this._page.$(selector);
|
||||||
|
if (button) {
|
||||||
|
await button.click();
|
||||||
|
this._logger.info(`Clicked add image button: ${selector}`);
|
||||||
|
addButtonClicked = true;
|
||||||
|
await this._page.waitForTimeout(1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find file input (may appear after clicking add, or may already exist)
|
||||||
|
const fileInput = await this._page.$('input[type="file"]');
|
||||||
|
if (fileInput) {
|
||||||
|
await fileInput.setInputFiles(filePath);
|
||||||
|
this._logger.info('Background image uploaded via file input');
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// The uploaded image should be auto-selected, but click it to be sure
|
||||||
|
// Look for the last image in the background gallery (newly uploaded)
|
||||||
|
try {
|
||||||
|
const images = await this._page.$$('[data-tid="background-image"], .background-image-item');
|
||||||
|
if (images.length > 0) {
|
||||||
|
const lastImage = images[images.length - 1];
|
||||||
|
await lastImage.click();
|
||||||
|
this._logger.info('Selected uploaded background image');
|
||||||
|
await this._page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this._logger.debug('Could not click uploaded image - may be auto-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addButtonClicked) {
|
||||||
|
this._logger.warn('No add-image button or file input found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.error(`Background image upload failed: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up temp file.
|
||||||
|
*/
|
||||||
|
private _cleanup(filePath: string): void {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,13 @@ export class JoinProcedure {
|
||||||
private _page: Page;
|
private _page: Page;
|
||||||
private _logger: Logger;
|
private _logger: Logger;
|
||||||
private _botName: string;
|
private _botName: string;
|
||||||
|
private _isAuthenticated: boolean;
|
||||||
|
|
||||||
constructor(page: Page, logger: Logger, botName: string) {
|
constructor(page: Page, logger: Logger, botName: string, isAuthenticated: boolean = false) {
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this._logger = logger;
|
this._logger = logger;
|
||||||
this._botName = botName;
|
this._botName = botName;
|
||||||
|
this._isAuthenticated = isAuthenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,13 +29,15 @@ export class JoinProcedure {
|
||||||
* 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, etc.) 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);
|
launchUrl = await resolveLaunchUrl(meetingUrl, this._isAuthenticated);
|
||||||
this._logger.info(`Resolved launch URL: ${launchUrl}`);
|
this._logger.info(`Resolved launch URL: ${launchUrl} (authenticated: ${this._isAuthenticated})`);
|
||||||
} 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);
|
launchUrl = getMeetingLaunchUrl(meetingUrl);
|
||||||
|
|
@ -104,10 +108,17 @@ 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...');
|
this._logger.info(`Starting lobby join flow... (authenticated: ${this._isAuthenticated})`);
|
||||||
|
|
||||||
// Enter the bot name (this also implicitly waits for the pre-join page to load)
|
if (this._isAuthenticated) {
|
||||||
|
// Authenticated join: name comes from Microsoft account, no name input needed
|
||||||
|
// Wait for the pre-join page to load (look for Join now button)
|
||||||
|
this._logger.info('Authenticated join - skipping name input, waiting for Join button...');
|
||||||
|
await this._page.waitForTimeout(3000);
|
||||||
|
} else {
|
||||||
|
// Anonymous join: enter bot name in the name input field
|
||||||
await this._enterBotName();
|
await this._enterBotName();
|
||||||
|
}
|
||||||
|
|
||||||
// Click "Join now"
|
// Click "Join now"
|
||||||
await this._clickJoinNow();
|
await this._clickJoinNow();
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
export async function resolveLaunchUrl(meetingUrl: string): Promise<string> {
|
export async function resolveLaunchUrl(meetingUrl: string, isAuthenticated: boolean = false): Promise<string> {
|
||||||
const trimmed = meetingUrl.trim();
|
const trimmed = meetingUrl.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -81,29 +81,36 @@ export async function resolveLaunchUrl(meetingUrl: string): Promise<string> {
|
||||||
resolvedUrl.searchParams.set('enableMobilePage', 'true');
|
resolvedUrl.searchParams.set('enableMobilePage', 'true');
|
||||||
resolvedUrl.searchParams.set('suppressPrompt', 'true');
|
resolvedUrl.searchParams.set('suppressPrompt', 'true');
|
||||||
|
|
||||||
|
// Only add anon=true for anonymous joins
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
resolvedUrl.searchParams.set('anon', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
return _addLaunchParams(trimmed, isAuthenticated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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): string {
|
function _addLaunchParams(url: string, isAuthenticated: boolean = false): 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 {
|
||||||
// If URL parsing fails, append as query string
|
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true&anon=true`;
|
const anonParam = isAuthenticated ? '' : '&anon=true';
|
||||||
|
return `${url}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true${anonParam}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,6 +118,6 @@ function _addLaunchParams(url: string): 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): string {
|
export function getMeetingLaunchUrl(url: string, isAuthenticated: boolean = false): string {
|
||||||
return _addLaunchParams(url);
|
return _addLaunchParams(url, isAuthenticated);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ export interface OrchestratorOptions {
|
||||||
gatewayWsUrl: string;
|
gatewayWsUrl: string;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
botAccountEmail?: string;
|
||||||
|
botAccountPassword?: string;
|
||||||
|
backgroundImageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -81,13 +84,15 @@ export class BotOrchestrator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the bot - connect to Gateway, launch browser, join meeting, enable captions.
|
* Start the bot - connect to Gateway, launch browser, authenticate (if configured), join meeting, enable captions.
|
||||||
*/
|
*/
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = !!(this._options.botAccountEmail && this._options.botAccountPassword);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._setState('launching');
|
this._setState('launching');
|
||||||
|
|
||||||
|
|
@ -97,12 +102,36 @@ export class BotOrchestrator {
|
||||||
// Launch browser
|
// Launch browser
|
||||||
await this._launchBrowser();
|
await this._launchBrowser();
|
||||||
|
|
||||||
|
// Authenticate with Microsoft if bot account is configured
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const { AuthProcedure } = await import('./authProcedure');
|
||||||
|
const authProcedure = new AuthProcedure(this._page!, this._logger);
|
||||||
|
const authSuccess = await authProcedure.authenticateWithMicrosoft(
|
||||||
|
this._options.botAccountEmail!,
|
||||||
|
this._options.botAccountPassword!
|
||||||
|
);
|
||||||
|
if (!authSuccess) {
|
||||||
|
this._logger.warn('Microsoft authentication failed - falling back to anonymous join');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._setState('navigating');
|
this._setState('navigating');
|
||||||
|
|
||||||
// Navigate to meeting and handle launcher
|
// Navigate to meeting and handle launcher
|
||||||
await this._joinProcedure!.startMeetingLauncherFlow(this._meetingUrl);
|
await this._joinProcedure!.startMeetingLauncherFlow(this._meetingUrl);
|
||||||
|
|
||||||
// Join the meeting (enter lobby)
|
// Set virtual background if configured (must be done on pre-join screen, before "Join now")
|
||||||
|
if (this._options.backgroundImageUrl && this._page) {
|
||||||
|
try {
|
||||||
|
const { BackgroundProcedure } = await import('./backgroundProcedure');
|
||||||
|
const bgProcedure = new BackgroundProcedure(this._page, this._logger);
|
||||||
|
await bgProcedure.setBackgroundFromUrl(this._options.backgroundImageUrl);
|
||||||
|
} catch (error) {
|
||||||
|
this._logger.warn(`Background image setup failed (non-fatal): ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join the meeting (enter lobby for anonymous, direct join for authenticated)
|
||||||
await this._joinProcedure!.joinMeetingLobbyFlow();
|
await this._joinProcedure!.joinMeetingLobbyFlow();
|
||||||
|
|
||||||
// Check if we're in lobby
|
// Check if we're in lobby
|
||||||
|
|
@ -380,7 +409,8 @@ export class BotOrchestrator {
|
||||||
this._page = await this._context.newPage();
|
this._page = await this._context.newPage();
|
||||||
|
|
||||||
// Initialize procedures
|
// Initialize procedures
|
||||||
this._joinProcedure = new JoinProcedure(this._page, this._logger, this._botName);
|
const isAuthenticated = !!(this._options.botAccountEmail && this._options.botAccountPassword);
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
httpServer = new HttpServer({
|
httpServer = new HttpServer({
|
||||||
onJoinRequest: async (sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language) => {
|
onJoinRequest: async (sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, backgroundImageUrl) => {
|
||||||
await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language);
|
await sessionManager.createSession(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, backgroundImageUrl);
|
||||||
},
|
},
|
||||||
onLeaveRequest: async (sessionId) => {
|
onLeaveRequest: async (sessionId) => {
|
||||||
await sessionManager.endSession(sessionId);
|
await sessionManager.endSession(sessionId);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { logger } from '../utils/logger';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
|
||||||
export interface HttpServerCallbacks {
|
export interface HttpServerCallbacks {
|
||||||
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string) => Promise<void>;
|
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, backgroundImageUrl?: string) => Promise<void>;
|
||||||
onLeaveRequest: (sessionId: string) => Promise<void>;
|
onLeaveRequest: (sessionId: string) => Promise<void>;
|
||||||
onStatusRequest: (sessionId: string) => { state: string; error?: string } | null;
|
onStatusRequest: (sessionId: string) => { state: string; error?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
@ -77,14 +77,14 @@ export class HttpServer {
|
||||||
// Deploy a new bot
|
// Deploy a new bot
|
||||||
this._app.post('/api/bot', async (req: Request, res: Response) => {
|
this._app.post('/api/bot', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language } = req.body;
|
const { sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, backgroundImageUrl } = req.body;
|
||||||
|
|
||||||
if (!sessionId || !meetingUrl) {
|
if (!sessionId || !meetingUrl) {
|
||||||
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
|
res.status(400).json({ error: 'Missing required fields: sessionId, meetingUrl' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language);
|
await this._callbacks.onJoinRequest(sessionId, meetingUrl, botName, instanceId, gatewayWsUrl, language, botAccountEmail, botAccountPassword, backgroundImageUrl);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ export class SessionManager {
|
||||||
botName?: string,
|
botName?: string,
|
||||||
instanceId?: string,
|
instanceId?: string,
|
||||||
gatewayWsUrl?: string,
|
gatewayWsUrl?: string,
|
||||||
language?: string
|
language?: string,
|
||||||
|
botAccountEmail?: string,
|
||||||
|
botAccountPassword?: string,
|
||||||
|
backgroundImageUrl?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._sessions.has(sessionId)) {
|
if (this._sessions.has(sessionId)) {
|
||||||
logger.warn(`Session ${sessionId} already exists`);
|
logger.warn(`Session ${sessionId} already exists`);
|
||||||
|
|
@ -47,6 +50,9 @@ export class SessionManager {
|
||||||
|
|
||||||
logger.info(`Creating session ${sessionId} for meeting: ${meetingUrl}`);
|
logger.info(`Creating session ${sessionId} for meeting: ${meetingUrl}`);
|
||||||
logger.info(`Gateway WebSocket URL: ${gatewayWsUrl || 'not provided, using config'}`);
|
logger.info(`Gateway WebSocket URL: ${gatewayWsUrl || 'not provided, using config'}`);
|
||||||
|
if (botAccountEmail) {
|
||||||
|
logger.info(`Authenticated join as: ${botAccountEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
const callbacks: OrchestratorCallbacks = {
|
const callbacks: OrchestratorCallbacks = {
|
||||||
onStateChange: (state, message) => {
|
onStateChange: (state, message) => {
|
||||||
|
|
@ -67,6 +73,9 @@ export class SessionManager {
|
||||||
gatewayWsUrl: gatewayWsUrl || config.gatewayWsUrl,
|
gatewayWsUrl: gatewayWsUrl || config.gatewayWsUrl,
|
||||||
instanceId: instanceId || 'default',
|
instanceId: instanceId || 'default',
|
||||||
language: language,
|
language: language,
|
||||||
|
botAccountEmail: botAccountEmail,
|
||||||
|
botAccountPassword: botAccountPassword,
|
||||||
|
backgroundImageUrl: backgroundImageUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const orchestrator = new BotOrchestrator(
|
const orchestrator = new BotOrchestrator(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue