teams-transcripts 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. legacy/__init__.py +0 -0
  2. legacy/transcript_download.py +3969 -0
  3. teams_transcripts/__init__.py +1 -0
  4. teams_transcripts/__main__.py +288 -0
  5. teams_transcripts/adapters/__init__.py +1 -0
  6. teams_transcripts/adapters/cdp/__init__.py +1 -0
  7. teams_transcripts/adapters/cdp/browser.py +196 -0
  8. teams_transcripts/adapters/cdp/connection.py +168 -0
  9. teams_transcripts/adapters/cdp/helpers.py +449 -0
  10. teams_transcripts/adapters/cdp/teams.py +463 -0
  11. teams_transcripts/adapters/vertex.py +274 -0
  12. teams_transcripts/domain/__init__.py +1 -0
  13. teams_transcripts/domain/chat.py +115 -0
  14. teams_transcripts/domain/html.py +126 -0
  15. teams_transcripts/domain/mcps.py +343 -0
  16. teams_transcripts/domain/models.py +531 -0
  17. teams_transcripts/domain/participants.py +292 -0
  18. teams_transcripts/domain/scoring.py +106 -0
  19. teams_transcripts/domain/speakers.py +130 -0
  20. teams_transcripts/domain/summary.py +266 -0
  21. teams_transcripts/domain/timestamps.py +350 -0
  22. teams_transcripts/domain/vtt.py +444 -0
  23. teams_transcripts/infrastructure/__init__.py +1 -0
  24. teams_transcripts/infrastructure/config.py +987 -0
  25. teams_transcripts/infrastructure/errors.py +120 -0
  26. teams_transcripts/infrastructure/logging.py +69 -0
  27. teams_transcripts/infrastructure/signals.py +143 -0
  28. teams_transcripts/mcp_server.py +818 -0
  29. teams_transcripts/ports/__init__.py +1 -0
  30. teams_transcripts/ports/agent.py +58 -0
  31. teams_transcripts/ports/browser.py +126 -0
  32. teams_transcripts/services/__init__.py +1 -0
  33. teams_transcripts/services/downloader.py +1283 -0
  34. teams_transcripts/services/extraction.py +1603 -0
  35. teams_transcripts/services/listing.py +826 -0
  36. teams_transcripts/services/navigation.py +1721 -0
  37. teams_transcripts/services/output.py +84 -0
  38. teams_transcripts/services/tenant.py +684 -0
  39. teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
  40. teams_transcripts-0.5.0.dist-info/RECORD +42 -0
  41. teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
  42. teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,1603 @@
