feat: support channel meetings with channel-pane-message selectors
This commit is contained in:
parent
3ff8381e5a
commit
ccfe47aa35
22
main.py
22
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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user