feat: support channel meetings with channel-pane-message selectors

This commit is contained in:
Oliver Hofmann 2026-05-17 13:04:20 +02:00
parent 3ff8381e5a
commit ccfe47aa35
4 changed files with 158 additions and 67 deletions

22
main.py
View File

@ -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()

View File

@ -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()

View File

@ -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:

View File

@ -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