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:
ValueOn AG 2026-02-15 16:35:00 +01:00
parent 31c196978b
commit ba8dab5a98
2 changed files with 114 additions and 39 deletions

View file

@ -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
if (option) { const optionSelectors = [
await option.click(); `[role="option"]:has-text("${name}")`,
this._logger.info(`Selected spoken language: ${name}`); `li:has-text("${name}")`,
break; `div[role="option"]:has-text("${name}")`,
`span:has-text("${name}")`,
];
for (const optSel of optionSelectors) {
const option = await this._page.$(optSel);
if (option) {
await option.click();
this._logger.info(`Selected spoken language: ${name} (via ${optSel})`);
languageSet = true;
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}`);

View file

@ -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');