1
+ """Transcript extraction services.
2
+
3
+ Functions that extract transcript data, roster members, and chat messages
4
+ from the Teams browser UI via the :class:`~teams_transcripts.ports.browser.BrowserPort`.
5
+
6
+ Each function takes a connected browser and manages its own connect/disconnect
7
+ lifecycle. Return values are strings or domain objects -- no file I/O is
8
+ performed here.
9
+
10
+ Extracted from ``transcript_download.py`` lines 2001--3240.
11
+
12
+ Schema version: 1.0.0
13
+ Created: 2026-04-25
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import typing
21
+ from collections import Counter
22
+ from datetime import datetime, timedelta
23
+
24
+ from teams_transcripts.domain.html import strip_html_to_text
25
+ from teams_transcripts.domain.models import ChatMessage, Reaction, RosterMember
26
+ from teams_transcripts.domain.speakers import resolve_chat_author
27
+ from teams_transcripts.domain.timestamps import (
28
+ parse_meeting_times,
29
+ sec_to_vtt_float,
30
+ sec_to_vtt_int,
31
+ ts_to_sec,
32
+ utc_to_local_iso,
33
+ )
34
+ from teams_transcripts.domain.vtt import generate_vtt_from_segments
35
+
36
+ if typing.TYPE_CHECKING:
37
+ from teams_transcripts.infrastructure.logging import Logger
38
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
39
+ from teams_transcripts.ports.browser import BrowserPort
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Step 7: Permission check
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ async def check_permission(
48
+ browser: BrowserPort,
49
+ target_id: str,
50
+ logger: Logger,
51
+ shutdown: ShutdownCoordinator,
52
+ ) -> str:
53
+ """Check the Download button state in the transcript iframe.
54
+
55
+ Args:
56
+ browser: Browser port (manages its own connection).
57
+ target_id: CDP target ID for the transcript iframe.
58
+ logger: Logger instance.
59
+ shutdown: Shutdown coordinator.
60
+
61
+ Returns:
62
+ ``"has_permission"``, ``"no_permission"``, or ``"no_button"``.
63
+ """
64
+ await browser.connect(target_id)
65
+ try:
66
+ max_attempts = 10
67
+ for attempt in range(max_attempts):
68
+ shutdown.check_shutdown()
69
+ result_json = await browser.evaluate("""
70
+ (function() {
71
+ var btn = document.querySelector('button[aria-haspopup="true"]');
72
+ if (!btn) return JSON.stringify({state: "no_button"});
73
+ var text = btn.textContent || "";
74
+ if (text.indexOf("permission") !== -1)
75
+ return JSON.stringify({
76
+ state: "no_permission",
77
+ message: text.trim().substring(0, 100)
78
+ });
79
+ return JSON.stringify({state: "has_permission"});
80
+ })()
81
+ """)
82
+ if result_json:
83
+ result = json.loads(str(result_json))
84
+ logger.log("step7", f"Permission state: {result['state']}")
85
+ if result.get("message"):
86
+ logger.log("step7", f"Message: {result['message']}")
87
+ if result["state"] == "no_button":
88
+ if attempt < max_attempts - 1:
89
+ logger.log(
90
+ "step7",
91
+ f"Attempt {attempt + 1}/{max_attempts} -- waiting for button...",
92
+ )
93
+ await shutdown.interruptible_sleep(2)
94
+ continue
95
+ return "no_button"
96
+ return result["state"]
97
+ logger.log("step7", f"Attempt {attempt + 1}/{max_attempts} -- waiting for button...")
98
+ await shutdown.interruptible_sleep(2)
99
+ finally:
100
+ await browser.disconnect()
101
+
102
+ return "no_button"
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Step 7A: Native VTT download (blob interception)
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ async def download_native_vtt(
111
+ browser: BrowserPort,
112
+ target_id: str,
113
+ logger: Logger,
114
+ shutdown: ShutdownCoordinator,
115
+ ) -> str | None:
116
+ """Download the transcript via blob interception (Approach A).
117
+
118
+ Monkey-patches ``URL.createObjectURL`` to capture VTT blob content,
119
+ then triggers the download via the UI menu.
120
+
121
+ Args:
122
+ browser: Browser port.
123
+ target_id: CDP target ID for the transcript iframe.
124
+ logger: Logger instance.
125
+ shutdown: Shutdown coordinator.
126
+
127
+ Returns:
128
+ VTT content as a string, or ``None`` if capture failed.
129
+ """
130
+ await browser.connect(target_id)
131
+ try:
132
+ # 1. Install blob interceptor
133
+ patch_result = await browser.evaluate("""
134
+ (function() {
135
+ window.__capturedVTT = null;
136
+ if (!window.__teamsTranscriptOrigCreateObjectURL) {
137
+ window.__teamsTranscriptOrigCreateObjectURL = URL.createObjectURL;
138
+ }
139
+ URL.createObjectURL = function(blob) {
140
+ if (blob instanceof Blob) {
141
+ blob.text().then(function(text) {
142
+ if (text.indexOf('WEBVTT') !== -1) {
143
+ window.__capturedVTT = text;
144
+ }
145
+ });
146
+ }
147
+ return window.__teamsTranscriptOrigCreateObjectURL.call(URL, blob);
148
+ };
149
+ return 'interceptor installed';
150
+ })()
151
+ """)
152
+ logger.log("step7a", f"Blob interceptor: {patch_result}")
153
+
154
+ # 2. Open the Download dropdown menu
155
+ menu_result_json = await browser.evaluate("""
156
+ (function() {
157
+ var btn = document.querySelector('button[aria-haspopup="true"]');
158
+ if (!btn) return JSON.stringify({error: 'Download button not found'});
159
+ if (btn.getAttribute('aria-expanded') !== 'true') btn.click();
160
+ return JSON.stringify({ok: true});
161
+ })()
162
+ """)
163
+ menu_result = json.loads(str(menu_result_json))
164
+ if menu_result.get("error"):
165
+ logger.log("step7a", f"Error: {menu_result['error']}")
166
+ return None
167
+ logger.log("step7a", "Download menu opened")
168
+
169
+ # 3. Wait for menu to render
170
+ await shutdown.interruptible_sleep(1)
171
+
172
+ # 4. Click "Download as .vtt"
173
+ click_result_json = await browser.evaluate("""
174
+ (function() {
175
+ var vttBtn = document.querySelector(
176
+ 'button[aria-label="Download as .vtt"]'
177
+ );
178
+ if (!vttBtn) {
179
+ var allBtns = Array.from(
180
+ document.querySelectorAll('button[role="menuitem"]')
181
+ );
182
+ vttBtn = allBtns.find(function(b) {
183
+ return b.textContent && b.textContent.indexOf('.vtt') !== -1;
184
+ });
185
+ }
186
+ if (!vttBtn) return JSON.stringify({error: 'VTT menu item not found'});
187
+ vttBtn.click();
188
+ return JSON.stringify({ok: true});
189
+ })()
190
+ """)
191
+ click_result = json.loads(str(click_result_json))
192
+ if click_result.get("error"):
193
+ logger.log("step7a", f"Error: {click_result['error']}")
194
+ return None
195
+ logger.log("step7a", "Clicked 'Download as .vtt'")
196
+
197
+ # 5. Wait for the blob to be captured
198
+ logger.log("step7a", "Waiting for VTT blob capture...")
199
+ await shutdown.interruptible_sleep(3)
200
+
201
+ # 6. Check if the VTT was captured
202
+ status_json = await browser.evaluate("""
203
+ (function() {
204
+ if (window.__capturedVTT) {
205
+ return JSON.stringify({
206
+ captured: true,
207
+ length: window.__capturedVTT.length
208
+ });
209
+ }
210
+ return JSON.stringify({captured: false});
211
+ })()
212
+ """)
213
+ status = json.loads(str(status_json))
214
+
215
+ if not status.get("captured"):
216
+ logger.log("step7a", "VTT blob was not captured.")
217
+ return None
218
+
219
+ length = status["length"]
220
+ logger.log("step7a", f"Captured {length} chars of VTT content")
221
+
222
+ # 7. Retrieve content in chunks (large transcripts may exceed
223
+ # the single-expression return limit)
224
+ chunks = []
225
+ chunk_size = 50000
226
+ for offset in range(0, length, chunk_size):
227
+ chunk = await browser.evaluate(
228
+ f"window.__capturedVTT.substring({offset}, {offset + chunk_size})"
229
+ )
230
+ chunks.append(chunk)
231
+
232
+ return "".join(chunks)
233
+ finally:
234
+ await browser.evaluate("""
235
+ (function() {
236
+ if (window.__teamsTranscriptOrigCreateObjectURL) {
237
+ URL.createObjectURL = window.__teamsTranscriptOrigCreateObjectURL;
238
+ delete window.__teamsTranscriptOrigCreateObjectURL;
239
+ }
240
+ return 'interceptor restored';
241
+ })()
242
+ """)
243
+ await browser.disconnect()
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Step 7B (React props): Approach A fallback
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ async def extract_via_react_props(
252
+ browser: BrowserPort,
253
+ target_id: str,
254
+ logger: Logger,
255
+ speaker_map: dict[str, str] | None = None,
256
+ ) -> str | None:
257
+ """Extract transcript data from the React fibre tree (Approach A variant).
258
+
259
+ The TranscriptList component holds all entries in its ``entries``
260
+ prop, bypassing the virtualised list renderer entirely.
261
+
262
+ Args:
263
+ browser: Browser port.
264
+ target_id: CDP target ID for the transcript iframe.
265
+ logger: Logger instance.
266
+ speaker_map: Optional GUID-to-display-name mapping for speaker
267
+ name resolution.
268
+
269
+ Returns:
270
+ VTT content as a string, ``None`` if the React props are not
271
+ accessible (caller should fall back to DOM scrolling).
272
+ """
273
+ await browser.connect(target_id)
274
+ try:
275
+ entries_json = await browser.evaluate(r"""
276
+ (function() {
277
+ var el = document.querySelector('[role="list"]');
278
+ if (!el) return JSON.stringify({error: 'no list element'});
279
+ var fiberKey = Object.keys(el).find(function(k) {
280
+ return k.startsWith('__reactFiber$');
281
+ });
282
+ if (!fiberKey) return JSON.stringify({error: 'no fiber key'});
283
+
284
+ var current = el[fiberKey];
285
+ var depth = 0;
286
+ var entries = null;
287
+ while (current && depth < 25) {
288
+ var props = current.memoizedProps || {};
289
+ if (props.entries && Array.isArray(props.entries)
290
+ && props.entries.length > 0) {
291
+ entries = props.entries;
292
+ break;
293
+ }
294
+ current = current.return;
295
+ depth++;
296
+ }
297
+ if (!entries)
298
+ return JSON.stringify({error: 'entries not found'});
299
+
300
+ function parseDur(d) {
301
+ if (!d) return null;
302
+ var m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
303
+ if (!m) return null;
304
+ return (parseInt(m[1]||0)*3600)
305
+ + (parseInt(m[2]||0)*60)
306
+ + parseFloat(m[3]||0);
307
+ }
308
+
309
+ var result = [];
310
+ for (var i = 0; i < entries.length; i++) {
311
+ var e = entries[i];
312
+ if (e.type !== 'Text') continue;
313
+ var ts = parseDur(e.timestamp);
314
+ result.push({
315
+ speaker: e.speakerDisplayName || 'Unknown',
316
+ speakerId: e.speakerId || '',
317
+ ts: ts,
318
+ text: e.text || ''
319
+ });
320
+ }
321
+ return JSON.stringify({ok: true, items: result});
322
+ })()
323
+ """)
324
+ finally:
325
+ await browser.disconnect()
326
+
327
+ data = json.loads(str(entries_json))
328
+
329
+ if data.get("error"):
330
+ logger.log("step7b", f"React props extraction failed: {data['error']}")
331
+ return None
332
+
333
+ items = data["items"]
334
+ if not items:
335
+ logger.log("step7b", "React props returned 0 text entries.")
336
+ return None
337
+
338
+ logger.log("step7b", f"React props: extracted {len(items)} text entries")
339
+
340
+ # Resolve speaker names using the Recap Speakers mapping.
341
+ if speaker_map:
342
+ resolved_pairs: dict[str, str] = {}
343
+ for item in items:
344
+ sid = item.get("speakerId", "")
345
+ guid = sid.split("@")[0] if "@" in sid else sid
346
+ if guid not in speaker_map:
347
+ continue
348
+ recap_name = speaker_map[guid]
349
+ transcript_name = item["speaker"]
350
+ if recap_name == transcript_name:
351
+ continue
352
+ item["speaker"] = recap_name
353
+ resolved_pairs[transcript_name] = recap_name
354
+ if resolved_pairs:
355
+ for old, new in resolved_pairs.items():
356
+ logger.log("step7b", f" Resolved speaker: {old!r} -> {new!r}")
357
+ total = sum(1 for it in items if it["speaker"] in resolved_pairs.values())
358
+ logger.log(
359
+ "step7b",
360
+ f"Resolved {len(resolved_pairs)} speaker name(s) ({total} entries affected)",
361
+ )
362
+
363
+ # Filter out meta entries
364
+ segments = []
365
+ for item in items:
366
+ text = item["text"]
367
+ if "started transcription" in text or "stopped transcription" in text:
368
+ continue
369
+ segments.append(item)
370
+
371
+ # Generate VTT using float timestamps (Approach A)
372
+ vtt = ["WEBVTT", ""]
373
+ for i, seg in enumerate(segments):
374
+ ss = seg["ts"] if seg["ts"] is not None else 0
375
+ es = segments[i + 1]["ts"] if i + 1 < len(segments) else ss + 10
376
+ if es is None or es <= ss:
377
+ es = ss + 10
378
+ vtt.extend(
379
+ [
380
+ str(i + 1),
381
+ f"{sec_to_vtt_float(ss)} --> {sec_to_vtt_float(es)}",
382
+ f"<v {seg['speaker']}>{seg['text']}</v>",
383
+ "",
384
+ ]
385
+ )
386
+
387
+ content = "\n".join(vtt)
388
+
389
+ logger.log("step7b", f"Generated {len(content)} bytes, {len(segments)} segments")
390
+ logger.log("step7b", "Speakers:")
391
+ for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
392
+ logger.log("step7b", f" {sp}: {cnt}")
393
+
394
+ return content
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Step 7B (DOM scrolling): Approach B
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ async def extract_dom_vtt(
403
+ browser: BrowserPort,
404
+ target_id: str,
405
+ logger: Logger,
406
+ ) -> str | None:
407
+ """Extract transcript from the DOM via scrolling (Approach B).
408
+
409
+ Scrolls through the virtualised transcript list, collecting all header
410
+ and sub-entry elements. Applies forward-fill, backfill, and
411
+ discontinuity detection before generating VTT.
412
+
413
+ Args:
414
+ browser: Browser port.
415
+ target_id: CDP target ID for the transcript iframe.
416
+ logger: Logger instance.
417
+
418
+ Returns:
419
+ VTT content as a string, or ``None`` on failure.
420
+ """
421
+ headers: dict[int, dict] = {}
422
+ texts: dict[int, dict] = {}
423
+ header_to_texts: dict[int, set[int]] = {}
424
+ orphaned_texts: list[int] = []
425
+
426
+ await browser.connect(target_id)
427
+ try:
428
+ # Scroll to top
429
+ await browser.evaluate("""
430
+ (function() {
431
+ var el = document.querySelector('[role="list"]');
432
+ var p = el;
433
+ while (p && p.scrollHeight <= p.clientHeight) p = p.parentElement;
434
+ if (p) p.scrollTop = 0;
435
+ })()
436
+ """)
437
+ await _async_sleep(0.5)
438
+
439
+ # Scroll through virtualised list, extracting entries
440
+ consecutive_empty = 0
441
+ for scroll_pass in range(200):
442
+ items_json = await browser.evaluate("""
443
+ (function() {
444
+ var items = Array.from(
445
+ document.querySelectorAll('[role="listitem"]')
446
+ );
447
+ var result = [];
448
+ var currentHeaderNum = null;
449
+ for (var i = 0; i < items.length; i++) {
450
+ var id = items[i].id || '';
451
+ var num = parseInt(id.replace(/[^0-9]/g, ''));
452
+ if (id.startsWith('itemHeader')) {
453
+ currentHeaderNum = num;
454
+ result.push({
455
+ type: 'h', n: num,
456
+ s: items[i].querySelector(
457
+ '[class*="itemDisplayName"]'
458
+ )?.textContent?.trim(),
459
+ t: items[i].querySelector(
460
+ '[class*="baseTimestamp"] span[aria-hidden]'
461
+ )?.textContent?.trim()
462
+ });
463
+ } else if (id.startsWith('sub-entry')) {
464
+ result.push({
465
+ type: 'e', n: num, hn: currentHeaderNum,
466
+ x: items[i].textContent?.trim()
467
+ });
468
+ }
469
+ }
470
+ if (items.length > 0) {
471
+ items[items.length - 1].scrollIntoView(
472
+ {behavior: 'instant', block: 'center'}
473
+ );
474
+ }
475
+ return JSON.stringify(result);
476
+ })()
477
+ """)
478
+ items = json.loads(str(items_json))
479
+
480
+ new_count = 0
481
+ for item in items:
482
+ if item["type"] == "h" and item["n"] not in headers:
483
+ headers[item["n"]] = {
484
+ "speaker": item["s"],
485
+ "timestamp": item["t"],
486
+ }
487
+ new_count += 1
488
+ elif item["type"] == "e" and item["n"] not in texts:
489
+ texts[item["n"]] = {
490
+ "text": item["x"],
491
+ "headerNum": item.get("hn"),
492
+ }
493
+ new_count += 1
494
+ hn = item.get("hn")
495
+ if hn is None:
496
+ orphaned_texts.append(item["n"])
497
+ else:
498
+ if hn not in header_to_texts:
499
+ header_to_texts[hn] = set()
500
+ header_to_texts[hn].add(item["n"])
501
+
502
+ if scroll_pass % 10 == 0:
503
+ logger.log(
504
+ "step7b",
505
+ f"Pass {scroll_pass}: +{new_count} | headers={len(headers)} texts={len(texts)}",
506
+ )
507
+
508
+ if new_count == 0:
509
+ consecutive_empty += 1
510
+ else:
511
+ consecutive_empty = 0
512
+
513
+ if consecutive_empty >= 3 and scroll_pass >= 3:
514
+ logger.log("step7b", f"Extraction complete at pass {scroll_pass}")
515
+ break
516
+
517
+ await _async_sleep(0.25)
518
+ finally:
519
+ await browser.disconnect()
520
+
521
+ # Sort text lists per header
522
+ sorted_header_texts: dict[int, list[int]] = {hn: sorted(s) for hn, s in header_to_texts.items()}
523
+
524
+ if orphaned_texts:
525
+ logger.log(
526
+ "step7b",
527
+ f"WARNING: {len(orphaned_texts)} sub-entries had no header "
528
+ f"in view: {orphaned_texts[:5]}...",
529
+ )
530
+
531
+ # Build ordered segments
532
+ segments: list[dict] = []
533
+ null_ts_count = 0
534
+ for hn in sorted(sorted_header_texts.keys()):
535
+ h = headers.get(hn, {})
536
+ ts_raw = h.get("timestamp")
537
+ ts_sec = ts_to_sec(ts_raw) if ts_raw else None
538
+ if ts_sec is None:
539
+ null_ts_count += 1
540
+ for tn in sorted_header_texts[hn]:
541
+ t = texts.get(tn, {})
542
+ text = t.get("text", "")
543
+ if "started transcription" in text or "stopped transcription" in text:
544
+ continue
545
+ segments.append(
546
+ {
547
+ "speaker": h.get("speaker", "Unknown"),
548
+ "ts_sec": ts_sec,
549
+ "text": text,
550
+ }
551
+ )
552
+
553
+ if null_ts_count > 0:
554
+ logger.log(
555
+ "step7b",
556
+ f"WARNING: {null_ts_count} headers had null/empty timestamps.",
557
+ )
558
+
559
+ # Forward-fill null timestamps
560
+ last_good = None
561
+ for seg in segments:
562
+ if seg["ts_sec"] is not None:
563
+ last_good = seg["ts_sec"]
564
+ elif last_good is not None:
565
+ seg["ts_sec"] = last_good + 10
566
+ last_good = seg["ts_sec"]
567
+
568
+ # Backfill remaining nulls
569
+ next_good = None
570
+ for seg in reversed(segments):
571
+ if seg["ts_sec"] is not None:
572
+ next_good = seg["ts_sec"]
573
+ elif next_good is not None:
574
+ seg["ts_sec"] = max(0, next_good - 10)
575
+ next_good = seg["ts_sec"]
576
+
577
+ # Final fallback
578
+ for seg in segments:
579
+ if seg["ts_sec"] is None:
580
+ seg["ts_sec"] = 0
581
+
582
+ # Detect timestamp discontinuities
583
+ if len(segments) > 2:
584
+ running_max = 0
585
+ cut_at = None
586
+ for i, seg in enumerate(segments):
587
+ ts = seg["ts_sec"]
588
+ if i > 0 and ts is not None and running_max > 0:
589
+ gap = ts - running_max
590
+ if gap > 600 and ts > running_max * 2:
591
+ logger.log(
592
+ "step7b",
593
+ f"WARNING: Timestamp discontinuity at segment {i}: "
594
+ f"{sec_to_vtt_int(running_max)} -> {sec_to_vtt_int(ts)} "
595
+ f"(gap {gap:.0f}s). Truncating "
596
+ f"{len(segments) - i} segment(s).",
597
+ )
598
+ cut_at = i
599
+ break
600
+ if ts is not None and ts > running_max:
601
+ running_max = ts
602
+ if cut_at is not None:
603
+ segments = segments[:cut_at]
604
+
605
+ # Generate VTT using integer timestamps (Approach B)
606
+ content = generate_vtt_from_segments(segments)
607
+
608
+ logger.log("step7b", f"Generated {len(content)} bytes, {len(segments)} segments")
609
+ logger.log("step7b", "Speakers:")
610
+ for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
611
+ logger.log("step7b", f" {sp}: {cnt}")
612
+
613
+ return content
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # Multi-part support
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ async def detect_part_tabs(
622
+ browser: BrowserPort,
623
+ target_id: str,
624
+ ) -> int:
625
+ """Detect Part N tabs in the Recap view.
626
+
627
+ Args:
628
+ browser: Browser port.
629
+ target_id: CDP target ID for the main Teams target.
630
+
631
+ Returns:
632
+ Number of parts (1 if no Part tabs are present).
633
+ """
634
+ await browser.connect(target_id)
635
+ try:
636
+ count_json = await browser.evaluate(r"""
637
+ (function() {
638
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
639
+ var parts = tabs.filter(function(t) {
640
+ return /^Part\s*\d/i.test(t.textContent.trim());
641
+ });
642
+ return JSON.stringify({count: parts.length});
643
+ })()
644
+ """)
645
+ finally:
646
+ await browser.disconnect()
647
+ data = json.loads(str(count_json))
648
+ return max(1, data.get("count", 0))
649
+
650
+
651
+ async def click_part_and_transcript(
652
+ browser: BrowserPort,
653
+ target_id: str,
654
+ part_number: int,
655
+ logger: Logger,
656
+ shutdown: ShutdownCoordinator,
657
+ ) -> bool:
658
+ """Navigate to Part N's Transcript via a full Recap re-entry.
659
+
660
+ Teams only creates the xplatplugins iframe when the Transcript tab
661
+ is first activated within a Recap session. Switching parts via tab
662
+ clicks (Part 2 → Transcript) does NOT create a new iframe -- the
663
+ old one is destroyed but no replacement appears.
664
+
665
+ To force iframe creation for Part 2+, this function:
666
+
667
+ 1. Exits Recap by clicking the "Chat" tab.
668
+ 2. Re-enters Recap by clicking the "Recap" tab.
669
+ 3. Waits for Recap content to load.
670
+ 4. Clicks the Part N tab.
671
+ 5. Clicks the Transcript sub-tab.
672
+
673
+ This triggers a fresh Recap session and reliable iframe creation.
674
+
675
+ Args:
676
+ browser: Browser port.
677
+ target_id: CDP target ID for the main Teams target.
678
+ part_number: 1-based part number.
679
+ logger: Logger instance.
680
+ shutdown: Shutdown coordinator.
681
+
682
+ Returns:
683
+ ``True`` if navigation succeeded.
684
+ """
685
+ await browser.connect(target_id)
686
+ try:
687
+ # Step 1: Exit Recap by clicking the Chat tab.
688
+ chat_result = await browser.evaluate("""
689
+ (function() {
690
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
691
+ var chatTab = tabs.find(function(t) {
692
+ return t.textContent.trim() === 'Chat';
693
+ });
694
+ if (!chatTab) return 'no_chat_tab';
695
+ chatTab.click();
696
+ return 'clicked_chat';
697
+ })()
698
+ """)
699
+ logger.log("multi-part", f"Exit Recap (Chat tab): {chat_result}")
700
+
701
+ await shutdown.interruptible_sleep(2)
702
+
703
+ # Step 2: Re-enter Recap.
704
+ recap_result = await browser.evaluate("""
705
+ (function() {
706
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
707
+ var recapTab = tabs.find(function(t) {
708
+ return t.textContent.indexOf('Recap') !== -1;
709
+ });
710
+ if (!recapTab) return 'no_recap_tab';
711
+ recapTab.click();
712
+ return 'clicked_recap';
713
+ })()
714
+ """)
715
+ logger.log("multi-part", f"Re-enter Recap: {recap_result}")
716
+
717
+ if recap_result == "no_recap_tab":
718
+ return False
719
+
720
+ await shutdown.interruptible_sleep(3)
721
+
722
+ # Step 3: Click Part N tab.
723
+ part_result = await browser.evaluate(rf"""
724
+ (function() {{
725
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
726
+ var partTab = tabs.find(function(t) {{
727
+ return /^Part\s*{part_number}($|Part)/i.test(
728
+ t.textContent.trim()
729
+ );
730
+ }});
731
+ if (!partTab) return 'no_part_tab';
732
+ partTab.click();
733
+ return 'clicked_part';
734
+ }})()
735
+ """)
736
+ logger.log("multi-part", f"Part {part_number} tab: {part_result}")
737
+
738
+ if part_result == "no_part_tab":
739
+ return False
740
+
741
+ await shutdown.interruptible_sleep(2)
742
+
743
+ # Step 4: Click Transcript sub-tab.
744
+ transcript_result = await browser.evaluate("""
745
+ (function() {
746
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
747
+ var transcriptTab = tabs.find(function(t) {
748
+ return t.textContent.indexOf('Transcript') !== -1;
749
+ });
750
+ if (!transcriptTab) return 'no_transcript_tab';
751
+ transcriptTab.click();
752
+ return 'clicked_transcript';
753
+ })()
754
+ """)
755
+ logger.log(
756
+ "multi-part",
757
+ f"Transcript tab for Part {part_number}: {transcript_result}",
758
+ )
759
+
760
+ if transcript_result == "no_transcript_tab":
761
+ return False
762
+
763
+ await shutdown.interruptible_sleep(3)
764
+ finally:
765
+ await browser.disconnect()
766
+
767
+ return True
768
+
769
+
770
+ # ---------------------------------------------------------------------------
771
+ # Roster extraction
772
+ # ---------------------------------------------------------------------------
773
+
774
+
775
+ async def retry_click_transcript(
776
+ browser: BrowserPort,
777
+ target_id: str,
778
+ logger: Logger,
779
+ shutdown: ShutdownCoordinator,
780
+ ) -> bool:
781
+ """Click Speakers then Transcript to force-reload the iframe.
782
+
783
+ Used as a retry mechanism when :func:`find_iframe_target` fails to
784
+ find an xplatplugins iframe for a subsequent part. Cycling through
785
+ the Speakers tab ensures the Transcript click is a genuine state
786
+ transition even if Transcript was already selected.
787
+
788
+ Args:
789
+ browser: Browser port.
790
+ target_id: CDP target ID for the main Teams target.
791
+ logger: Logger instance.
792
+ shutdown: Shutdown coordinator.
793
+
794
+ Returns:
795
+ ``True`` if the Transcript tab was found and clicked.
796
+ """
797
+ await browser.connect(target_id)
798
+ try:
799
+ await browser.evaluate("""
800
+ (function() {
801
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
802
+ var speakersTab = tabs.find(function(t) {
803
+ return /speaker/i.test(t.textContent);
804
+ });
805
+ if (speakersTab) speakersTab.click();
806
+ })()
807
+ """)
808
+ await shutdown.interruptible_sleep(2)
809
+
810
+ result = await browser.evaluate("""
811
+ (function() {
812
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
813
+ var transcriptTab = tabs.find(function(t) {
814
+ return t.textContent.indexOf('Transcript') !== -1;
815
+ });
816
+ if (!transcriptTab) return 'no_transcript_tab';
817
+ transcriptTab.click();
818
+ return 'clicked_transcript';
819
+ })()
820
+ """)
821
+ logger.log("multi-part", f"Transcript tab retry: {result}")
822
+ if result == "no_transcript_tab":
823
+ return False
824
+
825
+ await shutdown.interruptible_sleep(3)
826
+ finally:
827
+ await browser.disconnect()
828
+
829
+ return True
830
+
831
+
832
+ async def extract_roster_members(
833
+ browser: BrowserPort,
834
+ target_id: str,
835
+ logger: Logger,
836
+ shutdown: ShutdownCoordinator,
837
+ speaker_map: dict[str, str] | None = None,
838
+ ) -> tuple[list[RosterMember], str]:
839
+ """Extract the full meeting chat member list via the participant popover.
840
+
841
+ Primary approach:
842
+ 1. Click ``[data-tid="chat-header-participant-count"]`` to open
843
+ the participant popover.
844
+ 2. Read ``props.members`` from the ``chat-header-participant-list``
845
+ element's React fibre tree (level 7). This contains ALL
846
+ members with ``displayName``, ``email``, ``objectId``, etc.
847
+ 3. Close the popover with Escape.
848
+
849
+ Fallback (when the popover button is missing or the fibre read fails):
850
+ Read ``chat.membersLimited`` + ``chat.me`` from the
851
+ ``chat-topic-menu`` fibre tree. This returns only ~3-4 members
852
+ for large meetings.
853
+
854
+ In both cases, the ``organizerId`` is read from
855
+ ``chat.meetingInformation`` on the ``chat-topic-menu`` fibre tree.
856
+
857
+ Args:
858
+ browser: Browser port.
859
+ target_id: CDP target ID for the main Teams target.
860
+ logger: Logger instance.
861
+ shutdown: Shutdown coordinator.
862
+ speaker_map: Optional GUID-to-name mapping for name resolution.
863
+
864
+ Returns:
865
+ Tuple of ``(members, organizer_id)``:
866
+ - ``members``: list of :class:`RosterMember` instances.
867
+ - ``organizer_id``: bare GUID of the meeting organiser,
868
+ or empty string when unavailable.
869
+ """
870
+ await browser.connect(target_id)
871
+ try:
872
+ # --- Step 1: Read organizerId from chat-topic-menu fibre tree ---
873
+ organizer_id = await _read_organizer_id(browser, logger)
874
+
875
+ # --- Step 2: Try participant popover approach ---
876
+ raw_members = await _try_popover_extraction(browser, logger, shutdown)
877
+
878
+ # --- Step 3: Fallback to membersLimited if popover failed ---
879
+ if raw_members is None:
880
+ logger.log("roster", "Popover unavailable; falling back to membersLimited")
881
+ raw_members = await _read_members_limited(browser, logger)
882
+ finally:
883
+ await browser.disconnect()
884
+
885
+ if not raw_members:
886
+ logger.log("roster", "No members found in roster.")
887
+ return [], organizer_id
888
+
889
+ # Build RosterMember list with name cleaning
890
+ members = _build_roster_members(raw_members, organizer_id, speaker_map)
891
+ logger.log("roster", f"Extracted {len(members)} member(s)")
892
+ return members, organizer_id
893
+
894
+
895
+ async def _read_organizer_id(browser: BrowserPort, logger: Logger) -> str:
896
+ """Read ``meetingInformation.organizerId`` from the chat-topic-menu fibre."""
897
+ raw = await browser.evaluate("""
898
+ (function() {
899
+ var menu = document.querySelector('[data-tid="chat-topic-menu"]');
900
+ if (!menu) return JSON.stringify({error: 'no_menu'});
901
+
902
+ var fiberKey = Object.keys(menu).find(function(k) {
903
+ return k.startsWith('__reactFiber$');
904
+ });
905
+ if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
906
+
907
+ var current = menu[fiberKey];
908
+ for (var i = 0; i < 25 && current; i++) {
909
+ var props = current.memoizedProps;
910
+ if (props && props.chat && props.chat.meetingInformation) {
911
+ var mi = props.chat.meetingInformation;
912
+ return JSON.stringify({
913
+ organizerId: mi.organizerId || ''
914
+ });
915
+ }
916
+ current = current.return;
917
+ }
918
+ return JSON.stringify({error: 'no_meeting_info'});
919
+ })()
920
+ """)
921
+ if not raw:
922
+ return ""
923
+ data = json.loads(str(raw))
924
+ if data.get("error"):
925
+ logger.log("roster", f"Could not read organizer: {data['error']}")
926
+ return ""
927
+ return data.get("organizerId", "")
928
+
929
+
930
+ async def _try_popover_extraction(
931
+ browser: BrowserPort,
932
+ logger: Logger,
933
+ shutdown: ShutdownCoordinator,
934
+ ) -> list[dict] | None:
935
+ """Try to extract all members via the participant popover.
936
+
937
+ Returns a list of member dicts on success, or ``None`` if the popover
938
+ could not be opened or the fibre tree could not be read.
939
+ """
940
+ # Click the participant count button to open the popover
941
+ click_result = await browser.evaluate("""
942
+ (function() {
943
+ var btn = document.querySelector(
944
+ '[data-tid="chat-header-participant-count"]'
945
+ );
946
+ if (!btn) return JSON.stringify({error: 'no_button'});
947
+ btn.click();
948
+ return JSON.stringify({clicked: true});
949
+ })()
950
+ """)
951
+ if not click_result:
952
+ return None
953
+ click_data = json.loads(str(click_result))
954
+ if click_data.get("error"):
955
+ logger.log("roster", f"Popover button: {click_data['error']}")
956
+ return None
957
+
958
+ logger.log("roster", "Opened participant popover")
959
+ await shutdown.interruptible_sleep(1)
960
+
961
+ # Read props.members from chat-header-participant-list fibre tree
962
+ raw = await browser.evaluate(r"""
963
+ (function() {
964
+ var el = document.querySelector(
965
+ '[data-tid="chat-header-participant-list"]'
966
+ );
967
+ if (!el) return JSON.stringify({error: 'no_list_element'});
968
+
969
+ var fiberKey = Object.keys(el).find(function(k) {
970
+ return k.startsWith('__reactFiber$');
971
+ });
972
+ if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
973
+
974
+ var current = el[fiberKey];
975
+ for (var depth = 0; depth < 15 && current; depth++) {
976
+ var props = current.memoizedProps || {};
977
+ if (props.members && Array.isArray(props.members)
978
+ && props.members.length > 0
979
+ && props.members[0].objectId) {
980
+ var result = [];
981
+ for (var i = 0; i < props.members.length; i++) {
982
+ var m = props.members[i];
983
+ result.push({
984
+ displayName: m.displayName || '',
985
+ mri: m.mri || '',
986
+ objectId: m.objectId || '',
987
+ email: m.email || ''
988
+ });
989
+ }
990
+ return JSON.stringify({
991
+ members: result,
992
+ count: result.length,
993
+ level: depth
994
+ });
995
+ }
996
+ current = current.return;
997
+ }
998
+ return JSON.stringify({error: 'no_members_in_fiber'});
999
+ })()
1000
+ """)
1001
+
1002
+ # Close the popover (Escape key)
1003
+ await browser.send_command(
1004
+ "Input.dispatchKeyEvent",
1005
+ {"type": "keyDown", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27},
1006
+ )
1007
+ await browser.send_command(
1008
+ "Input.dispatchKeyEvent",
1009
+ {"type": "keyUp", "key": "Escape", "code": "Escape", "windowsVirtualKeyCode": 27},
1010
+ )
1011
+
1012
+ if not raw:
1013
+ logger.log("roster", "Popover fibre read returned nothing")
1014
+ return None
1015
+
1016
+ data = json.loads(str(raw))
1017
+ if data.get("error"):
1018
+ logger.log("roster", f"Popover fibre: {data['error']}")
1019
+ return None
1020
+
1021
+ members = data.get("members", [])
1022
+ logger.log(
1023
+ "roster",
1024
+ f"Popover extraction: {len(members)} members (fibre level {data.get('level', '?')})",
1025
+ )
1026
+ return members
1027
+
1028
+
1029
+ async def _read_members_limited(
1030
+ browser: BrowserPort,
1031
+ logger: Logger,
1032
+ ) -> list[dict]:
1033
+ """Fallback: read membersLimited + me from chat-topic-menu fibre tree."""
1034
+ raw = await browser.evaluate("""
1035
+ (function() {
1036
+ var menu = document.querySelector('[data-tid="chat-topic-menu"]');
1037
+ if (!menu) return JSON.stringify({error: 'no_menu'});
1038
+
1039
+ var fiberKey = Object.keys(menu).find(function(k) {
1040
+ return k.startsWith('__reactFiber$');
1041
+ });
1042
+ if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
1043
+
1044
+ var current = menu[fiberKey];
1045
+ for (var i = 0; i < 25 && current; i++) {
1046
+ var props = current.memoizedProps;
1047
+ if (props && props.chat && props.chat.membersLimited) {
1048
+ var chat = props.chat;
1049
+ var members = [];
1050
+
1051
+ var limited = chat.membersLimited || [];
1052
+ for (var j = 0; j < limited.length; j++) {
1053
+ var m = limited[j];
1054
+ members.push({
1055
+ displayName: m.displayName || '',
1056
+ mri: m.mri || '',
1057
+ objectId: m.objectId || '',
1058
+ email: m.email || ''
1059
+ });
1060
+ }
1061
+
1062
+ if (chat.me) {
1063
+ members.push({
1064
+ displayName: chat.me.displayName || '',
1065
+ mri: chat.me.mri || '',
1066
+ objectId: chat.me.objectId || '',
1067
+ email: chat.me.email || ''
1068
+ });
1069
+ }
1070
+
1071
+ return JSON.stringify({members: members});
1072
+ }
1073
+ current = current.return;
1074
+ }
1075
+ return JSON.stringify({error: 'no_chat_props'});
1076
+ })()
1077
+ """)
1078
+ if not raw:
1079
+ return []
1080
+ data = json.loads(str(raw))
1081
+ if data.get("error"):
1082
+ logger.log("roster", f"membersLimited fallback: {data['error']}")
1083
+ return []
1084
+ return data.get("members", [])
1085
+
1086
+
1087
+ def _build_roster_members(
1088
+ raw_members: list[dict],
1089
+ organizer_id: str,
1090
+ speaker_map: dict[str, str] | None,
1091
+ ) -> list[RosterMember]:
1092
+ """Build :class:`RosterMember` list from raw member dicts.
1093
+
1094
+ Applies name cleaning (``v-`` prefix, ``(...)`` suffix removal)
1095
+ and speaker map resolution.
1096
+ """
1097
+ members: list[RosterMember] = []
1098
+ for m in raw_members:
1099
+ name = m.get("displayName") or "Unknown"
1100
+ guid = _normalise_object_id(m.get("objectId", ""))
1101
+ organiser_guid = _normalise_object_id(organizer_id)
1102
+
1103
+ if speaker_map and guid and guid in speaker_map:
1104
+ name = speaker_map[guid]
1105
+ else:
1106
+ cleaned = re.sub(r"^v-", "", name)
1107
+ cleaned = re.sub(r"\s*\([^)]+\)\s*$", "", cleaned)
1108
+ if cleaned:
1109
+ name = cleaned
1110
+
1111
+ is_organiser = bool(organiser_guid and guid == organiser_guid)
1112
+ role = "organiser" if is_organiser else "member"
1113
+ members.append(
1114
+ RosterMember(
1115
+ name=name,
1116
+ email=m.get("email", ""),
1117
+ object_id=guid,
1118
+ role=role,
1119
+ )
1120
+ )
1121
+ return members
1122
+
1123
+
1124
+ def _normalise_object_id(value: str) -> str:
1125
+ """Return a bare object GUID from a Teams MRI-like identifier."""
1126
+ if not value:
1127
+ return ""
1128
+ return value.rsplit(":", 1)[-1]
1129
+
1130
+
1131
+ # ---------------------------------------------------------------------------
1132
+ # Chat message extraction
1133
+ # ---------------------------------------------------------------------------
1134
+
1135
+
1136
+ async def extract_chat_messages(
1137
+ browser: BrowserPort,
1138
+ target_id: str,
1139
+ target_date: str,
1140
+ logger: Logger,
1141
+ shutdown: ShutdownCoordinator,
1142
+ *,
1143
+ meeting_time_text: str = "",
1144
+ member_map: dict[str, str] | None = None,
1145
+ speaker_map: dict[str, str] | None = None,
1146
+ ) -> tuple[list[ChatMessage], list[str]]:
1147
+ """Extract meeting chat messages and Event/Call content from the Chat tab.
1148
+
1149
+ Navigates to the Chat tab, walks the React fibre tree, and extracts
1150
+ user messages within the meeting time window. Also collects the raw
1151
+ ``content`` strings from Event/Call messages (which contain
1152
+ ``<partlist>`` attendance data for use by the caller).
1153
+
1154
+ Args:
1155
+ browser: Browser port.
1156
+ target_id: CDP target ID for the main Teams target.
1157
+ target_date: Target date as ``YYYY-MM-DD``.
1158
+ logger: Logger instance.
1159
+ shutdown: Shutdown coordinator.
1160
+ meeting_time_text: Recap header meeting time text (optional).
1161
+ member_map: Combined GUID-to-name mapping (roster + speakers).
1162
+ speaker_map: Recap GUID-to-name mapping (fallback).
1163
+
1164
+ Returns:
1165
+ Tuple of ``(chat_messages, event_call_contents)``:
1166
+ - ``chat_messages``: sorted list of :class:`ChatMessage`.
1167
+ - ``event_call_contents``: raw content strings from Event/Call
1168
+ messages matching the target date (for partlist parsing).
1169
+ """
1170
+ await browser.connect(target_id)
1171
+ try:
1172
+ # Click the Chat tab
1173
+ chat_tab_result = await browser.evaluate(r"""
1174
+ (function() {
1175
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1176
+ var chatTab = tabs.find(function(t) {
1177
+ return t.getAttribute('data-tid') ===
1178
+ 'tab-item-com.microsoft.chattabs.chat';
1179
+ });
1180
+ if (!chatTab) return 'no_tab';
1181
+ chatTab.click();
1182
+ return 'clicked';
1183
+ })()
1184
+ """)
1185
+
1186
+ if chat_tab_result != "clicked":
1187
+ logger.log("chat", "Chat tab not found -- skipping chat extraction.")
1188
+ return [], []
1189
+
1190
+ await shutdown.interruptible_sleep(2)
1191
+
1192
+ # Extract all messages from the React fibre tree
1193
+ raw = await browser.evaluate(r"""
1194
+ (function() {
1195
+ var els = document.querySelectorAll('[data-tid="chat-pane-item"]');
1196
+ if (!els.length) return JSON.stringify({error: 'no_items'});
1197
+
1198
+ var el = els[0];
1199
+ var fk = Object.keys(el).find(function(k) {
1200
+ return k.startsWith('__reactFiber$');
1201
+ });
1202
+ if (!fk) return JSON.stringify({error: 'no_fiber'});
1203
+
1204
+ var fiber = el[fk];
1205
+ var c = fiber;
1206
+ var itemsArr = null;
1207
+ var loadPrev = null;
1208
+ var loadNext = null;
1209
+ for (var d = 0; d < 30 && c; d++) {
1210
+ var p = c.memoizedProps || {};
1211
+ if (p.items && Array.isArray(p.items) && p.items.length > 0
1212
+ && p.items[0] && p.items[0].originalArrivalTime) {
1213
+ itemsArr = p.items;
1214
+ loadPrev = p.loadPrev;
1215
+ loadNext = p.loadNext;
1216
+ break;
1217
+ }
1218
+ c = c.return;
1219
+ }
1220
+
1221
+ if (!itemsArr) return JSON.stringify({error: 'no_items_array'});
1222
+
1223
+ var messages = [];
1224
+ for (var i = 0; i < itemsArr.length; i++) {
1225
+ var m = itemsArr[i];
1226
+ messages.push({
1227
+ id: m.id,
1228
+ time: m.originalArrivalTime,
1229
+ itemType: m.itemType,
1230
+ messageType: m.messageType,
1231
+ fromUserId: m.fromUserId || '',
1232
+ displayName: m.imDisplayName || '',
1233
+ content: m.content || '',
1234
+ emotions: m.emotions,
1235
+ emotionsSummary: m.emotionsSummary,
1236
+ files: m.files ? m.files.length : 0,
1237
+ mentions: m.mentions
1238
+ });
1239
+ }
1240
+
1241
+ return JSON.stringify({
1242
+ total: itemsArr.length,
1243
+ hasPagination: !!(loadPrev || loadNext),
1244
+ messages: messages
1245
+ });
1246
+ })()
1247
+ """)
1248
+
1249
+ if not raw:
1250
+ logger.log("chat", "Failed to extract chat data from React fibre tree.")
1251
+ return [], []
1252
+
1253
+ data = json.loads(str(raw))
1254
+
1255
+ if "error" in data:
1256
+ logger.log("chat", f"Chat extraction error: {data['error']}")
1257
+ return [], []
1258
+
1259
+ if data.get("hasPagination"):
1260
+ logger.log(
1261
+ "chat",
1262
+ f"WARNING: Chat has pagination ({data['total']} items loaded). "
1263
+ "Some messages may be missing.",
1264
+ )
1265
+
1266
+ logger.log("chat", f"Total items in chat: {data['total']}")
1267
+
1268
+ finally:
1269
+ await browser.disconnect()
1270
+
1271
+ # Determine meeting time window from Event/Call control messages
1272
+ candidate_dates = [target_date]
1273
+ if meeting_time_text:
1274
+ recap_start, _ = parse_meeting_times(meeting_time_text, target_date)
1275
+ recap_date = recap_start[:10]
1276
+ if recap_date != target_date:
1277
+ candidate_dates.insert(0, recap_date)
1278
+
1279
+ event_calls = []
1280
+ matched_date = None
1281
+ for cdate in candidate_dates:
1282
+ event_calls = [
1283
+ m
1284
+ for m in data["messages"]
1285
+ if m.get("messageType") == "Event/Call" and m.get("time", "").startswith(cdate)
1286
+ ]
1287
+ if event_calls:
1288
+ matched_date = cdate
1289
+ break
1290
+
1291
+ if event_calls:
1292
+ event_calls.sort(key=lambda m: m["time"])
1293
+ start_iso = event_calls[0]["time"]
1294
+ end_iso = event_calls[-1]["time"]
1295
+ if start_iso == end_iso:
1296
+ dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
1297
+ start_iso = (dt - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
1298
+ end_iso = (dt + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
1299
+ logger.log(
1300
+ "chat",
1301
+ f"Time window (from Event/Call on {matched_date}): {start_iso} to {end_iso}",
1302
+ )
1303
+ else:
1304
+ fallback_date = candidate_dates[0]
1305
+ logger.log(
1306
+ "chat",
1307
+ f"No Event/Call markers found; falling back to {fallback_date}",
1308
+ )
1309
+ start_iso = f"{fallback_date}T00:00:00Z"
1310
+ end_iso = f"{fallback_date}T23:59:59Z"
1311
+ logger.log(
1312
+ "chat",
1313
+ f"Time window (full day fallback): {start_iso} to {end_iso}",
1314
+ )
1315
+
1316
+ # Prefer member_map for resolving authors; fall back to speaker_map
1317
+ name_map = member_map or speaker_map
1318
+
1319
+ results: list[ChatMessage] = []
1320
+ for msg in data["messages"]:
1321
+ if msg["itemType"] != 3:
1322
+ continue
1323
+ if msg["messageType"] != "RichText/Html":
1324
+ continue
1325
+ if msg["files"] and msg["files"] > 0:
1326
+ text = strip_html_to_text(msg["content"])
1327
+ if not text:
1328
+ continue
1329
+
1330
+ msg_time = msg["time"]
1331
+ if not msg_time:
1332
+ continue
1333
+ if msg_time < start_iso or msg_time > end_iso:
1334
+ continue
1335
+
1336
+ text = strip_html_to_text(msg["content"])
1337
+ if not text:
1338
+ continue
1339
+
1340
+ author = resolve_chat_author(msg["fromUserId"], msg["displayName"], name_map)
1341
+
1342
+ # Parse reactions
1343
+ reactions: list[Reaction] | None = None
1344
+ emotions_list = msg.get("emotions") or []
1345
+ if emotions_list:
1346
+ reactions = []
1347
+ for emo in emotions_list:
1348
+ reaction_type = emo.get("key", "")
1349
+ users: list[str] = []
1350
+ for u in emo.get("users") or []:
1351
+ uid = u.get("userId", "")
1352
+ display = u.get("displayName", "")
1353
+ name = resolve_chat_author(uid, display, name_map)
1354
+ users.append(name)
1355
+ reactions.append(Reaction(type=reaction_type, users=users))
1356
+ elif msg.get("emotionsSummary"):
1357
+ reactions = []
1358
+ for r in msg["emotionsSummary"]:
1359
+ reactions.append(Reaction(type=r.get("key", ""), users=[]))
1360
+
1361
+ results.append(
1362
+ ChatMessage(
1363
+ timestamp=utc_to_local_iso(msg_time),
1364
+ author=author,
1365
+ text=text,
1366
+ reactions=reactions,
1367
+ )
1368
+ )
1369
+
1370
+ results.sort(key=lambda m: m.timestamp)
1371
+
1372
+ logger.log("chat", f"Extracted {len(results)} chat message(s) in meeting window")
1373
+
1374
+ # Collect Event/Call content strings for attendance extraction.
1375
+ # These are the raw `content` fields from Event/Call messages matching
1376
+ # the target date. The caller can parse <partlist> XML from them.
1377
+ event_call_contents: list[str] = [
1378
+ m["content"]
1379
+ for m in data["messages"]
1380
+ if m.get("messageType") == "Event/Call"
1381
+ and m.get("time", "").startswith(matched_date or target_date)
1382
+ and m.get("content")
1383
+ ]
1384
+ if event_call_contents:
1385
+ logger.log("chat", f"Collected {len(event_call_contents)} Event/Call content string(s)")
1386
+
1387
+ return results, event_call_contents
1388
+
1389
+
1390
+ # ---------------------------------------------------------------------------
1391
+ # Step 9b: Tracking/RSVP extraction from Details tab
1392
+ # ---------------------------------------------------------------------------
1393
+
1394
+
1395
+ async def extract_tracking_data(
1396
+ browser: BrowserPort,
1397
+ target_id: str,
1398
+ logger: Logger,
1399
+ shutdown: ShutdownCoordinator,
1400
+ ) -> list[str]:
1401
+ """Extract RSVP tracking data from the meeting Details tab.
1402
+
1403
+ Navigates to the Details tab, finds the Tracking panel, clicks
1404
+ "Show more attendees" to load all entries, and collects the
1405
+ ``aria-label`` strings from each tracking list item.
1406
+
1407
+ This is a **soft-failure** function: if any step fails (no Details
1408
+ tab, no Tracking panel, etc.), it logs the reason and returns an
1409
+ empty list. The pipeline continues without RSVP data.
1410
+
1411
+ Args:
1412
+ browser: Browser port.
1413
+ target_id: CDP target ID for the main Teams target.
1414
+ logger: Logger instance.
1415
+ shutdown: Shutdown coordinator.
1416
+
1417
+ Returns:
1418
+ List of raw aria-label strings from the Tracking panel. Each
1419
+ label has the format:
1420
+ ``"Name Status, N of M, in a list of M items, Dialogue Pop-up Button"``
1421
+ """
1422
+ await browser.connect(target_id)
1423
+ try:
1424
+ return await _do_extract_tracking(browser, logger, shutdown)
1425
+ except Exception as exc: # soft failure by design
1426
+ logger.log("tracking", f"Tracking extraction failed: {exc}")
1427
+ return []
1428
+ finally:
1429
+ await browser.disconnect()
1430
+
1431
+
1432
+ async def _do_extract_tracking(
1433
+ browser: BrowserPort,
1434
+ logger: Logger,
1435
+ shutdown: ShutdownCoordinator,
1436
+ ) -> list[str]:
1437
+ """Inner implementation of tracking extraction (connected browser).
1438
+
1439
+ Separated from :func:`extract_tracking_data` to keep the
1440
+ connect/disconnect lifecycle in one place.
1441
+ """
1442
+ # --- Step 1: Click the Details tab ---
1443
+ tab_result = await browser.evaluate("""
1444
+ (function() {
1445
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1446
+ var detailsTab = tabs.find(function(t) {
1447
+ return t.textContent.trim() === 'Details';
1448
+ });
1449
+ if (detailsTab) {
1450
+ detailsTab.click();
1451
+ return 'clicked_details';
1452
+ }
1453
+ // Check overflow menu
1454
+ var overflow = document.querySelector('[data-tid="tab-overflow-button"]');
1455
+ if (overflow) return 'check_overflow';
1456
+ return 'no_details_tab';
1457
+ })()
1458
+ """)
1459
+ logger.log("tracking", f"Details tab navigation: {tab_result}")
1460
+
1461
+ if tab_result == "check_overflow":
1462
+ # Click overflow menu and look for Details
1463
+ overflow_rect_raw = await browser.evaluate("""
1464
+ JSON.stringify(
1465
+ document.querySelector('[data-tid="tab-overflow-button"]')
1466
+ .getBoundingClientRect()
1467
+ )
1468
+ """)
1469
+ if overflow_rect_raw:
1470
+ rect = json.loads(str(overflow_rect_raw))
1471
+ ox = int(rect["x"] + rect["width"] / 2)
1472
+ oy = int(rect["y"] + rect["height"] / 2)
1473
+ await browser.send_command(
1474
+ "Input.dispatchMouseEvent",
1475
+ {"type": "mousePressed", "x": ox, "y": oy, "button": "left", "clickCount": 1},
1476
+ )
1477
+ await browser.send_command(
1478
+ "Input.dispatchMouseEvent",
1479
+ {"type": "mouseReleased", "x": ox, "y": oy, "button": "left", "clickCount": 1},
1480
+ )
1481
+ await shutdown.interruptible_sleep(1)
1482
+
1483
+ tab_result = await browser.evaluate("""
1484
+ (function() {
1485
+ var menus = document.querySelectorAll('[role="menu"]');
1486
+ for (var i = 0; i < menus.length; i++) {
1487
+ var items = menus[i].querySelectorAll(
1488
+ '[role="menuitem"], [role="menuitemradio"]'
1489
+ );
1490
+ for (var j = 0; j < items.length; j++) {
1491
+ if (items[j].textContent.trim() === 'Details') {
1492
+ items[j].click();
1493
+ return 'clicked_details';
1494
+ }
1495
+ }
1496
+ }
1497
+ return 'no_details_tab';
1498
+ })()
1499
+ """)
1500
+ logger.log("tracking", f"Overflow menu result: {tab_result}")
1501
+
1502
+ if tab_result == "no_details_tab":
1503
+ logger.log("tracking", "No Details tab found; skipping RSVP extraction")
1504
+ return []
1505
+
1506
+ # Wait for Details tab content to load
1507
+ await shutdown.interruptible_sleep(2)
1508
+ shutdown.check_shutdown()
1509
+
1510
+ # --- Step 2: Find the Tracking panel ---
1511
+ tracking_present = await browser.evaluate("""
1512
+ (function() {
1513
+ var tv = document.querySelector('[aria-label*="Tracking view"]');
1514
+ if (tv) return 'found';
1515
+ // Also check for any element with "Tracking" heading
1516
+ var headings = Array.from(document.querySelectorAll('h2, h3, [role="heading"]'));
1517
+ var trackingH = headings.find(function(h) {
1518
+ return h.textContent.trim() === 'Tracking';
1519
+ });
1520
+ return trackingH ? 'found_heading' : 'not_found';
1521
+ })()
1522
+ """)
1523
+ logger.log("tracking", f"Tracking panel: {tracking_present}")
1524
+
1525
+ if tracking_present == "not_found":
1526
+ logger.log("tracking", "No Tracking panel on Details tab; skipping RSVP extraction")
1527
+ return []
1528
+
1529
+ # --- Step 3: Click "Show more attendees" to load all entries ---
1530
+ # There may be multiple "Show more" buttons (one per section).
1531
+ # Click them all, waiting between each.
1532
+ for attempt in range(3):
1533
+ show_more_result = await browser.evaluate("""
1534
+ (function() {
1535
+ var buttons = Array.from(document.querySelectorAll('button'));
1536
+ var showMore = buttons.filter(function(b) {
1537
+ var label = b.getAttribute('aria-label') || b.textContent || '';
1538
+ return label.indexOf('Show more attendees') !== -1
1539
+ || label.indexOf('Show more') !== -1;
1540
+ });
1541
+ if (showMore.length === 0) return 'none';
1542
+ for (var i = 0; i < showMore.length; i++) {
1543
+ showMore[i].click();
1544
+ }
1545
+ return 'clicked_' + showMore.length;
1546
+ })()
1547
+ """)
1548
+ logger.log("tracking", f"Show more (attempt {attempt + 1}): {show_more_result}")
1549
+
1550
+ if show_more_result == "none":
1551
+ break
1552
+
1553
+ await shutdown.interruptible_sleep(1.5)
1554
+ shutdown.check_shutdown()
1555
+
1556
+ # --- Step 4: Collect all tracking aria-labels ---
1557
+ labels_raw = await browser.evaluate("""
1558
+ (function() {
1559
+ var items = document.querySelectorAll(
1560
+ '[aria-label*="Dialogue Pop-up Button"]'
1561
+ );
1562
+ var labels = [];
1563
+ for (var i = 0; i < items.length; i++) {
1564
+ var label = items[i].getAttribute('aria-label');
1565
+ if (label) labels.push(label);
1566
+ }
1567
+ return JSON.stringify(labels);
1568
+ })()
1569
+ """)
1570
+
1571
+ if not labels_raw:
1572
+ logger.log("tracking", "No tracking labels collected")
1573
+ return []
1574
+
1575
+ labels: list[str] = json.loads(str(labels_raw))
1576
+ logger.log("tracking", f"Collected {len(labels)} tracking label(s)")
1577
+
1578
+ # --- Step 5: Navigate back to Chat tab ---
1579
+ # The pipeline expects to be on the Chat tab for subsequent operations.
1580
+ await browser.evaluate("""
1581
+ (function() {
1582
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1583
+ var chatTab = tabs.find(function(t) {
1584
+ return t.textContent.trim() === 'Chat';
1585
+ });
1586
+ if (chatTab) chatTab.click();
1587
+ })()
1588
+ """)
1589
+ await shutdown.interruptible_sleep(1)
1590
+
1591
+ return labels
1592
+
1593
+
1594
+ # ---------------------------------------------------------------------------
1595
+ # Internal helpers
1596
+ # ---------------------------------------------------------------------------
1597
+
1598
+
1599
+ async def _async_sleep(seconds: float) -> None:
1600
+ """Thin wrapper around asyncio.sleep for clarity."""
1601
+ import asyncio
1602
+
1603
+ await asyncio.sleep(seconds)