From ba8dab5a9861c67554272dd5e064bd20f43bcd59 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 15 Feb 2026 16:35:00 +0100 Subject: [PATCH] fix: robust meeting admission for auth join, fix spoken language selectors for Teams UI Co-authored-by: Cursor --- src/bot/captionsProcedure.ts | 131 +++++++++++++++++++++++++---------- src/bot/orchestrator.ts | 22 ++++-- 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/src/bot/captionsProcedure.ts b/src/bot/captionsProcedure.ts index e1f6ca9..2098202 100644 --- a/src/bot/captionsProcedure.ts +++ b/src/bot/captionsProcedure.ts @@ -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}")`); - if (option) { - await option.click(); - this._logger.info(`Selected spoken language: ${name}`); - break; + // 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} (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}`); diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index a704bb7..44803ab 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -514,6 +514,8 @@ export class BotOrchestrator { private async _waitForMeetingAdmission(): Promise { 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');