From 539e1a916b14c6c737e6e9d5ca9f8f343c87bc63 Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Sun, 17 May 2026 17:51:32 +0200 Subject: [PATCH] fix: click avatar instead of hover to open profile dialog --- src/teampulse/resolver.py | 237 ++++++++++++++++---------------------- 1 file changed, 102 insertions(+), 135 deletions(-) diff --git a/src/teampulse/resolver.py b/src/teampulse/resolver.py index 8c4b988..a10fcc0 100644 --- a/src/teampulse/resolver.py +++ b/src/teampulse/resolver.py @@ -4,17 +4,6 @@ from pathlib import Path from playwright.sync_api import Page -from teampulse.monitor import _PROFILE_EMAIL_SELECTOR - -# Selectors for the clickable sender element (avatar or name) that opens the profile card -_AUTHOR_SELECTORS = [ - # Channel meeting: avatar next to reply-message-header name - "[data-tid='reply-message-header-avatar']", - "[data-tid='post-message-header-avatar']", - # Meeting chat: author name element - "[data-tid='message-author-name']", -] - class Resolver: def __init__(self, cache_path: Path, page: Page): @@ -45,175 +34,153 @@ class Resolver: encoding="utf-8", ) - def _hover_sender(self, display_name: str) -> tuple[str, dict]: - """Find the sender element and hover to open the profile card. - Returns (strategy, bbox) or ('', {}) if not found.""" + def _click_avatar(self, display_name: str) -> str: + """Find the sender's avatar and click it to open the profile dialog. + Returns strategy name or '' if not found.""" - def _mouse_move_to(el) -> dict | None: - """Scroll element into view, get its bbox, move mouse there.""" - el.scroll_into_view_if_needed() - bbox = el.bounding_box() - if not bbox or bbox['width'] == 0: - return None - x = bbox['x'] + bbox['width'] / 2 - y = bbox['y'] + bbox['height'] / 2 - self._page.mouse.move(x, y) - return bbox + def _click_el(el) -> bool: + try: + el.scroll_into_view_if_needed() + bbox = el.bounding_box() + if not bbox or bbox['width'] == 0: + return False + el.click() + return True + except Exception: + return False - # Strategy 1: reply-message-header (channel meetings) + # Strategy 1: reply-message-header (channel meetings) — click avatar for header in self._page.query_selector_all("[data-tid='reply-message-header']"): try: if display_name not in header.inner_text(): continue avatar = header.query_selector("[data-tid='reply-message-header-avatar']") - if avatar: - bbox = _mouse_move_to(avatar) - if bbox: - return "reply-avatar", bbox + if avatar and _click_el(avatar): + return "reply-avatar" for span in header.query_selector_all("span"): - if span.inner_text().strip() == display_name: - bbox = _mouse_move_to(span) - if bbox: - return "reply-span", bbox + if span.inner_text().strip() == display_name and _click_el(span): + return "reply-span" except Exception: continue - # Strategy 2: aria-label - safe_name = display_name.replace("'", "\\'") - for el in self._page.query_selector_all(f"[aria-label*='{safe_name}']"): - try: - if el.is_visible(): - bbox = _mouse_move_to(el) - if bbox: - return "aria-label", bbox - except Exception: - continue - - # Strategy 3: message-author-name — also try avatar to the left + # Strategy 2: message-author-name — find avatar in parent tree and click for el in self._page.query_selector_all("[data-tid='message-author-name']"): try: if el.inner_text().strip() != display_name: continue - # Look for avatar in parent tree (usually 3-4 levels up) - avatar_bbox = self._page.evaluate("""(nameEl) => { + # Find avatar div in parent tree via JS and click it + clicked = self._page.evaluate("""(nameEl) => { let p = nameEl.parentElement; for (let i = 0; i < 6; i++) { if (!p) break; - const av = p.querySelector('[data-tid*="avatar"], [class*="avatar"] img, [class*="Avatar"] img'); + const av = p.querySelector('[class*="ChatMessage__avatar"], [class*="avatar"]'); if (av) { - const r = av.getBoundingClientRect(); - if (r.width > 0) return {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width, h: r.height}; + const inner = av.querySelector('img, [role="img"], button') || av; + const r = inner.getBoundingClientRect(); + if (r.width > 0) { inner.click(); return true; } } p = p.parentElement; } - return null; + return false; }""", el) - if avatar_bbox: - self._page.mouse.move(avatar_bbox['x'], avatar_bbox['y']) - return "nearby-avatar", avatar_bbox - bbox = _mouse_move_to(el) - if bbox: - return "author-name", bbox + if clicked: + return "nearby-avatar" + # Fallback: click the name span + if _click_el(el): + return "author-name" except Exception: continue - return "", {} + return "" def _extract_email_from_profile(self, display_name: str) -> str | None: try: - strategy, bbox = self._hover_sender(display_name) + strategy = self._click_avatar(display_name) except Exception as e: - print(f" Hover auf '{display_name}' fehlgeschlagen: {e}") + print(f" Klick auf '{display_name}' fehlgeschlagen: {e}") return None if not strategy: print(f" '{display_name}' nicht im sichtbaren Chat gefunden.") return None - x = bbox.get('x', 0) + bbox.get('w', bbox.get('width', 32)) / 2 if isinstance(bbox, dict) else 0 - y = bbox.get('y', 0) + bbox.get('h', bbox.get('height', 32)) / 2 if isinstance(bbox, dict) else 0 - print(f" '{display_name}' via '{strategy}' → Viewport ({x:.0f}, {y:.0f}) — warte auf Karte...") - # Approach 1: gradual mouse movement (simulate human) - self._page.mouse.move(x - 50, y, steps=5) - time.sleep(0.1) - self._page.mouse.move(x, y, steps=10) - time.sleep(0.3) - - # Approach 2: dispatch pointer/mouse events directly on element at that position - hit_info = self._page.evaluate(f"""() => {{ - const el = document.elementFromPoint({x}, {y}); - if (!el) return 'no element at position'; - ['pointerover','pointerenter','mouseover','mouseenter','pointermove','mousemove'].forEach(t => {{ - el.dispatchEvent(new MouseEvent(t, {{ - bubbles: true, cancelable: true, view: window, - clientX: {x}, clientY: {y} - }})); - }}); - return el.tagName + '[' + (el.getAttribute('data-tid')||'') + '] ' + el.className.substring(0,60); - }}""") - print(f" Element bei ({x:.0f},{y:.0f}): {hit_info}") - - # Screenshot after hover attempt - try: - from pathlib import Path as _Path - debug_dir = _Path("debug"); debug_dir.mkdir(exist_ok=True) - safe = display_name.replace(" ", "_") - self._page.screenshot(path=str(debug_dir / f"{safe}_after_hover.png")) - except Exception: - pass - - time.sleep(1.5) # Wait for hover card to fully appear + print(f" '{display_name}' via '{strategy}' geklickt — warte auf Dialog...") try: - # Stage 1: wait for card container to appear - self._page.wait_for_selector(".lpc_ip_root_class", timeout=5_000) + # Wait for the profile dialog to open + # Could be lpc_ip_root_class (hover card) or a role="dialog" modal + self._page.wait_for_selector( + ".lpc_ip_root_class, [role='dialog']", + timeout=6_000, + ) except Exception: - print(f" Hover-Card für '{display_name}' erscheint nicht.") - self._page.mouse.move(0, 0) + print(f" Profil-Dialog für '{display_name}' erscheint nicht.") + self._page.keyboard.press("Escape") return None try: - # Move mouse onto the card itself to keep it open while it loads - card_el = self._page.query_selector(".lpc_ip_root_class") - if card_el: - card_el.hover() - - # Stage 2: card content is inside a Shadow DOM () - # Wait for the mailto link to appear inside the shadow root - self._page.wait_for_function( - """() => { - const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); - if (!lpcCard || !lpcCard.shadowRoot) return false; - return !!lpcCard.shadowRoot.querySelector('a[href*="mailto:"]'); - }""", - timeout=10_000, - ) + # Check which dialog appeared and extract email + # Try shadow DOM of lpc-card first email = self._page.evaluate("""() => { + // Check lpc-card shadow DOM const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); - if (!lpcCard || !lpcCard.shadowRoot) return null; - const link = lpcCard.shadowRoot.querySelector('a[href*="mailto:"]'); - return link ? link.href.replace('mailto:', '').trim() : null; + if (lpcCard && lpcCard.shadowRoot) { + const link = lpcCard.shadowRoot.querySelector('a[href*="mailto:"]'); + if (link) return link.href.replace('mailto:', '').trim(); + // Also check text content for email pattern + const text = lpcCard.shadowRoot.textContent; + const match = text.match(/[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/); + if (match) return match[0]; + } + // Check regular dialog + const dialog = document.querySelector("[role='dialog']"); + if (dialog) { + const link = dialog.querySelector('a[href*="mailto:"]'); + if (link) return link.href.replace('mailto:', '').trim(); + const text = dialog.textContent; + const match = text.match(/[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/); + if (match) return match[0]; + } + return null; }""") - self._page.mouse.move(0, 0) + + if not email: + # Wait a bit more for the dialog to fully load (two-stage) + time.sleep(2) + email = self._page.evaluate("""() => { + function findEmail(root) { + const link = root.querySelector('a[href*="mailto:"]'); + if (link) return link.href.replace('mailto:', '').trim(); + const match = root.textContent.match(/[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/); + if (match) return match[0]; + // Search shadow roots recursively + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + const found = findEmail(el.shadowRoot); + if (found) return found; + } + } + return null; + } + return findEmail(document.body); + }""") + + if not email: + # Diagnostic: show what's in the dialog + content = self._page.evaluate("""() => { + const d = document.querySelector('.lpc_ip_root_class lpc-card'); + if (d && d.shadowRoot) return 'shadow: ' + d.shadowRoot.textContent.trim().substring(0, 200); + const r = document.querySelector("[role='dialog']"); + if (r) return 'dialog: ' + r.textContent.trim().substring(0, 200); + return '(nichts gefunden)'; + }""") + print(f" Dialog offen, E-Mail nicht gefunden. Inhalt: {content!r}") + + self._page.keyboard.press("Escape") time.sleep(0.3) return email or None - except Exception: - # Screenshot + shadow root text for diagnosis - try: - from pathlib import Path as _Path - debug_dir = _Path("debug"); debug_dir.mkdir(exist_ok=True) - safe = display_name.replace(" ", "_") - self._page.screenshot(path=str(debug_dir / f"{safe}_card_state.png")) - except Exception: - pass - card_text = self._page.evaluate("""() => { - const lpcCard = document.querySelector('.lpc_ip_root_class lpc-card'); - if (!lpcCard) return '(kein lpc-card Element)'; - if (!lpcCard.shadowRoot) return '(kein Shadow Root)'; - return lpcCard.shadowRoot.textContent.trim().substring(0, 300); - }""") or "(leer)" - print(f" Karte für '{display_name}' offen, E-Mail nicht gefunden.") - print(f" Shadow-Root-Inhalt: {card_text!r}") - print(f" Screenshots in debug/{safe}_*.png") - self._page.mouse.move(0, 0) + + except Exception as e: + print(f" Fehler beim Lesen des Profil-Dialogs: {e}") + self._page.keyboard.press("Escape") return None