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,1721 @@
1
+ """Meeting navigation services.
2
+
3
+ Functions that navigate through the Teams UI to reach the transcript:
4
+ searching for the meeting, selecting the correct occurrence for recurring
5
+ meetings, extracting speaker mappings, and clicking through to the
6
+ Transcript tab.
7
+
8
+ When the thread_id is already known (via MCPS resolution with
9
+ ``--meeting-id``), :func:`navigate_to_thread_direct` bypasses the
10
+ search-click-verify cycle by clicking the chat directly in the sidebar.
11
+
12
+ Each function takes a connected browser and manages its own connect/disconnect
13
+ lifecycle. Side-effectful config mutations (speaker_map, meeting_time_text)
14
+ are returned as part of :class:`NavigationResult` rather than mutating state.
15
+
16
+ Extracted from ``transcript_download.py`` lines 857--1889.
17
+
18
+ Schema version: 1.2.0
19
+ Created: 2026-04-25
20
+ Last updated: 2026-05-02 -- direct thread navigation via sidebar fibre walk
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import base64
27
+ import json
28
+ import typing
29
+ from dataclasses import dataclass, field
30
+ from datetime import date
31
+
32
+ from teams_transcripts.domain.scoring import (
33
+ best_result_index,
34
+ occurrence_matches,
35
+ )
36
+ from teams_transcripts.domain.timestamps import parse_occurrence_duration
37
+ from teams_transcripts.infrastructure.errors import (
38
+ EXIT_DOWNLOAD_FAILED,
39
+ EXIT_MEETING_NOT_FOUND,
40
+ exit_error,
41
+ )
42
+
43
+ if typing.TYPE_CHECKING:
44
+ from teams_transcripts.infrastructure.logging import Logger
45
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
46
+ from teams_transcripts.ports.agent import AgentPort
47
+ from teams_transcripts.ports.browser import BrowserPort
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Result types
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ @dataclass
56
+ class NavigationResult:
57
+ """Result of navigating to the transcript view.
58
+
59
+ Captures metadata discovered during navigation that the downloader
60
+ needs for subsequent steps.
61
+ """
62
+
63
+ success: bool
64
+ speaker_map: dict[str, str] = field(default_factory=dict)
65
+ meeting_time_text: str = ""
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Non-Chat view keywords for stuck-state detection
70
+ # ---------------------------------------------------------------------------
71
+
72
+ _NON_CHAT_KEYWORDS: list[str] = [
73
+ "Calendar",
74
+ "Activity",
75
+ "Teams", # generic Teams landing, not a chat
76
+ "Assignments",
77
+ "Files",
78
+ "Apps",
79
+ "Calls",
80
+ "OneDrive",
81
+ ]
82
+ """Page title keywords that indicate Teams is NOT on the Chat view.
83
+
84
+ The ``is_chat`` check (below) looks for ``| Chat``, ``| Copilot``,
85
+ or ``| Communities`` in the title. If none of those are present AND
86
+ one of these keywords *is* present, the view is stuck on a non-Chat
87
+ page and recovery is needed.
88
+ """
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Internal: Chat-view switching with recovery
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ async def _send_ctrl_3(browser: BrowserPort) -> None:
97
+ """Send the Ctrl+3 keyboard shortcut (Teams Chat view)."""
98
+ await browser.send_command(
99
+ "Input.dispatchKeyEvent",
100
+ {
101
+ "type": "keyDown",
102
+ "key": "3",
103
+ "code": "Digit3",
104
+ "windowsVirtualKeyCode": 51,
105
+ "modifiers": 2,
106
+ },
107
+ )
108
+ await browser.send_command(
109
+ "Input.dispatchKeyEvent",
110
+ {
111
+ "type": "keyUp",
112
+ "key": "3",
113
+ "code": "Digit3",
114
+ "windowsVirtualKeyCode": 51,
115
+ },
116
+ )
117
+
118
+
119
+ async def _send_escape(browser: BrowserPort) -> None:
120
+ """Send an Escape key press to dismiss popups/overlays."""
121
+ await browser.send_command(
122
+ "Input.dispatchKeyEvent",
123
+ {
124
+ "type": "keyDown",
125
+ "key": "Escape",
126
+ "code": "Escape",
127
+ "windowsVirtualKeyCode": 27,
128
+ },
129
+ )
130
+ await browser.send_command(
131
+ "Input.dispatchKeyEvent",
132
+ {
133
+ "type": "keyUp",
134
+ "key": "Escape",
135
+ "code": "Escape",
136
+ "windowsVirtualKeyCode": 27,
137
+ },
138
+ )
139
+
140
+
141
+ def _title_is_chat(title: str) -> bool:
142
+ """Return ``True`` if the page title indicates a Chat-family view.
143
+
144
+ Matches titles containing ``| Chat``, ``| Copilot``, or
145
+ ``| Communities``, as well as titles starting with those words.
146
+ """
147
+ return (
148
+ "| Chat" in title
149
+ or "| Copilot" in title
150
+ or "| Communities" in title
151
+ or title.startswith(("Chat |", "Copilot |", "Communities"))
152
+ )
153
+
154
+
155
+ async def _dom_has_chat_content(browser: BrowserPort) -> bool:
156
+ """Check whether the DOM contains chat-specific elements.
157
+
158
+ Teams v2 is an SPA -- the page title can be stale (e.g. still
159
+ showing "Calendar") while the DOM actually contains a meeting
160
+ chat. This function checks for reliable DOM indicators:
161
+
162
+ * ``[data-tid="chat-pane-item"]`` -- chat message items
163
+ * ``[data-tid="chat-title"]`` -- chat header title
164
+ * A tablist whose text includes "Recap" (meeting-specific tab)
165
+
166
+ Returns:
167
+ ``True`` if chat-specific DOM elements are present.
168
+ """
169
+ result = await browser.evaluate("""
170
+ (function() {
171
+ if (document.querySelector('[data-tid="chat-pane-item"]'))
172
+ return true;
173
+ if (document.querySelector('[data-tid="chat-title"]'))
174
+ return true;
175
+ var tabs = document.querySelectorAll('[role="tablist"]');
176
+ for (var i = 0; i < tabs.length; i++) {
177
+ if (tabs[i].textContent.indexOf('Recap') !== -1)
178
+ return true;
179
+ }
180
+ return false;
181
+ })()
182
+ """)
183
+ return result is True
184
+
185
+
186
+ async def _is_on_chat_view(browser: BrowserPort) -> bool:
187
+ """Determine whether the browser is on a Chat-family view.
188
+
189
+ Combines the page title check with DOM-based detection. The
190
+ title alone is unreliable because Teams' SPA can show a stale
191
+ title (e.g. "Calendar") while the content is actually a chat.
192
+
193
+ Returns:
194
+ ``True`` if either the title or DOM indicates a Chat view.
195
+ """
196
+ title = str(await browser.evaluate("document.title") or "")
197
+ if _title_is_chat(title) or "Search |" in title:
198
+ return True
199
+ return await _dom_has_chat_content(browser)
200
+
201
+
202
+ async def _click_chat_nav_item(browser: BrowserPort) -> bool:
203
+ """Try to click the Chat navigation item in the Teams left rail.
204
+
205
+ Uses the ``app-bar-Chat`` data-tid or falls back to matching
206
+ ``aria-label`` attributes.
207
+
208
+ Returns:
209
+ ``True`` if a clickable element was found and clicked.
210
+ """
211
+ result = await browser.evaluate("""
212
+ (function() {
213
+ // Primary: data-tid selector used by Teams for the Chat button
214
+ var chatBtn = document.querySelector(
215
+ '[data-tid="app-bar-Chat"]'
216
+ );
217
+ if (chatBtn) {
218
+ chatBtn.click();
219
+ return 'clicked_data_tid';
220
+ }
221
+ // Fallback: aria-label matching
222
+ var navBtns = Array.from(document.querySelectorAll(
223
+ 'button[aria-label], a[aria-label]'
224
+ ));
225
+ for (var i = 0; i < navBtns.length; i++) {
226
+ var label = navBtns[i].getAttribute('aria-label') || '';
227
+ if (label === 'Chat' || label === 'Chats') {
228
+ navBtns[i].click();
229
+ return 'clicked_aria';
230
+ }
231
+ }
232
+ return 'not_found';
233
+ })()
234
+ """)
235
+ return str(result).startswith("clicked")
236
+
237
+
238
+ async def _ensure_chat_view(
239
+ browser: BrowserPort,
240
+ logger: Logger,
241
+ shutdown: ShutdownCoordinator,
242
+ *,
243
+ max_attempts: int = 4,
244
+ ) -> None:
245
+ """Switch to Chat view, retrying if Teams is on a non-Chat page.
246
+
247
+ Strategy:
248
+ 1. Send ``Ctrl+3`` and check the page title.
249
+ 2. If the title indicates Chat, dismiss popups and return.
250
+ 3. If stuck on Calendar/Activity/etc., try clicking the Chat
251
+ nav item via JS (DOM click on ``[data-tid="app-bar-Chat"]``).
252
+ 4. Retry up to *max_attempts* times with increasing wait.
253
+
254
+ This handles the common case where a fresh Teams launch or tenant
255
+ switch opens on Calendar view instead of Chat.
256
+
257
+ Args:
258
+ browser: Connected browser port.
259
+ logger: Logger instance.
260
+ shutdown: Shutdown coordinator.
261
+ max_attempts: Maximum retry attempts (default 4).
262
+ """
263
+ for attempt in range(max_attempts):
264
+ # Send Ctrl+3 keyboard shortcut
265
+ logger.log(
266
+ "step4",
267
+ f"Switching to Chat view (Ctrl+3, attempt {attempt + 1}/{max_attempts})...",
268
+ )
269
+ await _send_ctrl_3(browser)
270
+ await shutdown.interruptible_sleep(2)
271
+
272
+ # Check current view -- first by title, then by DOM content.
273
+ # Teams v2 is an SPA whose page title can be stale (e.g. still
274
+ # "Calendar") while the DOM actually contains a meeting chat.
275
+ title = str(await browser.evaluate("document.title") or "")
276
+ logger.log("step4", f"Page title: {title}")
277
+
278
+ if _title_is_chat(title) or "Search |" in title:
279
+ # On Chat (or a search within Chat) -- dismiss popups and proceed
280
+ await _send_escape(browser)
281
+ await asyncio.sleep(0.5)
282
+ return
283
+
284
+ # Title doesn't confirm Chat -- check DOM indicators as fallback
285
+ if await _dom_has_chat_content(browser):
286
+ logger.log(
287
+ "step4",
288
+ "Title does not indicate Chat but DOM has chat content. "
289
+ "Proceeding (SPA title may be stale).",
290
+ )
291
+ await _send_escape(browser)
292
+ await asyncio.sleep(0.5)
293
+ return
294
+
295
+ # Not on Chat view -- try clicking the nav item directly
296
+ logger.log(
297
+ "step4",
298
+ "Not on Chat view (title contains non-Chat keyword). Trying Chat nav item click...",
299
+ )
300
+ clicked = await _click_chat_nav_item(browser)
301
+ if clicked:
302
+ logger.log("step4", "Clicked Chat nav item. Waiting for view switch...")
303
+ await shutdown.interruptible_sleep(3)
304
+
305
+ title = str(await browser.evaluate("document.title") or "")
306
+ logger.log("step4", f"Page title after nav click: {title}")
307
+ if _title_is_chat(title) or "Search |" in title:
308
+ await _send_escape(browser)
309
+ await asyncio.sleep(0.5)
310
+ return
311
+ # DOM fallback after nav click too
312
+ if await _dom_has_chat_content(browser):
313
+ logger.log(
314
+ "step4",
315
+ "DOM has chat content after nav click. Proceeding.",
316
+ )
317
+ await _send_escape(browser)
318
+ await asyncio.sleep(0.5)
319
+ return
320
+ else:
321
+ logger.log("step4", "Chat nav item not found in DOM.")
322
+
323
+ # Still not on Chat -- wait longer before retrying (Teams may be loading)
324
+ wait_secs = 3 + attempt * 2
325
+ logger.log("step4", f"Retrying in {wait_secs}s...")
326
+ await shutdown.interruptible_sleep(wait_secs)
327
+
328
+ # Final fallback: dismiss popups and proceed anyway -- the search
329
+ # input check downstream will catch a genuine failure.
330
+ logger.log(
331
+ "step4",
332
+ f"Could not confirm Chat view after {max_attempts} attempts. "
333
+ f"Proceeding anyway (search input check will catch failures).",
334
+ )
335
+ await _send_escape(browser)
336
+ await asyncio.sleep(0.5)
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Step 4: Navigate to meeting chat
341
+ # ---------------------------------------------------------------------------
342
+
343
+
344
+ async def navigate_to_meeting(
345
+ browser: BrowserPort,
346
+ agent: AgentPort,
347
+ target_id: str,
348
+ config_meeting: str,
349
+ config_date: str,
350
+ logger: Logger,
351
+ shutdown: ShutdownCoordinator,
352
+ *,
353
+ format_json: bool = False,
354
+ result_index_override: int | None = None,
355
+ ) -> bool:
356
+ """Navigate to the meeting chat via the Teams search UI.
357
+
358
+ The browser must be connected to the target containing the search
359
+ input (pre-discovered by the caller).
360
+
361
+ Args:
362
+ browser: Browser port.
363
+ agent: Agent port for vision queries.
364
+ target_id: CDP target ID for the search target.
365
+ config_meeting: Meeting name search string.
366
+ config_date: Target date as ``YYYY-MM-DD``.
367
+ logger: Logger instance.
368
+ shutdown: Shutdown coordinator.
369
+ format_json: Whether to emit JSON error output.
370
+ result_index_override: If set, skip agent and click the result
371
+ at this index directly (used for retries).
372
+
373
+ Returns:
374
+ ``True`` if navigation reached a meeting chat, ``False`` if it
375
+ failed (caller should retry with the next index).
376
+ """
377
+ await browser.connect(target_id)
378
+ try:
379
+ # Switch to Chat view -- with stuck-state detection and recovery.
380
+ # After a fresh launch or tenant switch, Teams may open on
381
+ # Calendar, Activity, or another non-Chat view. Ctrl+3 is the
382
+ # keyboard shortcut for Chat, but it can fail if Teams hasn't
383
+ # finished loading. We retry the shortcut and fall back to
384
+ # clicking the Chat nav item via JS.
385
+ await _ensure_chat_view(browser, logger, shutdown)
386
+
387
+ # Focus and clear search input
388
+ await browser.evaluate("""
389
+ (function() {
390
+ var input = document.querySelector('#ms-searchux-input');
391
+ if (!input) return;
392
+ input.focus();
393
+ var setter = Object.getOwnPropertyDescriptor(
394
+ window.HTMLInputElement.prototype, 'value'
395
+ ).set;
396
+ setter.call(input, '');
397
+ input.dispatchEvent(new Event('input', {bubbles: true}));
398
+ })()
399
+ """)
400
+ await asyncio.sleep(0.5)
401
+
402
+ logger.log("step4", f"Typing meeting name: {config_meeting}")
403
+ await browser.type_text(config_meeting)
404
+
405
+ # Wait for search results
406
+ logger.log("step4", "Waiting for search results...")
407
+ meeting_name_probe = json.dumps(config_meeting.lower()[:20])
408
+ search_mode = None
409
+ for _ in range(20):
410
+ await asyncio.sleep(0.5)
411
+ result = await browser.evaluate(f"""
412
+ (function() {{
413
+ for (var i = 0; i < 10; i++) {{
414
+ var el = document.querySelector(
415
+ '#ms-searchux-popup-' + i
416
+ );
417
+ if (el) {{
418
+ var text = el.textContent.trim();
419
+ if (text.indexOf('Search Filters') === -1)
420
+ return 'popup';
421
+ }}
422
+ }}
423
+ var opts = Array.from(
424
+ document.querySelectorAll('[role="option"]')
425
+ );
426
+ for (var j = 0; j < opts.length; j++) {{
427
+ var t = opts[j].textContent.trim().toLowerCase();
428
+ if (t.indexOf({meeting_name_probe}) !== -1)
429
+ return 'panel';
430
+ }}
431
+ return 'waiting';
432
+ }})()
433
+ """)
434
+ if result in ("popup", "panel"):
435
+ search_mode = result
436
+ break
437
+
438
+ if not search_mode:
439
+ exit_error(
440
+ EXIT_MEETING_NOT_FOUND,
441
+ "SEARCH_FAILED",
442
+ "Search results did not appear after 10 seconds.",
443
+ step="step4",
444
+ format_json=format_json,
445
+ )
446
+
447
+ await shutdown.interruptible_sleep(1)
448
+ logger.log("step4", f"Search UI mode: {search_mode}")
449
+
450
+ # Enumerate results
451
+ if search_mode == "popup":
452
+ results_json = await browser.evaluate("""
453
+ (function() {
454
+ var items = [];
455
+ for (var i = 0; i < 10; i++) {
456
+ var el = document.querySelector(
457
+ '#ms-searchux-popup-' + i
458
+ );
459
+ if (el) {
460
+ var text = el.textContent.trim().substring(0, 120);
461
+ if (text.indexOf('Search Filters') === -1) {
462
+ items.push({
463
+ id: el.id,
464
+ text: text,
465
+ selector: '#' + el.id
466
+ });
467
+ }
468
+ } else {
469
+ break;
470
+ }
471
+ }
472
+ return JSON.stringify(items);
473
+ })()
474
+ """)
475
+ else:
476
+ filter_words = [
477
+ "Messages",
478
+ "Files",
479
+ "Channels",
480
+ "Group chats",
481
+ "Meetings",
482
+ "Images",
483
+ "View all results",
484
+ "Search Filters",
485
+ ]
486
+ results_json = await browser.evaluate(
487
+ """
488
+ (function() {
489
+ var filterWords = """
490
+ + json.dumps(filter_words)
491
+ + """;
492
+ var opts = Array.from(
493
+ document.querySelectorAll('[role="option"]')
494
+ );
495
+ var items = [];
496
+ for (var i = 0; i < opts.length; i++) {
497
+ var text = opts[i].textContent.trim().substring(0, 120);
498
+ var isFilter = false;
499
+ for (var f = 0; f < filterWords.length; f++) {
500
+ if (text === filterWords[f]
501
+ || text.indexOf('Search Filters') !== -1) {
502
+ isFilter = true;
503
+ break;
504
+ }
505
+ }
506
+ if (!isFilter && /in:/.test(text)) isFilter = true;
507
+ if (!isFilter && text.length > 0) {
508
+ var oid = opts[i].id || ('option-' + i);
509
+ items.push({
510
+ id: oid,
511
+ text: text,
512
+ selector: opts[i].id
513
+ ? ('#' + opts[i].id) : null,
514
+ optionIndex: i
515
+ });
516
+ }
517
+ }
518
+ return JSON.stringify(items);
519
+ })()
520
+ """
521
+ )
522
+
523
+ search_results = json.loads(str(results_json))
524
+ logger.log("step4", f"Found {len(search_results)} search result(s):")
525
+ for sr in search_results:
526
+ logger.log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
527
+
528
+ # Panel mode with no results: try Meetings filter
529
+ if search_mode == "panel" and len(search_results) == 0:
530
+ search_results, search_mode = await _try_meetings_filter(browser, logger, shutdown)
531
+
532
+ # Determine which result to click
533
+ if result_index_override is not None:
534
+ result_index = result_index_override
535
+ logger.log("step4", f"Using override result index: {result_index}")
536
+ elif search_mode == "panel":
537
+ result_index = best_result_index(config_meeting, [sr["text"] for sr in search_results])
538
+ logger.log("step4", f"Word-overlap match: index {result_index}")
539
+ else:
540
+ # Popup mode: use the agent
541
+ logger.log("step4", "Taking screenshot of search results...")
542
+ screenshot_bytes = await browser.screenshot()
543
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
544
+
545
+ prompt = (
546
+ f"This is a screenshot of Microsoft Teams search results. "
547
+ f"I am looking for the meeting called '{config_meeting}' "
548
+ f"from {config_date}. "
549
+ f"The search results may include chat entries (showing "
550
+ f"participant names but not dates) and calendar/meeting "
551
+ f"entries (showing dates). "
552
+ f"Chat entries are preferred for transcript downloads. "
553
+ f"If the exact date is not visible in the results, pick "
554
+ f"the first chat result whose name matches the meeting "
555
+ f"name. "
556
+ f"Count the results from top to bottom starting at 0. "
557
+ f"Return a JSON object with a single key 'index' set to "
558
+ f"the 0-based index of the correct result to click, or "
559
+ f"null if NO result matches the meeting name at all. "
560
+ f'For example: {{"index": 0}} or {{"index": null}}'
561
+ )
562
+ logger.log("step4", "Asking agent to identify correct search result...")
563
+ agent_result = agent.vision_query(screenshot_b64, prompt)
564
+ logger.log("step4", f"Agent response: {agent_result}")
565
+
566
+ if not agent_result or agent_result.get("index") is None:
567
+ exit_error(
568
+ EXIT_MEETING_NOT_FOUND,
569
+ "MEETING_NOT_FOUND",
570
+ "Agent could not identify the meeting in search results.",
571
+ step="step4",
572
+ format_json=format_json,
573
+ )
574
+
575
+ result_index = agent_result["index"]
576
+
577
+ # Check index bounds
578
+ if (
579
+ not isinstance(result_index, int)
580
+ or result_index < 0
581
+ or result_index >= len(search_results)
582
+ ):
583
+ if result_index_override is not None:
584
+ logger.log(
585
+ "step4",
586
+ f"Search result index {result_index} out of range "
587
+ f"(only {len(search_results)} results). "
588
+ f"No more results to try.",
589
+ )
590
+ return False
591
+ exit_error(
592
+ EXIT_MEETING_NOT_FOUND,
593
+ "MEETING_NOT_FOUND",
594
+ f"Agent picked index {result_index} but only "
595
+ f"{len(search_results)} search results exist.",
596
+ step="step4",
597
+ format_json=format_json,
598
+ )
599
+
600
+ target_result = search_results[result_index]
601
+ target_id_attr = target_result.get("id", "")
602
+
603
+ logger.log(
604
+ "step4",
605
+ f"Selecting result index {result_index}: {target_result['text'][:60]}...",
606
+ )
607
+
608
+ # Get bounding box and click
609
+ if target_id_attr and not target_id_attr.startswith("option-"):
610
+ target_id_json = json.dumps(target_id_attr)
611
+ bbox_json = await browser.evaluate(f"""
612
+ (function() {{
613
+ var item = document.getElementById({target_id_json});
614
+ if (!item) return JSON.stringify({{error: 'not_found'}});
615
+ var rect = item.getBoundingClientRect();
616
+ return JSON.stringify({{
617
+ x: Math.round(rect.left + rect.width / 2),
618
+ y: Math.round(rect.top + rect.height / 2),
619
+ id: item.id
620
+ }});
621
+ }})()
622
+ """)
623
+ else:
624
+ oi = target_result.get("optionIndex", 0)
625
+ bbox_json = await browser.evaluate(f"""
626
+ (function() {{
627
+ var opts = Array.from(
628
+ document.querySelectorAll('[role="option"]')
629
+ );
630
+ if (opts.length <= {oi})
631
+ return JSON.stringify({{error: 'not_found'}});
632
+ var item = opts[{oi}];
633
+ var rect = item.getBoundingClientRect();
634
+ return JSON.stringify({{
635
+ x: Math.round(rect.left + rect.width / 2),
636
+ y: Math.round(rect.top + rect.height / 2),
637
+ id: item.id || 'option-{oi}'
638
+ }});
639
+ }})()
640
+ """)
641
+ bbox = json.loads(str(bbox_json))
642
+
643
+ if bbox.get("error"):
644
+ logger.log(
645
+ "step4",
646
+ f"Could not find element for result at index {result_index}.",
647
+ )
648
+ return False
649
+
650
+ cx, cy = bbox["x"], bbox["y"]
651
+ logger.log("step4", f"Clicking at ({cx}, {cy}) on {bbox['id']}...")
652
+
653
+ await browser.send_command(
654
+ "Input.dispatchMouseEvent",
655
+ {
656
+ "type": "mousePressed",
657
+ "x": cx,
658
+ "y": cy,
659
+ "button": "left",
660
+ "clickCount": 1,
661
+ },
662
+ )
663
+ await browser.send_command(
664
+ "Input.dispatchMouseEvent",
665
+ {
666
+ "type": "mouseReleased",
667
+ "x": cx,
668
+ "y": cy,
669
+ "button": "left",
670
+ "clickCount": 1,
671
+ },
672
+ )
673
+
674
+ # Wait for navigation
675
+ logger.log("step4", "Waiting for meeting chat to load...")
676
+ await shutdown.interruptible_sleep(5)
677
+
678
+ new_title = str(await browser.evaluate("document.title") or "")
679
+ logger.log("step4", f"Page title after navigation: {new_title}")
680
+
681
+ # "Search |" means we're still on the search results page --
682
+ # the click didn't navigate away. Hard failure.
683
+ if "Search |" in new_title:
684
+ logger.log(
685
+ "step4",
686
+ "Navigation did not reach a meeting chat. "
687
+ "Result may be a file, channel, or filter -- "
688
+ "not a chat entry.",
689
+ )
690
+ return False
691
+
692
+ # Check via title first, then fall back to DOM indicators.
693
+ # Teams' SPA title can be stale (e.g. still "Calendar") while
694
+ # the DOM actually contains the meeting chat content.
695
+ is_chat = _title_is_chat(new_title)
696
+ if not is_chat:
697
+ dom_ok = await _dom_has_chat_content(browser)
698
+ if dom_ok:
699
+ logger.log(
700
+ "step4",
701
+ "Title does not indicate Chat but DOM has chat content. "
702
+ "Proceeding (SPA title may be stale).",
703
+ )
704
+ else:
705
+ logger.log(
706
+ "step4",
707
+ "Navigation did not reach a meeting chat. "
708
+ "Result may be a file, channel, or filter -- "
709
+ "not a chat entry.",
710
+ )
711
+ return False
712
+ finally:
713
+ await browser.disconnect()
714
+
715
+ return True
716
+
717
+
718
+ # ---------------------------------------------------------------------------
719
+ # Step 4a: Direct thread navigation (when thread_id is known)
720
+ # ---------------------------------------------------------------------------
721
+
722
+ # JavaScript that walks the chat sidebar's React fibre tree to find a
723
+ # chat item whose ``props.chat.id`` matches the target thread_id.
724
+ # Returns the bounding box of the matching element for clicking, or
725
+ # ``{found: false}`` if no match is found.
726
+ #
727
+ # The sidebar in Teams v2 uses a virtualised list, so only visible
728
+ # items have DOM elements. This approach works best when the meeting
729
+ # chat was recent and is therefore near the top of the sidebar.
730
+ _JS_FIND_SIDEBAR_CHAT = r"""
731
+ (function() {
732
+ var targetId = TARGET_THREAD_ID;
733
+
734
+ // Strategy 1: Walk elements with data-tid containing "chat" that
735
+ // are likely sidebar chat items (treeitem, listitem, or generic).
736
+ var selectors = [
737
+ '[role="treeitem"]',
738
+ '[role="listitem"]',
739
+ '[data-tid*="chat-list"]',
740
+ '[data-tid*="chat-pane-list"] [role="option"]'
741
+ ];
742
+ var seen = new Set();
743
+ var candidates = [];
744
+ for (var s = 0; s < selectors.length; s++) {
745
+ var els = document.querySelectorAll(selectors[s]);
746
+ for (var e = 0; e < els.length; e++) {
747
+ if (!seen.has(els[e])) {
748
+ seen.add(els[e]);
749
+ candidates.push(els[e]);
750
+ }
751
+ }
752
+ }
753
+
754
+ for (var i = 0; i < candidates.length; i++) {
755
+ var el = candidates[i];
756
+ var fiberKey = Object.keys(el).find(function(k) {
757
+ return k.startsWith('__reactFiber$');
758
+ });
759
+ if (!fiberKey) continue;
760
+
761
+ var current = el[fiberKey];
762
+ for (var d = 0; d < 30 && current; d++) {
763
+ var props = current.memoizedProps;
764
+ if (props && props.chat && props.chat.id === targetId) {
765
+ var rect = el.getBoundingClientRect();
766
+ if (rect.width === 0 || rect.height === 0) break;
767
+ return JSON.stringify({
768
+ found: true,
769
+ x: Math.round(rect.left + rect.width / 2),
770
+ y: Math.round(rect.top + rect.height / 2),
771
+ title: (props.chat.topic || '').substring(0, 80)
772
+ });
773
+ }
774
+ current = current['return'];
775
+ }
776
+ }
777
+
778
+ return JSON.stringify({found: false, candidateCount: candidates.length});
779
+ })()
780
+ """
781
+
782
+
783
+ async def navigate_to_thread_direct(
784
+ browser: BrowserPort,
785
+ target_id: str,
786
+ thread_id: str,
787
+ logger: Logger,
788
+ shutdown: ShutdownCoordinator,
789
+ ) -> str | None:
790
+ """Navigate directly to a meeting chat by clicking its sidebar item.
791
+
792
+ Switches to Chat view, then walks the sidebar's React fibre tree
793
+ to find the chat item whose ``props.chat.id`` matches *thread_id*.
794
+ If found, clicks it and verifies we landed on a chat.
795
+
796
+ This bypasses the search-type-wait-agent-click-verify cycle used
797
+ by :func:`navigate_to_meeting`, saving ~25 seconds when the chat
798
+ is visible in the sidebar.
799
+
800
+ Args:
801
+ browser: Browser port.
802
+ target_id: CDP target ID for the main Teams target.
803
+ thread_id: Full Teams thread identifier
804
+ (e.g. ``19:meeting_...@thread.v2``).
805
+ logger: Logger instance.
806
+ shutdown: Shutdown coordinator.
807
+
808
+ Returns:
809
+ The chat title (meeting subject) on success, or ``None`` if
810
+ the chat was not found in the sidebar (caller should fall back
811
+ to search-based navigation).
812
+ """
813
+ await browser.connect(target_id)
814
+ try:
815
+ # Switch to Chat view (same as navigate_to_meeting)
816
+ await _ensure_chat_view(browser, logger, shutdown)
817
+
818
+ # Inject the thread_id into the JS template and search the sidebar
819
+ thread_id_json = json.dumps(thread_id)
820
+ js = _JS_FIND_SIDEBAR_CHAT.replace("TARGET_THREAD_ID", thread_id_json)
821
+
822
+ logger.log("step4-direct", f"Searching sidebar for thread {thread_id[:40]}...")
823
+ raw = await browser.evaluate(js)
824
+
825
+ if not raw:
826
+ logger.log("step4-direct", "No response from sidebar search.")
827
+ return None
828
+
829
+ result = json.loads(str(raw))
830
+
831
+ if not result.get("found"):
832
+ candidate_count = result.get("candidateCount", 0)
833
+ logger.log(
834
+ "step4-direct",
835
+ f"Thread not found in sidebar ({candidate_count} items inspected). "
836
+ f"Falling back to search.",
837
+ )
838
+ return None
839
+
840
+ # Click the matching sidebar item
841
+ cx, cy = result["x"], result["y"]
842
+ title = result.get("title", "")
843
+ logger.log("step4-direct", f"Found chat '{title}' at ({cx}, {cy}). Clicking...")
844
+
845
+ await browser.send_command(
846
+ "Input.dispatchMouseEvent",
847
+ {
848
+ "type": "mousePressed",
849
+ "x": cx,
850
+ "y": cy,
851
+ "button": "left",
852
+ "clickCount": 1,
853
+ },
854
+ )
855
+ await browser.send_command(
856
+ "Input.dispatchMouseEvent",
857
+ {
858
+ "type": "mouseReleased",
859
+ "x": cx,
860
+ "y": cy,
861
+ "button": "left",
862
+ "clickCount": 1,
863
+ },
864
+ )
865
+
866
+ # Wait for the chat to load
867
+ logger.log("step4-direct", "Waiting for meeting chat to load...")
868
+ await shutdown.interruptible_sleep(3)
869
+
870
+ # Verify we landed on a chat view
871
+ new_title = str(await browser.evaluate("document.title") or "")
872
+ logger.log("step4-direct", f"Page title after click: {new_title}")
873
+
874
+ if _title_is_chat(new_title):
875
+ logger.log("step4-direct", "Direct navigation succeeded (title confirms chat).")
876
+ return title
877
+
878
+ if await _dom_has_chat_content(browser):
879
+ logger.log(
880
+ "step4-direct",
881
+ "Direct navigation succeeded (DOM has chat content, title may be stale).",
882
+ )
883
+ return title
884
+
885
+ logger.log(
886
+ "step4-direct",
887
+ "Clicked sidebar item but did not reach a chat view. Falling back to search.",
888
+ )
889
+ return None
890
+ finally:
891
+ await browser.disconnect()
892
+
893
+
894
+ async def verify_meeting_thread_id(
895
+ browser: BrowserPort,
896
+ target_id: str,
897
+ expected_thread_id: str,
898
+ logger: Logger,
899
+ ) -> bool:
900
+ """Verify that the current chat's thread_id matches the expected value.
901
+
902
+ After ``navigate_to_meeting`` lands on a chat, this function reads the
903
+ thread_id from the React fibre tree of the ``chat-topic-menu`` element
904
+ and compares it against the expected value. Used to disambiguate when
905
+ multiple meetings share the same subject.
906
+
907
+ Args:
908
+ browser: Browser port.
909
+ target_id: CDP target ID for the main Teams target.
910
+ expected_thread_id: The full thread_id to match.
911
+ logger: Logger instance.
912
+
913
+ Returns:
914
+ ``True`` if the thread_id matches or could not be read (permissive),
915
+ ``False`` if it explicitly does not match (caller should retry).
916
+ """
917
+ await browser.connect(target_id)
918
+ try:
919
+ raw = await browser.evaluate("""
920
+ (function() {
921
+ var menu = document.querySelector('[data-tid="chat-topic-menu"]');
922
+ if (!menu) return JSON.stringify({error: 'no_menu'});
923
+
924
+ var fiberKey = Object.keys(menu).find(function(k) {
925
+ return k.startsWith('__reactFiber$');
926
+ });
927
+ if (!fiberKey) return JSON.stringify({error: 'no_fiber'});
928
+
929
+ var current = menu[fiberKey];
930
+ for (var i = 0; i < 25 && current; i++) {
931
+ var props = current.memoizedProps;
932
+ if (props && props.chat && props.chat.id) {
933
+ return JSON.stringify({threadId: props.chat.id});
934
+ }
935
+ current = current.return;
936
+ }
937
+ return JSON.stringify({error: 'no_chat_id'});
938
+ })()
939
+ """)
940
+ if not raw:
941
+ logger.log("step4b", "Could not read chat fibre (no response). Proceeding.")
942
+ return True
943
+
944
+ data = json.loads(str(raw))
945
+ if data.get("error"):
946
+ logger.log("step4b", f"Could not read thread_id: {data['error']}. Proceeding.")
947
+ return True
948
+
949
+ actual_thread_id = data.get("threadId", "")
950
+ if actual_thread_id == expected_thread_id:
951
+ logger.log("step4b", "Thread ID verified.")
952
+ return True
953
+
954
+ # Fallback: compare extracted UUIDs (handles format variations in
955
+ # the thread_id encoding between build_thread_id and the actual
956
+ # Teams fibre tree value).
957
+ from teams_transcripts.domain.mcps import extract_meeting_id
958
+
959
+ expected_uuid = extract_meeting_id(expected_thread_id).lower()
960
+ actual_uuid = extract_meeting_id(actual_thread_id).lower()
961
+ if expected_uuid and actual_uuid and expected_uuid == actual_uuid:
962
+ logger.log("step4b", "Thread ID verified (UUID match).")
963
+ return True
964
+
965
+ logger.log(
966
+ "step4b",
967
+ f"Thread ID mismatch: expected {expected_thread_id[:40]}..., "
968
+ f"got {actual_thread_id[:40]}...",
969
+ )
970
+ return False
971
+ finally:
972
+ await browser.disconnect()
973
+
974
+
975
+ # ---------------------------------------------------------------------------
976
+ # Step 5: Navigate to Recap > Transcript
977
+ # ---------------------------------------------------------------------------
978
+
979
+
980
+ async def navigate_to_transcript(
981
+ browser: BrowserPort,
982
+ agent: AgentPort,
983
+ target_id: str,
984
+ config_date: str,
985
+ config_time: str | None,
986
+ config_model: str,
987
+ logger: Logger,
988
+ shutdown: ShutdownCoordinator,
989
+ *,
990
+ format_json: bool = False,
991
+ require_recap: bool = True,
992
+ require_transcript: bool = True,
993
+ ) -> NavigationResult:
994
+ """Navigate from meeting chat to the Transcript tab inside Recap.
995
+
996
+ For recurring meetings, selects the correct occurrence from the date
997
+ dropdown. Extracts the speaker mapping and meeting time text.
998
+
999
+ Args:
1000
+ browser: Browser port.
1001
+ agent: Agent port for vision queries (occurrence selection).
1002
+ target_id: CDP target ID for the main Teams target.
1003
+ config_date: Target date as ``YYYY-MM-DD``.
1004
+ config_time: Target time as ``HH:MM``, or ``None``.
1005
+ config_model: LLM model identifier.
1006
+ logger: Logger instance.
1007
+ shutdown: Shutdown coordinator.
1008
+ format_json: Whether to emit JSON error output.
1009
+
1010
+ Returns:
1011
+ A :class:`NavigationResult` with success flag and metadata.
1012
+ """
1013
+ await browser.connect(target_id)
1014
+ try:
1015
+ # Click Recap tab -- check visible tabs first, then overflow menu
1016
+ recap_result = await browser.evaluate("""
1017
+ (function() {
1018
+ var tabs = Array.from(
1019
+ document.querySelectorAll('[role="tab"]')
1020
+ );
1021
+ var tabNames = tabs.map(function(t) {
1022
+ return t.textContent.trim();
1023
+ });
1024
+ var recapTab = tabs.find(function(t) {
1025
+ return t.textContent.indexOf('Recap') !== -1;
1026
+ });
1027
+ if (recapTab) {
1028
+ recapTab.click();
1029
+ return 'clicked_recap';
1030
+ }
1031
+ var inRecap = tabNames.some(function(n) {
1032
+ return n.indexOf('Speakers') !== -1;
1033
+ });
1034
+ if (inRecap) return 'already_in_recap';
1035
+ var overflow = document.querySelector('[data-tid="tab-overflow-button"]');
1036
+ if (overflow) return 'check_overflow';
1037
+ return 'no_recap_tab';
1038
+ })()
1039
+ """)
1040
+ logger.log("step5", f"Recap navigation: {recap_result}")
1041
+
1042
+ # If Recap is hidden behind the overflow menu, click overflow and
1043
+ # look for the Recap menu item.
1044
+ if recap_result == "check_overflow":
1045
+ logger.log("step5", "Recap not in visible tabs; checking overflow menu...")
1046
+ overflow_rect = await browser.evaluate("""
1047
+ JSON.stringify(
1048
+ document.querySelector('[data-tid="tab-overflow-button"]')
1049
+ .getBoundingClientRect()
1050
+ )
1051
+ """)
1052
+ import json as _json
1053
+
1054
+ rect = _json.loads(str(overflow_rect))
1055
+ ox = int(rect["x"] + rect["width"] / 2)
1056
+ oy = int(rect["y"] + rect["height"] / 2)
1057
+ await browser.send_command(
1058
+ "Input.dispatchMouseEvent",
1059
+ {
1060
+ "type": "mousePressed",
1061
+ "x": ox,
1062
+ "y": oy,
1063
+ "button": "left",
1064
+ "clickCount": 1,
1065
+ },
1066
+ )
1067
+ await browser.send_command(
1068
+ "Input.dispatchMouseEvent",
1069
+ {
1070
+ "type": "mouseReleased",
1071
+ "x": ox,
1072
+ "y": oy,
1073
+ "button": "left",
1074
+ "clickCount": 1,
1075
+ },
1076
+ )
1077
+ await shutdown.interruptible_sleep(1)
1078
+
1079
+ recap_result = await browser.evaluate("""
1080
+ (function() {
1081
+ var menus = document.querySelectorAll('[role="menu"]');
1082
+ for (var i = 0; i < menus.length; i++) {
1083
+ var items = menus[i].querySelectorAll(
1084
+ '[role="menuitem"], [role="menuitemradio"]'
1085
+ );
1086
+ for (var j = 0; j < items.length; j++) {
1087
+ if (items[j].textContent.indexOf('Recap') !== -1) {
1088
+ items[j].click();
1089
+ return 'clicked_recap';
1090
+ }
1091
+ }
1092
+ }
1093
+ return 'no_recap_tab';
1094
+ })()
1095
+ """)
1096
+ logger.log("step5", f"Overflow menu result: {recap_result}")
1097
+
1098
+ if recap_result == "no_recap_tab" and require_recap:
1099
+ exit_error(
1100
+ EXIT_DOWNLOAD_FAILED,
1101
+ "RECAP_NOT_FOUND",
1102
+ "Recap tab not found. The meeting may not have been recorded.",
1103
+ step="step5",
1104
+ format_json=format_json,
1105
+ )
1106
+ if recap_result == "no_recap_tab":
1107
+ logger.log("step5", "Recap tab not found; continuing because Recap is optional.")
1108
+ return NavigationResult(success=True)
1109
+
1110
+ if recap_result == "clicked_recap":
1111
+ logger.log("step5", "Waiting for Recap content to load...")
1112
+ await shutdown.interruptible_sleep(3)
1113
+
1114
+ # Select correct occurrence for recurring meetings
1115
+ occurrence_ok = await _select_correct_occurrence(
1116
+ browser,
1117
+ agent,
1118
+ config_date,
1119
+ config_time,
1120
+ config_model,
1121
+ logger,
1122
+ shutdown,
1123
+ format_json=format_json,
1124
+ )
1125
+ if not occurrence_ok:
1126
+ logger.log("step5", "Target date not available in this chat group.")
1127
+ return NavigationResult(success=False)
1128
+
1129
+ # Extract meeting time text from Recap header
1130
+ meeting_time_text = await browser.evaluate(r"""
1131
+ (function() {
1132
+ var months = 'January|February|March|April|May|June'
1133
+ + '|July|August|September|October|November|December';
1134
+ var re = new RegExp(
1135
+ '\\d{1,2}\\s+(' + months + ')\\s+\\d{4}\\s+'
1136
+ + '\\d{1,2}:\\d{2}\\s*-\\s*\\d{1,2}:\\d{2}', 'i'
1137
+ );
1138
+ var els = document.querySelectorAll('button, span, div');
1139
+ for (var i = 0; i < els.length; i++) {
1140
+ var t = els[i].textContent.trim();
1141
+ if (re.test(t) && t.length < 60
1142
+ && els[i].children.length === 0) {
1143
+ return t;
1144
+ }
1145
+ }
1146
+ return '';
1147
+ })()
1148
+ """)
1149
+ if meeting_time_text:
1150
+ logger.log("step5", f"Meeting time: {meeting_time_text}")
1151
+ else:
1152
+ logger.log("step5", "Meeting time text not found in Recap header.")
1153
+
1154
+ # Extract speaker mapping
1155
+ speaker_map = await _extract_speaker_mapping(browser, logger, shutdown)
1156
+ if speaker_map:
1157
+ logger.log("step5", f"Speaker mapping: {len(speaker_map)} speaker(s)")
1158
+ for guid, name in speaker_map.items():
1159
+ logger.log("step5", f" {guid[:12]}... -> {name}")
1160
+ else:
1161
+ logger.log(
1162
+ "step5",
1163
+ "No speaker mapping available (Speakers tab missing or empty).",
1164
+ )
1165
+
1166
+ if require_transcript:
1167
+ # Click Transcript tab
1168
+ transcript_result = await browser.evaluate("""
1169
+ (function() {
1170
+ var tabs = Array.from(
1171
+ document.querySelectorAll('[role="tab"]')
1172
+ );
1173
+ var transcriptTab = tabs.find(function(t) {
1174
+ return t.textContent.indexOf('Transcript') !== -1;
1175
+ });
1176
+ if (!transcriptTab) return 'no_transcript_tab';
1177
+ transcriptTab.click();
1178
+ return 'clicked_transcript:'
1179
+ + transcriptTab.getAttribute('aria-selected');
1180
+ })()
1181
+ """)
1182
+ logger.log("step5", f"Transcript navigation: {transcript_result}")
1183
+
1184
+ if transcript_result == "no_transcript_tab":
1185
+ exit_error(
1186
+ EXIT_DOWNLOAD_FAILED,
1187
+ "TRANSCRIPT_NOT_FOUND",
1188
+ "Transcript tab not found. Transcription may not have been enabled.",
1189
+ step="step5",
1190
+ format_json=format_json,
1191
+ )
1192
+
1193
+ await shutdown.interruptible_sleep(3)
1194
+ else:
1195
+ logger.log("step5", "Transcript tab skipped because transcript output is disabled.")
1196
+ finally:
1197
+ await browser.disconnect()
1198
+
1199
+ return NavigationResult(
1200
+ success=True,
1201
+ speaker_map=speaker_map,
1202
+ meeting_time_text=str(meeting_time_text or ""),
1203
+ )
1204
+
1205
+
1206
+ # ---------------------------------------------------------------------------
1207
+ # Internal: occurrence selection
1208
+ # ---------------------------------------------------------------------------
1209
+
1210
+
1211
+ async def _select_correct_occurrence(
1212
+ browser: BrowserPort,
1213
+ agent: AgentPort,
1214
+ config_date: str,
1215
+ config_time: str | None,
1216
+ config_model: str,
1217
+ logger: Logger,
1218
+ shutdown: ShutdownCoordinator,
1219
+ *,
1220
+ format_json: bool = False,
1221
+ ) -> bool:
1222
+ """For recurring meetings, select the correct date occurrence.
1223
+
1224
+ Returns ``True`` if the correct occurrence is selected (or it is a
1225
+ single-occurrence meeting), ``False`` if the target date is not
1226
+ available in the dropdown.
1227
+ """
1228
+ occurrence_info = await browser.evaluate("""
1229
+ (function() {
1230
+ var months = 'January|February|March|April|May|June'
1231
+ + '|July|August|September|October|November|December';
1232
+ var dateRe = new RegExp(
1233
+ '\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
1234
+ );
1235
+ var dateBtn = null;
1236
+ var allBtns = Array.from(document.querySelectorAll('button'));
1237
+ for (var i = 0; i < allBtns.length; i++) {
1238
+ var text = allBtns[i].textContent.trim();
1239
+ if (dateRe.test(text)) {
1240
+ dateBtn = allBtns[i];
1241
+ break;
1242
+ }
1243
+ }
1244
+ if (!dateBtn) return JSON.stringify({has_dropdown: false});
1245
+ return JSON.stringify({
1246
+ has_dropdown: true,
1247
+ current_text: dateBtn.textContent.trim().substring(0, 100)
1248
+ });
1249
+ })()
1250
+ """)
1251
+ info = json.loads(str(occurrence_info))
1252
+
1253
+ if not info.get("has_dropdown"):
1254
+ logger.log(
1255
+ "step5",
1256
+ "No occurrence dropdown found (single occurrence meeting).",
1257
+ )
1258
+ return True
1259
+
1260
+ current_text = info.get("current_text", "")
1261
+ logger.log("step5", f"Occurrence dropdown shows: {current_text}")
1262
+
1263
+ # When config_date is empty (speculative direct navigation without
1264
+ # MCPS resolution), we cannot select the correct occurrence.
1265
+ # Return False so the caller falls back to MCPS-based resolution.
1266
+ if not config_date:
1267
+ logger.log(
1268
+ "step5",
1269
+ "Recurring meeting detected but no target date available. "
1270
+ "Falling back to MCPS resolution.",
1271
+ )
1272
+ return False
1273
+
1274
+ dt = date.fromisoformat(config_date)
1275
+ day_str = str(dt.day)
1276
+ month_str = dt.strftime("%B")
1277
+ year_str = str(dt.year)
1278
+
1279
+ if occurrence_matches(current_text, config_date, config_time):
1280
+ logger.log("step5", "Correct occurrence already selected.")
1281
+ dur = parse_occurrence_duration(current_text)
1282
+ if dur:
1283
+ logger.log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
1284
+ return True
1285
+
1286
+ target_label = f"{day_str} {month_str} {year_str}"
1287
+ if config_time:
1288
+ target_label += f" {config_time}"
1289
+ logger.log(
1290
+ "step5",
1291
+ f"Need to switch to {target_label}. Opening dropdown...",
1292
+ )
1293
+
1294
+ # Click the date dropdown button
1295
+ bbox_json = await browser.evaluate("""
1296
+ (function() {
1297
+ var months = 'January|February|March|April|May|June'
1298
+ + '|July|August|September|October|November|December';
1299
+ var dateRe = new RegExp(
1300
+ '\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
1301
+ );
1302
+ var allBtns = Array.from(document.querySelectorAll('button'));
1303
+ for (var i = 0; i < allBtns.length; i++) {
1304
+ var text = allBtns[i].textContent.trim();
1305
+ if (dateRe.test(text)) {
1306
+ var rect = allBtns[i].getBoundingClientRect();
1307
+ return JSON.stringify({
1308
+ x: Math.round(rect.left + rect.width / 2),
1309
+ y: Math.round(rect.top + rect.height / 2)
1310
+ });
1311
+ }
1312
+ }
1313
+ return JSON.stringify({error: 'not_found'});
1314
+ })()
1315
+ """)
1316
+ bbox = json.loads(str(bbox_json))
1317
+
1318
+ if bbox.get("error"):
1319
+ logger.log("step5", "Could not find date dropdown button.")
1320
+ return False
1321
+
1322
+ await browser.send_command(
1323
+ "Input.dispatchMouseEvent",
1324
+ {
1325
+ "type": "mousePressed",
1326
+ "x": bbox["x"],
1327
+ "y": bbox["y"],
1328
+ "button": "left",
1329
+ "clickCount": 1,
1330
+ },
1331
+ )
1332
+ await browser.send_command(
1333
+ "Input.dispatchMouseEvent",
1334
+ {
1335
+ "type": "mouseReleased",
1336
+ "x": bbox["x"],
1337
+ "y": bbox["y"],
1338
+ "button": "left",
1339
+ "clickCount": 1,
1340
+ },
1341
+ )
1342
+ await shutdown.interruptible_sleep(2)
1343
+
1344
+ # Check if target date is in dropdown
1345
+ dropdown_check = await browser.evaluate(f"""
1346
+ (function() {{
1347
+ var items = Array.from(document.querySelectorAll(
1348
+ '[role="menuitem"], [role="option"], '
1349
+ + '[role="listbox"] > *, [role="menu"] > *'
1350
+ ));
1351
+ var allText = items.map(function(el) {{
1352
+ return el.textContent.trim();
1353
+ }});
1354
+ var hasTarget = allText.some(function(t) {{
1355
+ return t.indexOf('{month_str}') !== -1
1356
+ && t.indexOf('{year_str}') !== -1;
1357
+ }});
1358
+ return JSON.stringify({{
1359
+ count: items.length,
1360
+ has_target_month: hasTarget,
1361
+ items: allText.slice(0, 10)
1362
+ }});
1363
+ }})()
1364
+ """)
1365
+ dropdown_info = json.loads(str(dropdown_check))
1366
+ logger.log(
1367
+ "step5",
1368
+ f"Dropdown items: {dropdown_info.get('count', 0)}, "
1369
+ f"has target month: {dropdown_info.get('has_target_month', False)}",
1370
+ )
1371
+
1372
+ dropdown_items = dropdown_info.get("items", [])
1373
+ has_precise_match = dropdown_info.get("has_target_month", False) and any(
1374
+ occurrence_matches(item, config_date, config_time) for item in dropdown_items
1375
+ )
1376
+
1377
+ if not has_precise_match:
1378
+ logger.log(
1379
+ "step5",
1380
+ f"Target {target_label} not in dropdown. Available: {dropdown_items[:5]}",
1381
+ )
1382
+ await _dismiss_dropdown(browser)
1383
+ return False
1384
+
1385
+ # Use agent to pick exact occurrence
1386
+ screenshot_bytes = await browser.screenshot()
1387
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode()
1388
+
1389
+ time_instruction = ""
1390
+ if config_time:
1391
+ time_instruction = (
1392
+ f" The meeting must start at {config_time} -- match both the date AND the start time."
1393
+ )
1394
+
1395
+ prompt = (
1396
+ f"This is a screenshot of a Microsoft Teams recurring meeting's "
1397
+ f"occurrence selector dropdown. I need to select the meeting "
1398
+ f"occurrence from {config_date} ({target_label}). "
1399
+ f"Look at all visible dates and find the one matching "
1400
+ f"{target_label}.{time_instruction} "
1401
+ f"Count the dropdown items from top to bottom starting at 0. "
1402
+ f'Return JSON: {{"index": <n>}} for the correct occurrence, '
1403
+ f'or {{"index": null}} if none match.'
1404
+ )
1405
+
1406
+ logger.log("step5", "Asking agent to identify correct occurrence...")
1407
+ agent_result = agent.vision_query(screenshot_b64, prompt)
1408
+ logger.log("step5", f"Agent response: {agent_result}")
1409
+
1410
+ if not agent_result or agent_result.get("index") is None:
1411
+ logger.log("step5", "Agent could not identify occurrence.")
1412
+ await _dismiss_dropdown(browser)
1413
+ return False
1414
+
1415
+ occurrence_index = agent_result["index"]
1416
+ if not isinstance(occurrence_index, int) or occurrence_index < 0:
1417
+ logger.log("step5", f"Agent returned invalid occurrence index: {occurrence_index!r}")
1418
+ await _dismiss_dropdown(browser)
1419
+ return False
1420
+
1421
+ # Click the correct dropdown option
1422
+ opt_bbox_json = await browser.evaluate(f"""
1423
+ (function() {{
1424
+ var items = Array.from(document.querySelectorAll(
1425
+ '[role="menuitem"], [role="option"], '
1426
+ + '[role="listbox"] > *, [role="menu"] > *'
1427
+ ));
1428
+ var dateItems = items.filter(function(el) {{
1429
+ var months = 'January|February|March|April|May|June'
1430
+ + '|July|August|September|October|November|December';
1431
+ var re = new RegExp(
1432
+ '\\\\d{{1,2}}\\\\s+(' + months + ')', 'i'
1433
+ );
1434
+ return re.test(el.textContent);
1435
+ }});
1436
+ if (dateItems.length > {occurrence_index}) {{
1437
+ var rect = dateItems[{occurrence_index}].getBoundingClientRect();
1438
+ return JSON.stringify({{
1439
+ x: Math.round(rect.left + rect.width / 2),
1440
+ y: Math.round(rect.top + rect.height / 2),
1441
+ text: dateItems[{occurrence_index}]
1442
+ .textContent.trim().substring(0, 80)
1443
+ }});
1444
+ }}
1445
+ if (items.length > {occurrence_index}) {{
1446
+ var rect = items[{occurrence_index}].getBoundingClientRect();
1447
+ return JSON.stringify({{
1448
+ x: Math.round(rect.left + rect.width / 2),
1449
+ y: Math.round(rect.top + rect.height / 2),
1450
+ text: items[{occurrence_index}]
1451
+ .textContent.trim().substring(0, 80)
1452
+ }});
1453
+ }}
1454
+ return JSON.stringify({{error: 'not_found'}});
1455
+ }})()
1456
+ """)
1457
+ opt_bbox = json.loads(str(opt_bbox_json))
1458
+
1459
+ if opt_bbox.get("error"):
1460
+ logger.log(
1461
+ "step5",
1462
+ f"Could not find occurrence option at index {occurrence_index}.",
1463
+ )
1464
+ return False
1465
+
1466
+ logger.log("step5", f"Clicking occurrence: {opt_bbox.get('text', '?')}")
1467
+ await browser.send_command(
1468
+ "Input.dispatchMouseEvent",
1469
+ {
1470
+ "type": "mousePressed",
1471
+ "x": opt_bbox["x"],
1472
+ "y": opt_bbox["y"],
1473
+ "button": "left",
1474
+ "clickCount": 1,
1475
+ },
1476
+ )
1477
+ await browser.send_command(
1478
+ "Input.dispatchMouseEvent",
1479
+ {
1480
+ "type": "mouseReleased",
1481
+ "x": opt_bbox["x"],
1482
+ "y": opt_bbox["y"],
1483
+ "button": "left",
1484
+ "clickCount": 1,
1485
+ },
1486
+ )
1487
+
1488
+ logger.log("step5", "Waiting for occurrence content to load...")
1489
+ await shutdown.interruptible_sleep(4)
1490
+ dur = parse_occurrence_duration(opt_bbox.get("text", ""))
1491
+ if dur:
1492
+ logger.log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
1493
+ return True
1494
+
1495
+
1496
+ # ---------------------------------------------------------------------------
1497
+ # Internal: speaker mapping extraction
1498
+ # ---------------------------------------------------------------------------
1499
+
1500
+
1501
+ async def _extract_speaker_mapping(
1502
+ browser: BrowserPort,
1503
+ logger: Logger,
1504
+ shutdown: ShutdownCoordinator,
1505
+ ) -> dict[str, str]:
1506
+ """Extract speaker GUID-to-displayName mapping from the Recap Speakers tab.
1507
+
1508
+ Clicks the Speakers sub-tab, reads React props of each speaker item.
1509
+ Returns a dict mapping bare GUID to display name.
1510
+ """
1511
+ clicked = await browser.evaluate(r"""
1512
+ (function() {
1513
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1514
+ var speakersTab = tabs.find(function(t) {
1515
+ var txt = t.textContent.trim();
1516
+ return txt === 'Speakers' || txt === 'SpeakersSpeakers';
1517
+ });
1518
+ if (!speakersTab) return 'no_tab';
1519
+ speakersTab.click();
1520
+ return 'clicked';
1521
+ })()
1522
+ """)
1523
+
1524
+ if clicked != "clicked":
1525
+ return {}
1526
+
1527
+ await shutdown.interruptible_sleep(1.5)
1528
+
1529
+ # Click "Show more" if present
1530
+ await browser.evaluate(r"""
1531
+ (function() {
1532
+ var btns = Array.from(document.querySelectorAll('button'));
1533
+ var showMore = btns.find(function(b) {
1534
+ return b.textContent.trim().toLowerCase().indexOf('show more') >= 0
1535
+ || b.textContent.trim().toLowerCase().indexOf('show all') >= 0;
1536
+ });
1537
+ if (showMore) showMore.click();
1538
+ })()
1539
+ """)
1540
+ await asyncio.sleep(0.5)
1541
+
1542
+ # Extract userId + displayName from React props
1543
+ raw = await browser.evaluate(r"""
1544
+ (function() {
1545
+ var items = document.querySelectorAll(
1546
+ '[data-tid="meeting-recap-speakers-list-item"]'
1547
+ );
1548
+ if (!items.length) return JSON.stringify([]);
1549
+
1550
+ var results = [];
1551
+ items.forEach(function(item) {
1552
+ var fiberKey = Object.keys(item).find(function(k) {
1553
+ return k.startsWith('__reactFiber$');
1554
+ });
1555
+ if (!fiberKey) return;
1556
+
1557
+ var current = item[fiberKey];
1558
+ var depth = 0;
1559
+ var userId = null;
1560
+ var displayName = null;
1561
+
1562
+ while (current && depth < 15) {
1563
+ var props = current.memoizedProps || {};
1564
+ if (props.userId && !userId) userId = props.userId;
1565
+ if (props.displayName && !displayName)
1566
+ displayName = props.displayName;
1567
+ if (userId && displayName) break;
1568
+ current = current.return;
1569
+ depth++;
1570
+ }
1571
+
1572
+ if (userId && displayName) {
1573
+ results.push({
1574
+ userId: userId, displayName: displayName
1575
+ });
1576
+ }
1577
+ });
1578
+ return JSON.stringify(results);
1579
+ })()
1580
+ """)
1581
+
1582
+ items = json.loads(str(raw))
1583
+ mapping: dict[str, str] = {}
1584
+ for item in items:
1585
+ uid = item["userId"]
1586
+ guid = uid.split(":")[-1] if ":" in uid else uid
1587
+ mapping[guid] = item["displayName"]
1588
+
1589
+ return mapping
1590
+
1591
+
1592
+ # ---------------------------------------------------------------------------
1593
+ # Internal helpers
1594
+ # ---------------------------------------------------------------------------
1595
+
1596
+
1597
+ async def _dismiss_dropdown(browser: BrowserPort) -> None:
1598
+ """Close an open dropdown by pressing Escape."""
1599
+ await browser.send_command(
1600
+ "Input.dispatchKeyEvent",
1601
+ {
1602
+ "type": "keyDown",
1603
+ "key": "Escape",
1604
+ "code": "Escape",
1605
+ "windowsVirtualKeyCode": 27,
1606
+ },
1607
+ )
1608
+ await browser.send_command(
1609
+ "Input.dispatchKeyEvent",
1610
+ {
1611
+ "type": "keyUp",
1612
+ "key": "Escape",
1613
+ "code": "Escape",
1614
+ "windowsVirtualKeyCode": 27,
1615
+ },
1616
+ )
1617
+
1618
+
1619
+ async def _try_meetings_filter(
1620
+ browser: BrowserPort,
1621
+ logger: Logger,
1622
+ shutdown: ShutdownCoordinator,
1623
+ ) -> tuple[list[dict], str]:
1624
+ """Click the Meetings filter tab and re-extract search results.
1625
+
1626
+ Returns a tuple of ``(search_results, search_mode)``.
1627
+ """
1628
+ logger.log(
1629
+ "step4",
1630
+ "No chat results in panel. Clicking 'Meetings' filter...",
1631
+ )
1632
+ meetings_bbox_json = await browser.evaluate("""
1633
+ (function() {
1634
+ var opts = Array.from(
1635
+ document.querySelectorAll('[role="option"]')
1636
+ );
1637
+ var meetingsOpt = opts.find(function(o) {
1638
+ return o.textContent.trim() === 'Meetings';
1639
+ });
1640
+ if (!meetingsOpt)
1641
+ return JSON.stringify({error: 'not_found'});
1642
+ var rect = meetingsOpt.getBoundingClientRect();
1643
+ return JSON.stringify({
1644
+ x: Math.round(rect.left + rect.width / 2),
1645
+ y: Math.round(rect.top + rect.height / 2)
1646
+ });
1647
+ })()
1648
+ """)
1649
+ meetings_bbox = json.loads(str(meetings_bbox_json))
1650
+
1651
+ if meetings_bbox.get("error"):
1652
+ logger.log("step4", "Could not find 'Meetings' filter tab.")
1653
+ return [], "panel"
1654
+
1655
+ await browser.send_command(
1656
+ "Input.dispatchMouseEvent",
1657
+ {
1658
+ "type": "mousePressed",
1659
+ "x": meetings_bbox["x"],
1660
+ "y": meetings_bbox["y"],
1661
+ "button": "left",
1662
+ "clickCount": 1,
1663
+ },
1664
+ )
1665
+ await browser.send_command(
1666
+ "Input.dispatchMouseEvent",
1667
+ {
1668
+ "type": "mouseReleased",
1669
+ "x": meetings_bbox["x"],
1670
+ "y": meetings_bbox["y"],
1671
+ "button": "left",
1672
+ "clickCount": 1,
1673
+ },
1674
+ )
1675
+ await shutdown.interruptible_sleep(3)
1676
+
1677
+ search_mode = "search_page"
1678
+ results_json = await browser.evaluate("""
1679
+ (function() {
1680
+ var candidates = Array.from(document.querySelectorAll(
1681
+ '[role="listitem"], article, '
1682
+ + '[data-tid*="search-result"], '
1683
+ + '[data-tid*="meeting-result"]'
1684
+ ));
1685
+ if (candidates.length === 0) {
1686
+ candidates = Array.from(
1687
+ document.querySelectorAll('[role="option"]')
1688
+ ).filter(function(o) {
1689
+ var t = o.textContent.trim();
1690
+ var filterRe = /^(Messages|Files|Channels|Group chats|Meetings|Images)$/;
1691
+ return t.length > 10
1692
+ && t !== 'View all results'
1693
+ && !filterRe.test(t)
1694
+ && t.indexOf('Search Filters') === -1
1695
+ && !/in:/.test(t);
1696
+ });
1697
+ }
1698
+ var items = [];
1699
+ for (var i = 0; i < candidates.length; i++) {
1700
+ var text = candidates[i].textContent
1701
+ .trim().substring(0, 120);
1702
+ if (text.length > 5) {
1703
+ items.push({
1704
+ id: candidates[i].id || ('result-' + i),
1705
+ text: text,
1706
+ optionIndex: i
1707
+ });
1708
+ }
1709
+ }
1710
+ return JSON.stringify(items);
1711
+ })()
1712
+ """)
1713
+ search_results = json.loads(str(results_json))
1714
+ logger.log(
1715
+ "step4",
1716
+ f"After Meetings filter: {len(search_results)} result(s):",
1717
+ )
1718
+ for sr in search_results:
1719
+ logger.log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
1720
+
1721
+ return search_results, search_mode