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