teamsbot auth fixes

This commit is contained in:
ValueOn AG 2026-05-12 21:31:25 +02:00
parent 09dc63d75c
commit 414e2a5e40
3 changed files with 213 additions and 63 deletions

View file

@ -65,15 +65,17 @@ export class CaptionsProcedure {
* Works for both anonymous (light-meetings) and authenticated (full Teams) UI. * Works for both anonymous (light-meetings) and authenticated (full Teams) UI.
*/ */
private async _openMoreMenu(): Promise<void> { private async _openMoreMenu(): Promise<void> {
const allSelectors = [ // Specific call-controls selectors first, broad ones last.
// In the full Teams web app, broad selectors like `aria-label*="More"`
// might match sidebar navigation buttons — restrict them to the
// call-controls area to avoid navigating away from the meeting.
const specificSelectors = [
'button[id="callingButtons-showMoreBtn"]', 'button[id="callingButtons-showMoreBtn"]',
'[data-tid="callingButtons-showMoreBtn"]', '[data-tid="callingButtons-showMoreBtn"]',
'button[aria-label*="More actions"]',
'button[aria-label*="More"]',
'[data-tid="more-button"]', '[data-tid="more-button"]',
]; ];
for (const selector of allSelectors) { for (const selector of specificSelectors) {
try { try {
const button = await this._page.$(selector); const button = await this._page.$(selector);
if (button) { if (button) {
@ -87,10 +89,37 @@ export class CaptionsProcedure {
} }
} }
// Broader selectors scoped to the calling/meeting toolbar only
const found = await this._page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button')) as HTMLElement[];
for (const btn of btns) {
const aria = (btn.getAttribute('aria-label') || '').toLowerCase();
if (!aria.includes('more')) continue;
if (btn.offsetHeight === 0 || btn.offsetWidth === 0) continue;
// Exclude sidebar navigation buttons
if (btn.closest('[data-tid="app-bar"], nav[role="navigation"]')) continue;
// Prefer buttons near calling controls
const nearCalling = btn.closest(
'[data-tid="calling-unified-bar"], [data-tid="call-controls"], '
+ '[data-tid="callingButtons-area"], [id="callingButtons-area"]',
);
if (nearCalling || aria.includes('more actions') || aria.includes('weitere aktionen')) {
btn.click();
return aria;
}
}
return null;
});
if (found) {
this._logger.info(`Clicked "More" button via scoped search: "${found}"`);
await this._page.waitForTimeout(1000);
return;
}
// Last resort: wait for the primary selector with a short timeout // Last resort: wait for the primary selector with a short timeout
try { try {
await this._page.waitForSelector(allSelectors[0], { timeout: 10000 }); await this._page.waitForSelector(specificSelectors[0], { timeout: 10000 });
await this._page.click(allSelectors[0]); await this._page.click(specificSelectors[0]);
this._logger.info('Found "More" button (after wait)'); this._logger.info('Found "More" button (after wait)');
await this._page.waitForTimeout(1000); await this._page.waitForTimeout(1000);
return; return;

View file

@ -26,15 +26,18 @@ export class ChatProcedure {
private _scanIntervalId: ReturnType<typeof setInterval> | null = null; private _scanIntervalId: ReturnType<typeof setInterval> | null = null;
private _consecutiveOpenFailures: number = 0; private _consecutiveOpenFailures: number = 0;
private static readonly _MAX_OPEN_FAILURES = 5; private static readonly _MAX_OPEN_FAILURES = 5;
private _isAuthMode: boolean;
constructor( constructor(
page: Page, page: Page,
logger: Logger, logger: Logger,
onChatMessage: (entry: ChatMessageEntry) => void onChatMessage: (entry: ChatMessageEntry) => void,
isAuthMode: boolean = false,
) { ) {
this._page = page; this._page = page;
this._logger = logger; this._logger = logger;
this._onChatMessage = onChatMessage; this._onChatMessage = onChatMessage;
this._isAuthMode = isAuthMode;
} }
/** /**
@ -55,7 +58,7 @@ export class ChatProcedure {
* authenticated Teams meeting layout. * authenticated Teams meeting layout.
*/ */
async enableChatMonitoring(): Promise<boolean> { async enableChatMonitoring(): Promise<boolean> {
this._logger.info('Enabling chat monitoring...'); this._logger.info(`Enabling chat monitoring (authMode=${this._isAuthMode})...`);
await this._dumpChatButtonDiagnostics(); await this._dumpChatButtonDiagnostics();
await this._openChatPanel(); await this._openChatPanel();
@ -64,11 +67,6 @@ export class ChatProcedure {
const isOpen = await this._isChatPanelOpen(); const isOpen = await this._isChatPanelOpen();
if (isOpen) { if (isOpen) {
this._logger.info('Chat panel opened successfully'); this._logger.info('Chat panel opened successfully');
// Light-meetings ships a "simplified compose" with a collapsed
// placeholder + dedicated expand button. The real ckeditor textbox
// is rendered but Playwright considers it invisible until expanded.
// Expand once now so the periodic scan and send path see the
// canonical ckeditor surface.
await this._ensureComposeExpanded(); await this._ensureComposeExpanded();
} else { } else {
this._logger.warn('Chat panel could not be opened - chat send/receive will not work'); this._logger.warn('Chat panel could not be opened - chat send/receive will not work');
@ -178,14 +176,15 @@ export class ChatProcedure {
*/ */
private async _isChatPanelOpen(): Promise<boolean> { private async _isChatPanelOpen(): Promise<boolean> {
return this._page.evaluate(() => { return this._page.evaluate(() => {
// Strategy 1: light-meetings / standard meeting UI — side panel container
const sidePanel = document.querySelector( const sidePanel = document.querySelector(
'[data-tid="calling-right-side-panel"]', '[data-tid="calling-right-side-panel"]',
) as HTMLElement | null; ) as HTMLElement | null;
if (!sidePanel) return false; if (sidePanel) {
const isVisible = sidePanel.offsetWidth > 0 const isVisible = sidePanel.offsetWidth > 0
&& sidePanel.offsetHeight > 0 && sidePanel.offsetHeight > 0
&& sidePanel.offsetParent !== null; && sidePanel.offsetParent !== null;
if (!isVisible) return false; if (isVisible) {
const chatHallmark = sidePanel.querySelector( const chatHallmark = sidePanel.querySelector(
'[data-tid="message-pane-layout"], ' '[data-tid="message-pane-layout"], '
+ '[data-tid="message-pane-body"], ' + '[data-tid="message-pane-body"], '
@ -194,7 +193,27 @@ export class ChatProcedure {
+ '#chat-pane-list, ' + '#chat-pane-list, '
+ '[data-app-name="chats"]', + '[data-app-name="chats"]',
); );
return chatHallmark !== null; if (chatHallmark) return true;
}
}
// Strategy 2: full Teams web app (auth mode) — the chat toggle button
// has aria-pressed="true" when the in-meeting chat panel is open.
// The correct toggle is the one with a keyboard shortcut in the label
// (e.g. "Chat (Ctrl+Shift+2)").
const allBtns = Array.from(document.querySelectorAll(
'button[aria-pressed], [role="button"][aria-pressed]',
));
for (const btn of allBtns) {
const aria = (btn.getAttribute('aria-label') || '').toLowerCase();
const hasChatHint = aria.includes('chat') || aria.includes('unterhalt');
const hasShortcutHint = aria.includes('ctrl') || aria.includes('strg');
if (hasChatHint && hasShortcutHint && btn.getAttribute('aria-pressed') === 'true') {
return true;
}
}
return false;
}); });
} }
@ -250,6 +269,26 @@ export class ChatProcedure {
|| v.includes('conversation'), || v.includes('conversation'),
); );
}; };
const isDangerousNavButton = (el: Element): boolean => {
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
const tid = (el.getAttribute('data-tid') || '').toLowerCase();
const id = (el.id || '').toLowerCase();
// Teams sidebar "Chats" navigation — clicking navigates away from meeting
if (aria === 'chats' || aria === 'unterhaltungen') return true;
// Tab items inside the chat panel (Files, Recap, Whiteboard, etc.)
if (tid.startsWith('tab-item-')) return true;
// "More chat options" submenu triggers — open dropdowns, not chat panel
if (aria.includes('more chat options') || aria.includes('weitere chatoptionen')) return true;
// Participant count link
if (tid === 'chat-header-participant-count') return true;
// Chat join button (call join, not chat toggle)
if (tid === 'chat-join-button') return true;
// Chat app tab IDs (com.microsoft.chattabs.*)
if (id.startsWith('com.microsoft.chattabs.')) return true;
// Buttons inside the Teams left app-bar / sidebar navigation
if (el.closest('[data-tid="app-bar"], [data-tid="app-bar-search"], nav[role="navigation"]')) return true;
return false;
};
const isVisible = (el: HTMLElement): boolean => const isVisible = (el: HTMLElement): boolean =>
el.offsetHeight > 0 && el.offsetWidth > 0 && el.offsetParent !== null; el.offsetHeight > 0 && el.offsetWidth > 0 && el.offsetParent !== null;
const keyOf = (el: Element): string => const keyOf = (el: Element): string =>
@ -263,7 +302,7 @@ export class ChatProcedure {
document.querySelectorAll('button, [role="button"], [role="menuitem"]'), document.querySelectorAll('button, [role="button"], [role="menuitem"]'),
) as HTMLElement[]; ) as HTMLElement[];
const candidates = all const candidates = all
.filter((el) => matchesChatHint(el) && isVisible(el)) .filter((el) => matchesChatHint(el) && isVisible(el) && !isDangerousNavButton(el))
.filter((el) => !alreadyTried.includes(keyOf(el))); .filter((el) => !alreadyTried.includes(keyOf(el)));
if (candidates.length === 0) return { picked: null as null | { key: string; id: string; tid: string; aria: string; toggle: boolean } }; if (candidates.length === 0) return { picked: null as null | { key: string; id: string; tid: string; aria: string; toggle: boolean } };
@ -308,6 +347,31 @@ export class ChatProcedure {
this._logger.info('Chat panel opened successfully'); this._logger.info('Chat panel opened successfully');
return true; return true;
} }
// Fallback for auth mode: if we clicked a toggle button and its
// aria-pressed is now "true", the panel IS open even if the DOM
// detection via calling-right-side-panel fails (different layout
// in the full Teams web app).
if (click.picked.toggle) {
const pressedNow = await this._page.evaluate((btnKey: string) => {
const all = Array.from(
document.querySelectorAll('button, [role="button"], [role="menuitem"]'),
);
for (const el of all) {
const k = `${el.id || ''}|${el.getAttribute('data-tid') || ''}|${el.getAttribute('aria-label') || ''}`;
if (k === btnKey) return el.getAttribute('aria-pressed');
}
return null;
}, click.picked.key);
if (pressedNow === 'true') {
this._logger.info(
'Chat panel DOM not detected, but toggle aria-pressed="true" — treating as open (auth mode fallback)',
);
return true;
}
}
this._logger.info( this._logger.info(
'Chat button clicked but panel not detected — will try a different candidate next round', 'Chat button clicked but panel not detected — will try a different candidate next round',
); );
@ -662,6 +726,11 @@ export class ChatProcedure {
if (!this._isSubscribed || !this._page) return; if (!this._isSubscribed || !this._page) return;
try { try {
if (!(await this._isChatPanelOpen())) { if (!(await this._isChatPanelOpen())) {
// In auth mode, never try to reopen the chat panel — the toggle
// navigates to the Chat section and minimizes the meeting to PiP.
if (this._isAuthMode) {
this._logger.info('Chat panel closed (auth mode) — skipping reopen to avoid PiP');
} else {
if (this._consecutiveOpenFailures >= ChatProcedure._MAX_OPEN_FAILURES) { if (this._consecutiveOpenFailures >= ChatProcedure._MAX_OPEN_FAILURES) {
this._logger.warn( this._logger.warn(
`Chat panel failed to open ${this._consecutiveOpenFailures} consecutive times - ` + `Chat panel failed to open ${this._consecutiveOpenFailures} consecutive times - ` +
@ -686,6 +755,7 @@ export class ChatProcedure {
return; return;
} }
await this._page.waitForTimeout(1000); await this._page.waitForTimeout(1000);
}
} else { } else {
this._consecutiveOpenFailures = 0; this._consecutiveOpenFailures = 0;
} }
@ -958,8 +1028,6 @@ export class ChatProcedure {
// overlay used in anonymous / pre-join layouts has a generic // overlay used in anonymous / pre-join layouts has a generic
// [data-tid="ckeditor"] [role="textbox"] in a floating layer that // [data-tid="ckeditor"] [role="textbox"] in a floating layer that
// looks like a chat input but does NOT post into the meeting chat). // looks like a chat input but does NOT post into the meeting chat).
// Surfacing the failure fast lets the periodic scan re-toggle the
// panel and the Gateway resend the message.
const panelOpen = await this._isChatPanelOpen(); const panelOpen = await this._isChatPanelOpen();
if (!panelOpen) { if (!panelOpen) {
this._logger.warn('Chat panel not open — aborting send so the periodic scan can re-toggle it'); this._logger.warn('Chat panel not open — aborting send so the periodic scan can re-toggle it');
@ -1168,8 +1236,13 @@ export class ChatProcedure {
} }
await this._page.waitForTimeout(200); await this._page.waitForTimeout(200);
// Try clicking the send button first; fall back to Enter key.
const sent = await this._clickSendButton();
if (!sent) {
await this._page.keyboard.press('Enter'); await this._page.keyboard.press('Enter');
this._logger.info(`Chat message sent (stage=${stageUsed})`); }
this._logger.info(`Chat message sent (stage=${stageUsed}, via=${sent ? 'sendBtn' : 'enter'})`);
return true; return true;
} }
@ -1192,6 +1265,42 @@ export class ChatProcedure {
return false; return false;
} }
/**
* Click the chat send button if visible. Teams renders a send button
* (arrow icon) once text is present in the compose box. Clicking it is
* more reliable than pressing Enter, which CKEditor sometimes swallows.
*/
private async _clickSendButton(): Promise<boolean> {
try {
const sendSelectors = [
'button[data-tid="newMessageCommands-send"]',
'button[data-tid="send-message-button"]',
'button[aria-label="Send" i]',
'button[aria-label="Senden" i]',
'button[aria-label*="Send message" i]',
'button[aria-label*="Nachricht senden" i]',
'[data-tid="chat-pane-compose-message-footer"] button[type="submit"]',
'[data-tid="message-pane-footer"] button[type="submit"]',
];
for (const sel of sendSelectors) {
const btn = await this._page.$(sel);
if (btn) {
const visible = await btn.isVisible().catch(() => false);
if (visible) {
await btn.click();
this._logger.info(`Clicked send button: ${sel}`);
await btn.dispose();
return true;
}
await btn.dispose();
}
}
} catch (err) {
this._logger.warn(`Send button click failed: ${err}`);
}
return false;
}
/** /**
* Dump rich diagnostics about contenteditable / textbox candidates in the * Dump rich diagnostics about contenteditable / textbox candidates in the
* page so we can adapt selectors when Teams ships a UI change. * page so we can adapt selectors when Teams ships a UI change.

View file

@ -15,7 +15,7 @@ import { AudioCaptureProcedure } from './audioCaptureProcedure';
import { ChatProcedure, ChatMessageEntry } from './chatProcedure'; import { ChatProcedure, ChatMessageEntry } from './chatProcedure';
import { AuthProcedure, MfaChallenge } from './authProcedure'; import { AuthProcedure, MfaChallenge } from './authProcedure';
import { TeamsActionsService } from './teamsActionsService'; import { TeamsActionsService } from './teamsActionsService';
import { BackgroundProcedure } from './backgroundProcedure';
import { isValidMeetingUrl, getMeetingLaunchUrl, resolveLaunchUrl } from './meetingUrlParser'; import { isValidMeetingUrl, getMeetingLaunchUrl, resolveLaunchUrl } from './meetingUrlParser';
// Optional: canvas "avatar" video (config.botUseCanvasVideo) replaces the Chromium // Optional: canvas "avatar" video (config.botUseCanvasVideo) replaces the Chromium
@ -85,6 +85,8 @@ export class BotOrchestrator {
private _frameNavMediaRebindTimer: ReturnType<typeof setTimeout> | null = null; private _frameNavMediaRebindTimer: ReturnType<typeof setTimeout> | null = null;
/** Re-apply gUM + video senders for a few seconds after join */ /** Re-apply gUM + video senders for a few seconds after join */
private _canvasRebindTimer: ReturnType<typeof setInterval> | null = null; private _canvasRebindTimer: ReturnType<typeof setInterval> | null = null;
/** Whether the bot is running in authenticated mode (full Teams web app) */
private _isAuthMode: boolean = false;
constructor( constructor(
sessionId: string, sessionId: string,
@ -218,8 +220,6 @@ export class BotOrchestrator {
await this._ensureMicOn(); await this._ensureMicOn();
if (config.botUseCanvasVideo) { if (config.botUseCanvasVideo) {
await this._ensureCameraOn(); await this._ensureCameraOn();
const bg = new BackgroundProcedure(this._page!, this._logger);
void bg.trySelectNoVirtualBackground();
} }
// STEP 2: Enter bot name and click "Join now" // STEP 2: Enter bot name and click "Join now"
@ -435,8 +435,6 @@ export class BotOrchestrator {
await this._ensureMicOn(); await this._ensureMicOn();
if (config.botUseCanvasVideo) { if (config.botUseCanvasVideo) {
await this._ensureCameraOn(); await this._ensureCameraOn();
const bg = new BackgroundProcedure(this._page!, this._logger);
void bg.trySelectNoVirtualBackground();
} }
// STEP 5: Poll for "Join now" on the pre-join screen // STEP 5: Poll for "Join now" on the pre-join screen
@ -466,10 +464,22 @@ export class BotOrchestrator {
await this._ensureCameraOnInMeeting(); await this._ensureCameraOnInMeeting();
this._startCanvasRebindAfterJoin(); this._startCanvasRebindAfterJoin();
} }
// Auth mode: wait for meeting UI to stabilize, then captions first,
// then chat. The chat toggle in the full Teams web app navigates to
// the Chat section and minimizes the meeting to PiP if triggered too
// early or while the More menu is still open from captions setup.
if (this._isAuthMode) {
await this._page!.waitForTimeout(3000);
await this._enableTranscriptCapture();
await this._page!.waitForTimeout(2000);
await this._enableChat();
} else {
await Promise.all([ await Promise.all([
this._enableTranscriptCapture(), this._enableTranscriptCapture(),
this._enableChat(), this._enableChat(),
]); ]);
}
await this._sendJoinGreeting(); await this._sendJoinGreeting();
} }
@ -1048,6 +1058,7 @@ export class BotOrchestrator {
* (`rejectMediaDescriptionsUpdateAsync`). Keep these flag sets separate. * (`rejectMediaDescriptionsUpdateAsync`). Keep these flag sets separate.
*/ */
private async _launchBrowser(authMode: boolean = false): Promise<void> { private async _launchBrowser(authMode: boolean = false): Promise<void> {
this._isAuthMode = authMode;
this._logger.info(`Launching browser (authMode=${authMode})...`); this._logger.info(`Launching browser (authMode=${authMode})...`);
// When BOT_ANON_USE_AUTH_BROWSER_SETUP is on, the anon path uses the // When BOT_ANON_USE_AUTH_BROWSER_SETUP is on, the anon path uses the
@ -1188,6 +1199,7 @@ export class BotOrchestrator {
isFinal: true, isFinal: true,
}); });
}, },
this._isAuthMode,
); );
// DEBUG TOGGLE: skip both wrappers when isolating the Teams anonymous // DEBUG TOGGLE: skip both wrappers when isolating the Teams anonymous