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
await this._page.waitForTimeout(2000);
// Strategy 1: Try "Caption settings" button near the captions area
// This is typically a gear icon or "..." button in the captions banner
let settingsOpened = false;
// Strategy 1: Try "Caption settings" gear button near the captions area
const captionSettingsSelectors = [
'button[aria-label*="Caption settings"]',
'button[aria-label*="caption settings"]',
'button[aria-label*="Captions settings"]',
'button[aria-label*="Caption settings" i]',
'button[aria-label*="Captions settings" i]',
'button[aria-label*="Untertiteleinstellungen" i]',
'button[data-tid="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) {
try {
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) {
this._logger.info('Caption settings button not found, trying More menu > Language and speech...');
await this._openMoreMenu();
await this._page.waitForTimeout(500);
await this._page.waitForTimeout(1000);
// Look for "Language and speech" or "Captions & transcripts" menu items
// Teams has renamed this menu multiple times across versions
// All selectors must have an element prefix for Playwright
const languageMenuSelectors = [
'[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"]',
'button:has-text("Language")',
'button:has-text("Sprache")',
'div[role="menuitem"]:has-text("Captions & transcripts")',
'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) {
@ -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) {
this._logger.warn('Could not open language settings - captions will use default language (English)');
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 = [
':has-text("Change spoken language")',
':has-text("Gesprochene Sprache ändern")',
':has-text("Language settings")',
':has-text("Spracheinstellungen")',
'button:has-text("Change spoken language")',
'button:has-text("Gesprochene Sprache ändern")',
'button:has-text("Language settings")',
'button:has-text("Spracheinstellungen")',
'button:has-text("Spoken language")',
'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) {
@ -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 = [
'select[aria-label*="spoken language" i]',
'select[aria-label*="Meeting spoken language" i]',
'select[aria-label*="Gesprochene Sprache" i]',
'[data-tid="spoken-language-dropdown"]',
'div[role="combobox"]',
'div[role="listbox"]',
'select', // Generic fallback
];
for (const selector of dropdownSelectors) {
if (languageSet) break;
try {
const dropdown = await this._page.$(selector);
if (dropdown) {
const tagName = await dropdown.evaluate(el => el.tagName.toLowerCase());
if (tagName === 'select') {
// Native select element - try to select by text
// Native select element
for (const name of targetNames) {
try {
await this._page.selectOption(selector, { label: name });
this._logger.info(`Selected spoken language: ${name}`);
languageSet = true;
break;
} catch {
// Try next name variant
}
}
} else {
// Fluent UI dropdown - click and select from options
// Fluent UI dropdown/combobox
await dropdown.click();
await this._page.waitForTimeout(500);
for (const name of targetNames) {
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) {
await option.click();
this._logger.info(`Selected spoken language: ${name}`);
this._logger.info(`Selected spoken language: ${name} (via ${optSel})`);
languageSet = true;
break;
}
}
if (languageSet) break;
} catch {
// Try next name variant
}
}
}
break;
if (languageSet) break;
}
} catch {
// 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 = [
'button:has-text("Update")',
'button:has-text("Apply")',
'button:has-text("Confirm")',
'button:has-text("Aktualisieren")',
'button:has-text("Übernehmen")',
'button:has-text("Bestätigen")',
'button[data-tid="language-update-button"]',
];
@ -365,7 +426,7 @@ export class CaptionsProcedure {
// Close any open dialogs/menus
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) {
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> {
const startTime = Date.now();
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) {
// Check if we're in the meeting
@ -524,12 +526,24 @@ export class BotOrchestrator {
// Check if still in lobby
const inLobby = await this._joinProcedure!.isInMeetingLobby({ waitForSeconds: 2 });
if (!inLobby) {
// Might have been rejected or meeting ended
throw new Error('Bot was removed from lobby or meeting ended');
if (inLobby) {
consecutiveNoSignal = 0;
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');