fix: robust meeting admission for auth join, fix spoken language selectors for Teams UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
31c196978b
commit
ba8dab5a98
2 changed files with 114 additions and 39 deletions
|
|
@ -194,17 +194,19 @@ export class CaptionsProcedure {
|
||||||
// Wait a moment for the captions UI to stabilize
|
// Wait a moment for the captions UI to stabilize
|
||||||
await this._page.waitForTimeout(2000);
|
await this._page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Strategy 1: Try "Caption settings" button near the captions area
|
let settingsOpened = false;
|
||||||
// This is typically a gear icon or "..." button in the captions banner
|
|
||||||
|
// Strategy 1: Try "Caption settings" gear button near the captions area
|
||||||
const captionSettingsSelectors = [
|
const captionSettingsSelectors = [
|
||||||
'button[aria-label*="Caption settings"]',
|
'button[aria-label*="Caption settings" i]',
|
||||||
'button[aria-label*="caption settings"]',
|
'button[aria-label*="Captions settings" i]',
|
||||||
'button[aria-label*="Captions settings"]',
|
'button[aria-label*="Untertiteleinstellungen" i]',
|
||||||
'button[data-tid="caption-settings-button"]',
|
'button[data-tid="caption-settings-button"]',
|
||||||
'button[id="caption-settings-button"]',
|
'button[id="caption-settings-button"]',
|
||||||
|
// Teams 2025+: settings icon inside the captions banner
|
||||||
|
'button[aria-label*="Settings" i][data-tid*="caption" i]',
|
||||||
];
|
];
|
||||||
|
|
||||||
let settingsOpened = false;
|
|
||||||
for (const selector of captionSettingsSelectors) {
|
for (const selector of captionSettingsSelectors) {
|
||||||
try {
|
try {
|
||||||
const button = await this._page.$(selector);
|
const button = await this._page.$(selector);
|
||||||
|
|
@ -220,27 +222,30 @@ export class CaptionsProcedure {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: If no caption settings button found, try More menu > Language and speech
|
// Strategy 2: More menu > "Language and speech" / "Captions & transcripts"
|
||||||
if (!settingsOpened) {
|
if (!settingsOpened) {
|
||||||
this._logger.info('Caption settings button not found, trying More menu > Language and speech...');
|
this._logger.info('Caption settings button not found, trying More menu > Language and speech...');
|
||||||
|
|
||||||
await this._openMoreMenu();
|
await this._openMoreMenu();
|
||||||
await this._page.waitForTimeout(500);
|
await this._page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Look for "Language and speech" or "Captions & transcripts" menu items
|
// All selectors must have an element prefix for Playwright
|
||||||
// Teams has renamed this menu multiple times across versions
|
|
||||||
const languageMenuSelectors = [
|
const languageMenuSelectors = [
|
||||||
'[data-tid="captions-and-transcripts-button"]',
|
'[data-tid="captions-and-transcripts-button"]',
|
||||||
':has-text("Captions & transcripts")',
|
|
||||||
':has-text("Captions and transcripts")',
|
|
||||||
':has-text("Untertitel und Transkripte")',
|
|
||||||
':has-text("Language and speech")',
|
|
||||||
':has-text("Spoken language")',
|
|
||||||
':has-text("Sprache und Spracheingabe")',
|
|
||||||
':has-text("Gesprochene Sprache")',
|
|
||||||
'[data-tid="language-and-speech-button"]',
|
'[data-tid="language-and-speech-button"]',
|
||||||
'button:has-text("Language")',
|
'div[role="menuitem"]:has-text("Captions & transcripts")',
|
||||||
'button:has-text("Sprache")',
|
'div[role="menuitem"]:has-text("Captions and transcripts")',
|
||||||
|
'div[role="menuitem"]:has-text("Untertitel und Transkripte")',
|
||||||
|
'div[role="menuitem"]:has-text("Language and speech")',
|
||||||
|
'div[role="menuitem"]:has-text("Sprache und Spracheingabe")',
|
||||||
|
'button:has-text("Captions & transcripts")',
|
||||||
|
'button:has-text("Captions and transcripts")',
|
||||||
|
'button:has-text("Language and speech")',
|
||||||
|
'button:has-text("Sprache und Spracheingabe")',
|
||||||
|
'li:has-text("Captions")',
|
||||||
|
'li:has-text("Language")',
|
||||||
|
'li:has-text("Untertitel")',
|
||||||
|
'li:has-text("Sprache")',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of languageMenuSelectors) {
|
for (const selector of languageMenuSelectors) {
|
||||||
|
|
@ -259,20 +264,54 @@ export class CaptionsProcedure {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Search all visible menu items by evaluating text content
|
||||||
|
if (!settingsOpened) {
|
||||||
|
this._logger.info('Standard selectors failed, scanning menu items by text...');
|
||||||
|
|
||||||
|
const found = await this._page.evaluate(() => {
|
||||||
|
const keywords = [
|
||||||
|
'caption', 'captions', 'untertitel',
|
||||||
|
'language', 'sprache', 'spoken',
|
||||||
|
];
|
||||||
|
// Search all menu items, buttons, and clickable elements
|
||||||
|
const candidates = document.querySelectorAll(
|
||||||
|
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, li'
|
||||||
|
);
|
||||||
|
for (const el of candidates) {
|
||||||
|
const text = (el as HTMLElement).innerText?.toLowerCase() || '';
|
||||||
|
if (keywords.some(kw => text.includes(kw))) {
|
||||||
|
(el as HTMLElement).click();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
this._logger.info(`Clicked menu item by text scan: "${found}"`);
|
||||||
|
settingsOpened = true;
|
||||||
|
await this._page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!settingsOpened) {
|
if (!settingsOpened) {
|
||||||
this._logger.warn('Could not open language settings - captions will use default language (English)');
|
this._logger.warn('Could not open language settings - captions will use default language (English)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now look for the "Language settings" / "Change spoken language" sub-option if needed
|
// Look for sub-options like "Change spoken language" / "Language settings"
|
||||||
const langSettingsSelectors = [
|
const langSettingsSelectors = [
|
||||||
':has-text("Change spoken language")',
|
'button:has-text("Change spoken language")',
|
||||||
':has-text("Gesprochene Sprache ändern")',
|
'button:has-text("Gesprochene Sprache ändern")',
|
||||||
':has-text("Language settings")',
|
|
||||||
':has-text("Spracheinstellungen")',
|
|
||||||
'button:has-text("Language settings")',
|
'button:has-text("Language settings")',
|
||||||
|
'button:has-text("Spracheinstellungen")',
|
||||||
'button:has-text("Spoken language")',
|
'button:has-text("Spoken language")',
|
||||||
'button:has-text("Gesprochene Sprache")',
|
'button:has-text("Gesprochene Sprache")',
|
||||||
|
'div[role="menuitem"]:has-text("Change spoken language")',
|
||||||
|
'div[role="menuitem"]:has-text("Spoken language")',
|
||||||
|
'div[role="menuitem"]:has-text("Gesprochene Sprache")',
|
||||||
|
'a:has-text("Change spoken language")',
|
||||||
|
'a:has-text("Spoken language")',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of langSettingsSelectors) {
|
for (const selector of langSettingsSelectors) {
|
||||||
|
|
@ -289,63 +328,85 @@ export class CaptionsProcedure {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for the spoken language dropdown
|
// Look for the spoken language dropdown/combobox
|
||||||
|
let languageSet = false;
|
||||||
const dropdownSelectors = [
|
const dropdownSelectors = [
|
||||||
'select[aria-label*="spoken language" i]',
|
'select[aria-label*="spoken language" i]',
|
||||||
'select[aria-label*="Meeting spoken language" i]',
|
'select[aria-label*="Meeting spoken language" i]',
|
||||||
|
'select[aria-label*="Gesprochene Sprache" i]',
|
||||||
'[data-tid="spoken-language-dropdown"]',
|
'[data-tid="spoken-language-dropdown"]',
|
||||||
|
'div[role="combobox"]',
|
||||||
'div[role="listbox"]',
|
'div[role="listbox"]',
|
||||||
'select', // Generic fallback
|
'select', // Generic fallback
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of dropdownSelectors) {
|
for (const selector of dropdownSelectors) {
|
||||||
|
if (languageSet) break;
|
||||||
try {
|
try {
|
||||||
const dropdown = await this._page.$(selector);
|
const dropdown = await this._page.$(selector);
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
const tagName = await dropdown.evaluate(el => el.tagName.toLowerCase());
|
const tagName = await dropdown.evaluate(el => el.tagName.toLowerCase());
|
||||||
|
|
||||||
if (tagName === 'select') {
|
if (tagName === 'select') {
|
||||||
// Native select element - try to select by text
|
// Native select element
|
||||||
for (const name of targetNames) {
|
for (const name of targetNames) {
|
||||||
try {
|
try {
|
||||||
await this._page.selectOption(selector, { label: name });
|
await this._page.selectOption(selector, { label: name });
|
||||||
this._logger.info(`Selected spoken language: ${name}`);
|
this._logger.info(`Selected spoken language: ${name}`);
|
||||||
|
languageSet = true;
|
||||||
break;
|
break;
|
||||||
} catch {
|
} catch {
|
||||||
// Try next name variant
|
// Try next name variant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fluent UI dropdown - click and select from options
|
// Fluent UI dropdown/combobox
|
||||||
await dropdown.click();
|
await dropdown.click();
|
||||||
await this._page.waitForTimeout(500);
|
await this._page.waitForTimeout(500);
|
||||||
|
|
||||||
for (const name of targetNames) {
|
for (const name of targetNames) {
|
||||||
try {
|
try {
|
||||||
const option = await this._page.$(`[role="option"]:has-text("${name}")`);
|
// Try role="option" first, then generic text search
|
||||||
|
const optionSelectors = [
|
||||||
|
`[role="option"]:has-text("${name}")`,
|
||||||
|
`li:has-text("${name}")`,
|
||||||
|
`div[role="option"]:has-text("${name}")`,
|
||||||
|
`span:has-text("${name}")`,
|
||||||
|
];
|
||||||
|
for (const optSel of optionSelectors) {
|
||||||
|
const option = await this._page.$(optSel);
|
||||||
if (option) {
|
if (option) {
|
||||||
await option.click();
|
await option.click();
|
||||||
this._logger.info(`Selected spoken language: ${name}`);
|
this._logger.info(`Selected spoken language: ${name} (via ${optSel})`);
|
||||||
|
languageSet = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (languageSet) break;
|
||||||
} catch {
|
} catch {
|
||||||
// Try next name variant
|
// Try next name variant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
if (languageSet) break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Continue
|
// Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click "Update" or "Apply" button
|
if (!languageSet) {
|
||||||
|
this._logger.warn('Could not find/select spoken language in dropdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click "Update" / "Apply" / "Confirm" button
|
||||||
const updateSelectors = [
|
const updateSelectors = [
|
||||||
'button:has-text("Update")',
|
'button:has-text("Update")',
|
||||||
'button:has-text("Apply")',
|
'button:has-text("Apply")',
|
||||||
|
'button:has-text("Confirm")',
|
||||||
'button:has-text("Aktualisieren")',
|
'button:has-text("Aktualisieren")',
|
||||||
'button:has-text("Übernehmen")',
|
'button:has-text("Übernehmen")',
|
||||||
|
'button:has-text("Bestätigen")',
|
||||||
'button[data-tid="language-update-button"]',
|
'button[data-tid="language-update-button"]',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -365,7 +426,7 @@ export class CaptionsProcedure {
|
||||||
|
|
||||||
// Close any open dialogs/menus
|
// Close any open dialogs/menus
|
||||||
await this._page.keyboard.press('Escape');
|
await this._page.keyboard.press('Escape');
|
||||||
this._logger.info('Spoken language setting attempt completed');
|
this._logger.info(`Spoken language setting attempt completed (set: ${languageSet})`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._logger.warn(`Could not set spoken language to ${this._language}: ${error}`);
|
this._logger.warn(`Could not set spoken language to ${this._language}: ${error}`);
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,8 @@ export class BotOrchestrator {
|
||||||
private async _waitForMeetingAdmission(): Promise<void> {
|
private async _waitForMeetingAdmission(): Promise<void> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const timeout = config.timeouts.lobbyWait;
|
const timeout = config.timeouts.lobbyWait;
|
||||||
|
let consecutiveNoSignal = 0;
|
||||||
|
const maxNoSignal = 5; // Allow several cycles with no lobby/meeting signal before giving up
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
while (Date.now() - startTime < timeout) {
|
||||||
// Check if we're in the meeting
|
// Check if we're in the meeting
|
||||||
|
|
@ -524,12 +526,24 @@ export class BotOrchestrator {
|
||||||
|
|
||||||
// Check if still in lobby
|
// Check if still in lobby
|
||||||
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 2 });
|
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 2 });
|
||||||
if (!inLobby) {
|
if (inLobby) {
|
||||||
// Might have been rejected or meeting ended
|
consecutiveNoSignal = 0;
|
||||||
throw new Error('Bot was removed from lobby or meeting ended');
|
this._logger.info('Still waiting in lobby...');
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._logger.info('Still waiting in lobby...');
|
// Neither in meeting nor in lobby — this can happen legitimately:
|
||||||
|
// - Authenticated users skip lobby, but meeting UI takes seconds to load
|
||||||
|
// - Page is transitioning between states
|
||||||
|
// Only give up after several consecutive cycles with no signal
|
||||||
|
consecutiveNoSignal++;
|
||||||
|
this._logger.info(`No lobby/meeting signal detected (attempt ${consecutiveNoSignal}/${maxNoSignal}), waiting...`);
|
||||||
|
|
||||||
|
if (consecutiveNoSignal >= maxNoSignal) {
|
||||||
|
// Take a screenshot for debugging before giving up
|
||||||
|
await this._takeScreenshot('no-meeting-signal');
|
||||||
|
throw new Error('Bot was removed from lobby or meeting ended');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Timeout waiting to be admitted from lobby');
|
throw new Error('Timeout waiting to be admitted from lobby');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue