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.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- 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
|