diff --git a/main.py b/main.py index 272e4fe..e692c3b 100644 --- a/main.py +++ b/main.py @@ -87,12 +87,24 @@ def main(): monitor._current_user = current_user print(f"Eingeloggt als: {current_user}") - # The meet URL opens a join-lobby, not the chat — stay on Teams main. - # The user navigates to the meeting chat manually in the browser. - print("\nBitte im Browser-Fenster zum Meeting-Chat navigieren:") - print(" Linke Sidebar → Chat → laufendes Meeting anklicken\n") + if meeting_url: + print("Navigiere zur Meeting-URL...") + page.goto(meeting_url) + # Check whether we landed on a chat/channel (good) or join lobby (fallback) + try: + page.wait_for_selector( + "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']", + timeout=6000, + ) + print("Chat direkt erreicht.") + except Exception: + print("Join-Lobby erkannt — bitte im Browser-Fenster zum Chat navigieren:") + print(" Linke Sidebar → Chat oder Kanal → laufendes Meeting\n") + else: + print("\nBitte im Browser-Fenster zum Meeting-Chat navigieren:") + print(" Linke Sidebar → Chat oder Kanal → laufendes Meeting\n") - print("\nPoste '!start \"Name des Vortragenden\"' im Chat um zu beginnen.\n") + print("\nPoste '!start Name' im Chat um zu beginnen.\n") window = monitor.run() diff --git a/scripts/discover_dom.py b/scripts/discover_dom.py index d25355f..2b22fed 100644 --- a/scripts/discover_dom.py +++ b/scripts/discover_dom.py @@ -1,14 +1,11 @@ -"""Run this script once with a Teams meeting chat open. -It prints DOM info to identify selectors for messages and profile cards. +"""DOM discovery for Teams Web — meeting chat and channel posts. Usage: - .venv/bin/python scripts/discover_dom.py [teams-chat-url] + .venv/bin/python scripts/discover_dom.py """ -import sys from pathlib import Path from playwright.sync_api import sync_playwright -URL = sys.argv[1] if len(sys.argv) > 1 else "https://teams.microsoft.com" SESSION_DIR = str(Path.home() / ".teampulse" / "session") @@ -20,34 +17,77 @@ def main(): args=["--no-sandbox"], ) page = browser.pages[0] if browser.pages else browser.new_page() - page.goto(URL) - print("Navigiere im Browser zu einem Meeting-Chat, dann hier Enter drücken...") + page.goto("https://teams.microsoft.com") + print("Navigiere im Browser zum Channel/Chat mit sichtbaren Nachrichten, dann Enter...") input() - candidates = page.query_selector_all( - "[data-tid*='message'], [class*='message'], [role='listitem']" - ) - print(f"\n{len(candidates)} potenzielle Nachrichten-Elemente gefunden") - for i, el in enumerate(candidates[:5]): - print(f"\n--- Element {i} ---") - print(f" tag: {el.evaluate('e => e.tagName')}") - print(f" data-tid: {el.get_attribute('data-tid')}") - print(f" class (100 Zch): {(el.get_attribute('class') or '')[:100]}") - print(f" text (100 Zch): {el.inner_text()[:100].replace(chr(10), ' ')}") + results = page.evaluate("""() => { + const out = {}; - print("\nKlicke im Browser auf einen Sender-Namen, dann hier Enter drücken...") - input() + // 1. All unique data-tid values on the page + const tids = new Set(); + document.querySelectorAll('[data-tid]').forEach(el => tids.add(el.getAttribute('data-tid'))); + out.allDataTids = [...tids].sort(); - cards = page.query_selector_all( - "[class*='persona'], [class*='Persona'], [class*='profile'], [data-tid*='persona']" - ) - print(f"\n{len(cards)} potenzielle Profilkarten-Elemente gefunden") - for i, el in enumerate(cards[:5]): - print(f"\n--- Karte {i} ---") - print(f" tag: {el.evaluate('e => e.tagName')}") - print(f" data-tid: {el.get_attribute('data-tid')}") - print(f" class (100 Zch): {(el.get_attribute('class') or '')[:100]}") - print(f" text (100 Zch): {el.inner_text()[:100].replace(chr(10), ' ')}") + // 2. Try common message selectors + const msgSelectors = [ + "[data-tid='chat-pane-message']", + "[data-tid='channel-message-content']", + "[data-tid='message-body']", + "[data-tid='messageBody']", + "[class*='fui-ChatMessage']:not([class*='Control'])", + "[class*='message-body']", + "[class*='messageBody']", + "[class*='chatMessage']", + "[class*='ChatMessage']", + ]; + out.msgSelectorHits = {}; + msgSelectors.forEach(sel => { + try { + out.msgSelectorHits[sel] = document.querySelectorAll(sel).length; + } catch(e) {} + }); + + // 3. Find elements whose text contains known message content + const knownTexts = ['Oliver Hofmann', '!stop', 'test', '!start']; + out.knownTextMatches = []; + document.querySelectorAll('*').forEach(el => { + if (el.children.length > 0) return; + const t = el.textContent.trim(); + if (knownTexts.some(kt => t === kt || t.startsWith(kt))) { + let p = el; + const ancestors = []; + for (let i = 0; i < 5 && p; i++) { + ancestors.push({ + tag: p.tagName, + dataTid: p.getAttribute('data-tid'), + cls: (p.className || '').substring(0, 80) + }); + p = p.parentElement; + } + out.knownTextMatches.push({ text: t.substring(0, 40), ancestors }); + } + }); + out.knownTextMatches = out.knownTextMatches.slice(0, 10); + + return out; + }""") + + print("\n--- Alle data-tid Werte auf der Seite ---") + print(", ".join(results["allDataTids"])) + + print("\n--- Nachrichten-Selektoren ---") + for sel, count in results["msgSelectorHits"].items(): + if count > 0: + print(f" ✓ {sel}: {count} Treffer") + else: + print(f" {sel}: 0") + + print("\n--- Elemente mit bekanntem Text ---") + for match in results["knownTextMatches"]: + print(f"\n Text: \"{match['text']}\"") + for anc in match["ancestors"]: + print(f" {anc['tag']} data-tid='{anc['dataTid']}' class='{anc['cls'][:60]}'") print("\nEnter zum Schließen...") input() diff --git a/src/teampulse/monitor.py b/src/teampulse/monitor.py index c8858cd..047df5e 100644 --- a/src/teampulse/monitor.py +++ b/src/teampulse/monitor.py @@ -6,16 +6,58 @@ from playwright.sync_api import Page from teampulse.models import AuditEntry, AuditWindow, ChatMessage # Selectors verified against Teams Web DOM (2026-05-17) -# _MSG_SELECTOR: individual user chat messages (not system events) -# _SENDER_SELECTOR: author name inside each message -# _PROFILE_EMAIL_SELECTOR: mailto link inside Live Persona Card (.lpc_ip_root_class) -_MSG_SELECTOR = "[data-tid='chat-pane-message']" -_SENDER_SELECTOR = "[data-tid='message-author-name']" +# Supports both channel meetings (channel-pane-message) and meeting chat (chat-pane-message) +_MSG_SELECTOR = "[data-tid='channel-pane-message'], [data-tid='chat-pane-message']" _PROFILE_EMAIL_SELECTOR = ".lpc_ip_root_class a[href*='mailto:']" _START_RE = re.compile(r'^!start(?:\s+(.+))?$', re.IGNORECASE) _STOP_RE = re.compile(r'^!stop$', re.IGNORECASE) -_QUOTE_CHARS = '"“”' # straight and typographic quotes +_QUOTE_CHARS = '"“”' # straight, left and right double quote + +# JavaScript that extracts (sender, text, id) from both channel and meeting chat DOM +_POLL_JS = """() => { + const msgs = []; + + // Channel meeting: channel-pane-message contains reply-message-header + message-body siblings + const channelPanes = document.querySelectorAll("[data-tid='channel-pane-message']"); + if (channelPanes.length > 0) { + channelPanes.forEach((pane, paneIdx) => { + const els = pane.querySelectorAll( + "[data-tid='reply-message-header'], [data-tid='message-body']" + ); + let currentSender = ''; + let senderIdx = 0; + els.forEach((el, elIdx) => { + const tid = el.getAttribute('data-tid'); + if (tid === 'reply-message-header') { + const span = el.querySelector('span.fui-StyledText'); + currentSender = span ? span.textContent.trim() : ''; + senderIdx++; + } else if (tid === 'message-body' && currentSender) { + const text = el.innerText.trim(); + if (text) { + msgs.push({ + sender: currentSender, + text: text, + id: paneIdx + '_' + senderIdx + '_' + elIdx + '_' + text.substring(0, 20) + }); + } + } + }); + }); + return msgs; + } + + // Meeting chat: chat-pane-message with message-author-name inside + document.querySelectorAll("[data-tid='chat-pane-message']").forEach((msg, idx) => { + const senderEl = msg.querySelector("[data-tid='message-author-name']"); + const sender = senderEl ? senderEl.innerText.trim() : ''; + if (!sender) return; + const id = msg.getAttribute('id') || (idx + '_' + msg.innerText.substring(0, 40)); + msgs.push({ sender, text: msg.innerText.trim(), id }); + }); + return msgs; +}""" def parse_trigger(message: ChatMessage, current_user: str) -> tuple[str, str] | None: @@ -48,7 +90,6 @@ class Monitor: print("Chat erkannt.") def get_current_user_display_name(self) -> str: - # Retry up to 10s — Teams loads the display name asynchronously for _ in range(10): for selector, extractor in [ ("[data-tid='me-control-display-name']", @@ -74,24 +115,18 @@ class Monitor: return name def poll_new_messages(self) -> list[ChatMessage]: - elements = self._page.query_selector_all(_MSG_SELECTOR) + raw = self._page.evaluate(_POLL_JS) new_messages = [] - for el in elements: - msg_id = el.get_attribute("data-tid") or el.get_attribute("id") or el.inner_text()[:40] - if msg_id in self._seen_message_ids: + for item in raw: + if item["id"] in self._seen_message_ids: continue - self._seen_message_ids.add(msg_id) - - sender_el = el.query_selector(_SENDER_SELECTOR) - if not sender_el: - continue - sender = sender_el.inner_text().strip() - text = el.inner_text().strip() - new_messages.append(ChatMessage( - sender=sender, - text=text, - timestamp=datetime.now(), - )) + self._seen_message_ids.add(item["id"]) + if item["sender"]: + new_messages.append(ChatMessage( + sender=item["sender"], + text=item["text"], + timestamp=datetime.now(), + )) return new_messages def run(self) -> AuditWindow: @@ -103,7 +138,7 @@ class Monitor: start_time: datetime | None = None collected: list[ChatMessage] = [] - print("Monitoring aktiv. Poste '!start \"Name\"' im Chat um ein Zeitfenster zu starten.") + print("Monitoring aktiv. Poste '!start Name' im Chat um ein Zeitfenster zu starten.") while True: try: diff --git a/src/teampulse/resolver.py b/src/teampulse/resolver.py index 8b5803f..72eda89 100644 --- a/src/teampulse/resolver.py +++ b/src/teampulse/resolver.py @@ -4,7 +4,7 @@ from pathlib import Path from playwright.sync_api import Page -from teampulse.monitor import _PROFILE_EMAIL_SELECTOR, _SENDER_SELECTOR +from teampulse.monitor import _PROFILE_EMAIL_SELECTOR class Resolver: @@ -37,19 +37,23 @@ class Resolver: ) def _extract_email_from_profile(self, display_name: str) -> str | None: - sender_elements = self._page.query_selector_all(_SENDER_SELECTOR) - target = None - for el in sender_elements: - if el.inner_text().strip() == display_name: - target = el - break + # Click the first visible span whose text exactly matches the display name. + # Works for both channel meetings (fui-StyledText spans) and meeting chat. + clicked = self._page.evaluate("""(name) => { + for (const span of document.querySelectorAll('span')) { + if (span.textContent.trim() === name && span.offsetParent !== null) { + span.click(); + return true; + } + } + return false; + }""", display_name) - if target is None: + if not clicked: print(f" Sender '{display_name}' nicht im Chat gefunden.") return None try: - target.click() self._page.wait_for_selector(_PROFILE_EMAIL_SELECTOR, timeout=5000) email_el = self._page.query_selector(_PROFILE_EMAIL_SELECTOR) email = None