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,3969 @@
1
+ #!/usr/bin/env python3
2
+ """Download .vtt transcript from Microsoft Teams meetings via CDP.
3
+
4
+ This script automates the entire transcript download process using the
5
+ Chrome DevTools Protocol (CDP). It connects to Teams' WebView2 instance,
6
+ navigates to the specified meeting, and downloads the transcript as a .vtt
7
+ file. An LLM agent (Claude on Vertex AI) assists with the two non-deterministic
8
+ steps: selecting the correct search result and disambiguating iframe targets.
9
+
10
+ Two download approaches are supported:
11
+ A) Native blob interception (when download permission exists)
12
+ B) DOM extraction from the virtualised transcript list (when blocked)
13
+
14
+ The approach is auto-detected based on the Download button state.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import contextlib
22
+ import json
23
+ import os
24
+ import re
25
+ import signal
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ import typing
30
+ import urllib.request
31
+ from collections import Counter
32
+ from datetime import date
33
+
34
+ import websockets
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Exit codes
38
+ # ---------------------------------------------------------------------------
39
+
40
+ EXIT_OK = 0
41
+ EXIT_BAD_ARGS = 1 # Invalid CLI arguments or config
42
+ EXIT_CDP_UNAVAILABLE = 2 # Teams/CDP not running or not responding
43
+ EXIT_MEETING_NOT_FOUND = 3 # Meeting search failed after all retries
44
+ EXIT_DOWNLOAD_FAILED = 4 # Transcript download or permission error
45
+ EXIT_VERIFICATION_FAILED = 5 # Output file invalid
46
+ EXIT_INTERRUPTED = 130 # SIGINT (128 + 2)
47
+ EXIT_TERMINATED = 143 # SIGTERM (128 + 15)
48
+
49
+ # Module-level output control flags, set by resolve_config().
50
+ _quiet = False
51
+ _verbose = False
52
+ _format_json = False
53
+
54
+ # Signal handling state. Set by _handle_signal(); checked in loops.
55
+ _shutdown_requested = False
56
+ _shutdown_signal: int | None = None
57
+
58
+
59
+ def _handle_signal(signum: int, _frame: object) -> None:
60
+ """Handle SIGINT/SIGTERM by setting the shutdown flag.
61
+
62
+ The first signal sets the flag so that the next loop iteration exits
63
+ cleanly. A second signal (e.g. impatient Ctrl+C) terminates
64
+ immediately.
65
+ """
66
+ global _shutdown_requested, _shutdown_signal
67
+
68
+ if _shutdown_requested:
69
+ # Second signal — force exit.
70
+ sys.exit(128 + signum)
71
+
72
+ _shutdown_requested = True
73
+ _shutdown_signal = signum
74
+ sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
75
+ print(
76
+ f"[{sig_name}] Shutdown requested, finishing current step...",
77
+ file=sys.stderr,
78
+ flush=True,
79
+ )
80
+
81
+
82
+ def _check_shutdown() -> None:
83
+ """If a shutdown signal has been received, exit with the appropriate code.
84
+
85
+ Call this at the top of long-running loop iterations and between
86
+ pipeline stages.
87
+ """
88
+ if not _shutdown_requested:
89
+ return
90
+
91
+ signum = _shutdown_signal or signal.SIGINT
92
+ code = EXIT_INTERRUPTED if signum == signal.SIGINT else EXIT_TERMINATED
93
+ error_code = "INTERRUPTED" if signum == signal.SIGINT else "TERMINATED"
94
+ sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
95
+
96
+ _exit_error(code, error_code, f"Process terminated by {sig_name}.")
97
+
98
+
99
+ async def _interruptible_sleep(seconds: float) -> None:
100
+ """Sleep in 0.5 s increments, checking for shutdown between each."""
101
+ remaining = seconds
102
+ while remaining > 0:
103
+ chunk = min(remaining, 0.5)
104
+ await asyncio.sleep(chunk)
105
+ remaining -= chunk
106
+ if _shutdown_requested:
107
+ _check_shutdown()
108
+
109
+
110
+ def _interruptible_sleep_sync(seconds: float) -> None:
111
+ """Synchronous equivalent of :func:`_interruptible_sleep`."""
112
+ remaining = seconds
113
+ while remaining > 0:
114
+ chunk = min(remaining, 0.5)
115
+ time.sleep(chunk)
116
+ remaining -= chunk
117
+ if _shutdown_requested:
118
+ _check_shutdown()
119
+
120
+
121
+ def _install_signal_handlers() -> None:
122
+ """Register signal handlers for SIGINT and SIGTERM."""
123
+ signal.signal(signal.SIGINT, _handle_signal)
124
+ signal.signal(signal.SIGTERM, _handle_signal)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Argument parsing and config resolution
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def parse_args() -> argparse.Namespace:
133
+ p = argparse.ArgumentParser(
134
+ description=(
135
+ "Download a Microsoft Teams meeting transcript as a WebVTT (.vtt) file.\n"
136
+ "\n"
137
+ "Connects to the Teams desktop app via the Chrome DevTools Protocol (CDP),\n"
138
+ "navigates to the specified meeting's Recap > Transcript tab, and extracts\n"
139
+ "the transcript. An LLM agent (Claude via Vertex AI) assists with\n"
140
+ "non-deterministic UI steps such as search result selection and iframe\n"
141
+ "disambiguation.\n"
142
+ "\n"
143
+ "Two download approaches are supported and auto-detected:\n"
144
+ " A) Native blob interception -- used when you have download permission.\n"
145
+ " The script clicks the Download button and intercepts the resulting\n"
146
+ " blob URL to capture the VTT content byte-for-byte.\n"
147
+ " B) React fibre tree extraction -- used when download is blocked.\n"
148
+ " The script walks the React component tree to extract all transcript\n"
149
+ " entries directly from the in-memory data structure.\n"
150
+ "\n"
151
+ "Speaker names are resolved using the Recap Speakers tab. Vendor-prefixed\n"
152
+ "names (e.g. 'v-Jane Smith (Acme)') and synthetic placeholders (e.g. '@1')\n"
153
+ "are replaced with clean display names (e.g. 'Jane Smith', 'Speaker 1')."
154
+ ),
155
+ formatter_class=argparse.RawDescriptionHelpFormatter,
156
+ epilog=(
157
+ "prerequisites:\n"
158
+ " 1. Microsoft Teams desktop app (macOS) must be running with CDP enabled.\n"
159
+ " The script will set the required flags and relaunch Teams automatically\n"
160
+ " if CDP is not responding on the configured port. To launch manually:\n"
161
+ " export WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="
162
+ "'--remote-debugging-port=9333'\n"
163
+ " open -a 'Microsoft Teams'\n"
164
+ "\n"
165
+ " 2. Google Cloud Application Default Credentials (ADC) must be configured\n"
166
+ " for the Vertex AI project. The simplest way:\n"
167
+ " gcloud auth application-default login\n"
168
+ " Or point --adc / GOOGLE_ADC at a service account key file.\n"
169
+ "\n"
170
+ " 3. Python dependencies (websockets, anthropic[vertex]) are managed\n"
171
+ " by uv. Run 'uv sync' to install from the lockfile.\n"
172
+ "\n"
173
+ "environment variables (used as fallbacks when CLI flags are omitted):\n"
174
+ " GOOGLE_CLOUD_PROJECT GCP project ID for Vertex AI (required if\n"
175
+ " --gcp-project is not provided)\n"
176
+ " CLOUD_ML_REGION Vertex AI region (default: 'global')\n"
177
+ " MODEL_ID Anthropic model ID (default: 'claude-haiku-4-5')\n"
178
+ " GOOGLE_ADC Path to ADC JSON credentials file\n"
179
+ "\n"
180
+ "examples:\n"
181
+ " # Basic usage -- download a single-occurrence meeting transcript\n"
182
+ " %(prog)s \\\n"
183
+ " --meeting 'Project Kickoff' \\\n"
184
+ " --datetime 2026-04-20 \\\n"
185
+ " --output ./transcripts/kickoff.vtt \\\n"
186
+ " --gcp-project my-vertex-project\n"
187
+ "\n"
188
+ " # Recurring meeting -- the script finds the correct occurrence by date\n"
189
+ " %(prog)s \\\n"
190
+ " --meeting 'Weekly Standup' \\\n"
191
+ " --datetime 2026-04-14 \\\n"
192
+ " --output ./transcripts/standup-2026-04-14.vtt\n"
193
+ "\n"
194
+ " # Disambiguate two meetings with the same name on the same day\n"
195
+ " %(prog)s \\\n"
196
+ " --meeting 'Weekly Standup' \\\n"
197
+ " --datetime 2026-04-14T14:00 \\\n"
198
+ " --output ./transcripts/standup-afternoon.vtt\n"
199
+ "\n"
200
+ " # Pipe VTT to stdout (no file written)\n"
201
+ " %(prog)s \\\n"
202
+ " --meeting 'Project Kickoff' \\\n"
203
+ " --datetime 2026-04-20 | less\n"
204
+ "\n"
205
+ " # JSON summary with file path\n"
206
+ " %(prog)s \\\n"
207
+ " --meeting 'Project Kickoff' \\\n"
208
+ " --datetime 2026-04-20 \\\n"
209
+ " --output ./kickoff.vtt --format json\n"
210
+ "\n"
211
+ " # JSON with embedded VTT (no file, agent-friendly)\n"
212
+ " %(prog)s \\\n"
213
+ " --meeting 'Project Kickoff' \\\n"
214
+ " --datetime 2026-04-20 --format json\n"
215
+ "\n"
216
+ " # Dry-run -- check meeting exists without downloading\n"
217
+ " %(prog)s \\\n"
218
+ " --meeting 'Weekly Standup' \\\n"
219
+ " --datetime 2026-04-14 --dry-run --format json\n"
220
+ "\n"
221
+ " # Quiet mode for cron or agent use\n"
222
+ " %(prog)s \\\n"
223
+ " --meeting 'Weekly Standup' \\\n"
224
+ " --datetime 2026-04-14 \\\n"
225
+ " --output ./standup.vtt -q\n"
226
+ "\n"
227
+ " # Use a specific model and region\n"
228
+ " %(prog)s \\\n"
229
+ " --meeting 'Quarterly Review' \\\n"
230
+ " --datetime 2026-03-31 \\\n"
231
+ " --output ./review.vtt \\\n"
232
+ " --model claude-sonnet-4-20250514 \\\n"
233
+ " --gcp-region us-east5\n"
234
+ "\n"
235
+ "how it works:\n"
236
+ " Step 1 Verify Teams is running with CDP enabled (relaunch if needed)\n"
237
+ " Step 2 Confirm CDP is responding on the configured port\n"
238
+ " Step 3 Discover Teams page targets via CDP\n"
239
+ " Step 4 Search for the meeting by name and navigate to its chat\n"
240
+ " Step 5 Open Recap, select the correct date occurrence, extract\n"
241
+ " speaker names, then switch to the Transcript tab\n"
242
+ " Step 6 Locate the transcript iframe target\n"
243
+ " Step 7 Download the transcript (Approach A or B, auto-detected)\n"
244
+ " Step 8 Validate the output VTT file\n"
245
+ "\n"
246
+ "notes:\n"
247
+ " - The meeting name must match what appears in Teams search results.\n"
248
+ " Partial matches work as long as the name is unambiguous.\n"
249
+ " - For recurring meetings, the occurrence dropdown must contain the\n"
250
+ " target date. Teams typically retains the most recent ~5 occurrences.\n"
251
+ " Older occurrences may no longer be available.\n"
252
+ " - The script retries up to 4 times with different search results if\n"
253
+ " the first result does not contain the target date.\n"
254
+ " - Output file is overwritten if it already exists.\n"
255
+ " - --force-restart bypasses the live-call safeguard and may interrupt\n"
256
+ " an active call. Use it only when interruption is explicitly intended.\n"
257
+ " - All diagnostic output goes to stderr. stdout carries only data\n"
258
+ " (VTT content, or JSON when --format json is set).\n"
259
+ " - In JSON mode, the 'file' key is present when --output is given;\n"
260
+ " the 'vtt' key is present when --output is omitted (containing the\n"
261
+ " full VTT content as a string). The two keys are mutually exclusive.\n"
262
+ "\n"
263
+ "exit codes:\n"
264
+ " 0 Success\n"
265
+ " 1 Invalid arguments or missing configuration (BAD_ARGS)\n"
266
+ " 2 Teams/CDP not running or not responding (CDP_UNAVAILABLE)\n"
267
+ " 3 Meeting not found after all retries (MEETING_NOT_FOUND)\n"
268
+ " 4 Transcript download failed (DOWNLOAD_FAILED)\n"
269
+ " 5 Output file verification failed (VERIFICATION_FAILED)\n"
270
+ " 130 Interrupted by SIGINT (Ctrl+C)\n"
271
+ " 143 Terminated by SIGTERM\n"
272
+ "\n"
273
+ "output modes:\n"
274
+ " --output PATH --format text File written, nothing on stdout\n"
275
+ " --output PATH --format json File written, JSON summary on stdout\n"
276
+ " (no --output) --format text VTT content on stdout\n"
277
+ " (no --output) --format json JSON with embedded VTT on stdout\n"
278
+ " --dry-run --format text Summary logged to stderr\n"
279
+ " --dry-run --format json JSON metadata on stdout (no VTT)\n"
280
+ " --dry-run --output PATH Error (incompatible)\n"
281
+ "\n"
282
+ "error codes (machine-stable, used in text prefix and JSON 'code' field):\n"
283
+ " BAD_ARGS Invalid CLI arguments or missing config\n"
284
+ " CDP_UNAVAILABLE Teams/CDP not running or not responding\n"
285
+ " RESTART_BLOCKED Teams restart blocked by active/unknown call state\n"
286
+ " NO_TARGETS No Teams page targets found via CDP\n"
287
+ " SEARCH_FAILED Search results did not appear\n"
288
+ " MEETING_NOT_FOUND Meeting not found in search results\n"
289
+ " RECAP_NOT_FOUND Recap tab not found after navigation\n"
290
+ " TRANSCRIPT_NOT_FOUND Transcript tab not found\n"
291
+ " IFRAME_NOT_FOUND No transcript iframe target\n"
292
+ " DOWNLOAD_FAILED VTT download failed\n"
293
+ " VERIFICATION_FAILED Output file is invalid\n"
294
+ " INTERRUPTED Process interrupted by SIGINT\n"
295
+ " TERMINATED Process terminated by SIGTERM\n"
296
+ ),
297
+ )
298
+ p.add_argument(
299
+ "--meeting",
300
+ required=True,
301
+ metavar="NAME",
302
+ help=(
303
+ "Meeting name to search for in Teams. This is typed into the Teams "
304
+ "search bar, so it should match the meeting title as it appears in the "
305
+ "Teams UI. For best results use the full meeting title."
306
+ ),
307
+ )
308
+ p.add_argument(
309
+ "--datetime",
310
+ required=True,
311
+ metavar="YYYY-MM-DD[THH:MM]",
312
+ dest="datetime",
313
+ help=(
314
+ "Target meeting date, optionally with time, in ISO 8601 format. "
315
+ "Date only (e.g. 2026-04-22) selects the first occurrence on that "
316
+ "date. Date with time (e.g. 2026-04-22T14:00) disambiguates when "
317
+ "there are multiple meetings with the same name on the same day. "
318
+ "The time is matched against the start time shown in the Teams "
319
+ "occurrence dropdown (e.g. '22 April 2026 14:00 - 15:00')."
320
+ ),
321
+ )
322
+ p.add_argument(
323
+ "--output",
324
+ default=None,
325
+ metavar="PATH",
326
+ help=(
327
+ "Output file path for the WebVTT (.vtt) transcript. The file is "
328
+ "created (or overwritten) at this path. Parent directories must "
329
+ "already exist. When omitted, the VTT content is written to "
330
+ "stdout (or embedded in the JSON object when --format json)."
331
+ ),
332
+ )
333
+ p.add_argument(
334
+ "--dry-run",
335
+ action="store_true",
336
+ default=False,
337
+ help=(
338
+ "Run steps 1-5 (find meeting, verify transcript exists, extract "
339
+ "roster) then exit without downloading the transcript. Useful "
340
+ "for validating that a meeting can be found. Incompatible with "
341
+ "--output."
342
+ ),
343
+ )
344
+ p.add_argument(
345
+ "--gcp-project",
346
+ default=None,
347
+ metavar="PROJECT_ID",
348
+ help=(
349
+ "Google Cloud project ID for Vertex AI API calls. Falls back to "
350
+ "the GOOGLE_CLOUD_PROJECT environment variable. A value must be "
351
+ "provided via either this flag or the environment variable."
352
+ ),
353
+ )
354
+ p.add_argument(
355
+ "--port",
356
+ type=int,
357
+ default=9333,
358
+ metavar="PORT",
359
+ help=(
360
+ "CDP remote debugging port that Teams is listening on "
361
+ "(default: %(default)s). Must match the value passed to Teams via "
362
+ "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS."
363
+ ),
364
+ )
365
+ p.add_argument(
366
+ "--force-restart",
367
+ action="store_true",
368
+ default=False,
369
+ help=(
370
+ "Allow Teams to be killed and relaunched even when an active call is "
371
+ "detected or call state cannot be determined. This may interrupt a live call."
372
+ ),
373
+ )
374
+ p.add_argument(
375
+ "--gcp-region",
376
+ default=None,
377
+ metavar="REGION",
378
+ help=(
379
+ "Vertex AI region for model inference (default: 'global'). Falls "
380
+ "back to the CLOUD_ML_REGION environment variable."
381
+ ),
382
+ )
383
+ p.add_argument(
384
+ "--model",
385
+ default=None,
386
+ metavar="MODEL_ID",
387
+ help=(
388
+ "Anthropic model ID for the LLM agent used in non-deterministic "
389
+ "navigation steps (default: 'claude-haiku-4-5'). Falls back to "
390
+ "the MODEL_ID environment variable. Haiku is recommended for speed "
391
+ "and cost; the agent prompts are simple classification tasks."
392
+ ),
393
+ )
394
+ p.add_argument(
395
+ "--adc",
396
+ default=None,
397
+ metavar="PATH",
398
+ help=(
399
+ "Path to a Google Application Default Credentials JSON file. "
400
+ "Falls back to the GOOGLE_ADC environment variable, then to "
401
+ "~/.config/gcloud/application_default_credentials.json."
402
+ ),
403
+ )
404
+
405
+ verbosity = p.add_mutually_exclusive_group()
406
+ verbosity.add_argument(
407
+ "--quiet",
408
+ "-q",
409
+ action="store_true",
410
+ default=False,
411
+ help="Suppress all diagnostic output on stderr. Errors and "
412
+ "structured output (--format json) are still emitted.",
413
+ )
414
+ verbosity.add_argument(
415
+ "--verbose",
416
+ "-v",
417
+ action="store_true",
418
+ default=False,
419
+ help="Emit additional diagnostic detail on stderr (CDP payloads, "
420
+ "agent prompts, React fibre data sizes).",
421
+ )
422
+
423
+ p.add_argument(
424
+ "--format",
425
+ dest="output_format",
426
+ choices=["text", "json"],
427
+ default="text",
428
+ help="Output format on stdout. 'text' (default) emits nothing on "
429
+ "stdout when --output is given, or raw VTT content when --output "
430
+ "is omitted. 'json' emits a structured JSON summary.",
431
+ )
432
+
433
+ return p.parse_args()
434
+
435
+
436
+ def resolve_config(args: argparse.Namespace) -> dict:
437
+ """Resolve all configuration from CLI args, env vars, and defaults."""
438
+ global _quiet, _verbose, _format_json
439
+
440
+ _quiet = args.quiet
441
+ _verbose = args.verbose
442
+ _format_json = args.output_format == "json"
443
+
444
+ project = args.gcp_project or os.environ.get("GOOGLE_CLOUD_PROJECT")
445
+ if not project:
446
+ _exit_error(
447
+ EXIT_BAD_ARGS,
448
+ "BAD_ARGS",
449
+ "--gcp-project not provided and GOOGLE_CLOUD_PROJECT not set.",
450
+ )
451
+
452
+ model = args.model or os.environ.get("MODEL_ID") or "claude-haiku-4-5"
453
+ region = args.gcp_region or os.environ.get("CLOUD_ML_REGION") or "global"
454
+ adc = (
455
+ args.adc
456
+ or os.environ.get("GOOGLE_ADC")
457
+ or os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
458
+ )
459
+
460
+ # Parse --datetime: accept YYYY-MM-DD or YYYY-MM-DDTHH:MM
461
+ dt_raw = args.datetime
462
+ target_time = None
463
+ if "T" in dt_raw:
464
+ date_part, time_part = dt_raw.split("T", 1)
465
+ # Validate date
466
+ try:
467
+ date.fromisoformat(date_part)
468
+ except ValueError:
469
+ _exit_error(
470
+ EXIT_BAD_ARGS,
471
+ "BAD_ARGS",
472
+ f"Invalid date '{date_part}'. Expected YYYY-MM-DD.",
473
+ )
474
+ # Validate time (HH:MM with valid ranges)
475
+ if not re.match(r"^\d{2}:\d{2}$", time_part):
476
+ _exit_error(
477
+ EXIT_BAD_ARGS,
478
+ "BAD_ARGS",
479
+ f"Invalid time format '{time_part}'. Expected HH:MM.",
480
+ )
481
+ hh, mm = time_part.split(":")
482
+ if not (0 <= int(hh) <= 23 and 0 <= int(mm) <= 59):
483
+ _exit_error(
484
+ EXIT_BAD_ARGS,
485
+ "BAD_ARGS",
486
+ f"Invalid time '{time_part}'. Hours must be 00-23, minutes 00-59.",
487
+ )
488
+ target_time = time_part
489
+ target_date = date_part
490
+ else:
491
+ try:
492
+ date.fromisoformat(dt_raw)
493
+ except ValueError:
494
+ _exit_error(
495
+ EXIT_BAD_ARGS,
496
+ "BAD_ARGS",
497
+ f"Invalid date '{dt_raw}'. Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
498
+ )
499
+ target_date = dt_raw
500
+
501
+ # Validate flag combinations
502
+ if args.dry_run and args.output:
503
+ _exit_error(
504
+ EXIT_BAD_ARGS,
505
+ "BAD_ARGS",
506
+ "--dry-run and --output are incompatible. Dry-run does not produce a file.",
507
+ )
508
+
509
+ # When --output is omitted (and not --dry-run), use a temp file
510
+ # internally so the file-based pipeline works unchanged. The temp
511
+ # file content is written to stdout (or embedded in JSON) at the end.
512
+ output_to_stdout = not args.dry_run and args.output is None
513
+ if output_to_stdout:
514
+ tmp_fd, output_path = tempfile.mkstemp(suffix=".vtt")
515
+ os.close(tmp_fd)
516
+ else:
517
+ output_path = args.output # None when --dry-run
518
+
519
+ return {
520
+ "meeting": args.meeting,
521
+ "date": target_date,
522
+ "time": target_time,
523
+ "output": output_path,
524
+ "output_to_stdout": output_to_stdout,
525
+ "dry_run": args.dry_run,
526
+ "port": args.port,
527
+ "force_restart": args.force_restart,
528
+ "gcp_project": project,
529
+ "gcp_region": region,
530
+ "model": model,
531
+ "adc": adc,
532
+ "output_format": args.output_format,
533
+ "quiet": args.quiet,
534
+ "verbose": args.verbose,
535
+ }
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Logging and structured error output
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ def log(step: str, msg: str) -> None:
544
+ """Print a structured log line to stderr.
545
+
546
+ Suppressed entirely when ``--quiet`` is active. All diagnostic output
547
+ goes to stderr so that stdout is reserved for data (VTT content or JSON).
548
+ """
549
+ if not _quiet:
550
+ print(f"[{step}] {msg}", file=sys.stderr, flush=True)
551
+
552
+
553
+ def _exit_error(
554
+ code: int,
555
+ error_code: str,
556
+ message: str,
557
+ *,
558
+ step: str | None = None,
559
+ ) -> typing.NoReturn:
560
+ """Emit an actionable error and terminate the process.
561
+
562
+ * Always prints a human-readable message to stderr.
563
+ * When ``--format json`` is active, also emits a JSON error object to
564
+ stdout so that calling agents can parse the failure without scraping
565
+ stderr.
566
+ * ``error_code`` is a short, machine-stable identifier (e.g.
567
+ ``MEETING_NOT_FOUND``).
568
+ """
569
+ # Human-readable on stderr
570
+ prefix = f"[{error_code}]"
571
+ print(f"{prefix} {message}", file=sys.stderr, flush=True)
572
+
573
+ # Machine-readable on stdout when JSON output is requested
574
+ if _format_json:
575
+ payload: dict[str, object] = {
576
+ "status": "error",
577
+ "code": error_code,
578
+ "message": message,
579
+ }
580
+ if step:
581
+ payload["step"] = step
582
+ print(json.dumps(payload, ensure_ascii=False), flush=True)
583
+
584
+ sys.exit(code)
585
+
586
+
587
+ # ---------------------------------------------------------------------------
588
+ # CDP HTTP helpers
589
+ # ---------------------------------------------------------------------------
590
+
591
+
592
+ def cdp_http_get(port: int, path: str) -> dict | list | None:
593
+ """GET a CDP HTTP endpoint, return parsed JSON or None on failure."""
594
+ url = f"http://localhost:{port}{path}"
595
+ try:
596
+ with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
597
+ return json.loads(resp.read())
598
+ except Exception:
599
+ return None
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # CDP WebSocket helpers
604
+ # ---------------------------------------------------------------------------
605
+
606
+
607
+ class CDPConnection:
608
+ """Manages a CDP WebSocket connection with message ID tracking."""
609
+
610
+ def __init__(self, ws):
611
+ self._ws = ws
612
+ self._msg_id = 0
613
+
614
+ async def send(self, method: str, params: dict | None = None, timeout: float = 15):
615
+ """Send a CDP command and wait for the matching response."""
616
+ self._msg_id += 1
617
+ mid = self._msg_id
618
+ await self._ws.send(
619
+ json.dumps(
620
+ {
621
+ "id": mid,
622
+ "method": method,
623
+ "params": params or {},
624
+ }
625
+ )
626
+ )
627
+ while True:
628
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
629
+ data = json.loads(raw)
630
+ if "id" in data and data["id"] == mid:
631
+ return data
632
+
633
+ async def evaluate(self, expression: str, timeout: float = 15):
634
+ """Shorthand for Runtime.evaluate, returns the result value."""
635
+ resp = await self.send(
636
+ "Runtime.evaluate",
637
+ {"expression": expression},
638
+ timeout=timeout,
639
+ )
640
+ return resp.get("result", {}).get("result", {}).get("value")
641
+
642
+ async def drain(self, timeout: float = 0.5):
643
+ """Drain any pending messages from the WebSocket."""
644
+ try:
645
+ while True:
646
+ await asyncio.wait_for(self._ws.recv(), timeout=timeout)
647
+ except (asyncio.TimeoutError, TimeoutError):
648
+ pass
649
+
650
+ async def screenshot(self) -> str:
651
+ """Take a screenshot via CDP, return base64 PNG data."""
652
+ resp = await self.send("Page.captureScreenshot", {"format": "png"})
653
+ return resp.get("result", {}).get("data", "")
654
+
655
+
656
+ async def connect_to_target(port: int, target_id: str) -> tuple:
657
+ """Connect to a CDP target, return (websocket, CDPConnection)."""
658
+ ws_url = f"ws://localhost:{port}/devtools/page/{target_id}"
659
+ ws = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
660
+ return ws, CDPConnection(ws)
661
+
662
+
663
+ # ---------------------------------------------------------------------------
664
+ # Agent (LLM) helpers
665
+ # ---------------------------------------------------------------------------
666
+
667
+
668
+ def create_agent_client(config: dict):
669
+ """Create an AnthropicVertex client from resolved config."""
670
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = config["adc"]
671
+ from anthropic import AnthropicVertex
672
+
673
+ return AnthropicVertex(
674
+ project_id=config["gcp_project"],
675
+ region=config["gcp_region"],
676
+ )
677
+
678
+
679
+ def agent_vision_query(client, model: str, image_b64: str, prompt: str) -> dict | None:
680
+ """Send a single-turn vision query to the agent. Returns parsed JSON or None."""
681
+ try:
682
+ response = client.messages.create(
683
+ model=model,
684
+ max_tokens=512,
685
+ messages=[
686
+ {
687
+ "role": "user",
688
+ "content": [
689
+ {
690
+ "type": "image",
691
+ "source": {
692
+ "type": "base64",
693
+ "media_type": "image/png",
694
+ "data": image_b64,
695
+ },
696
+ },
697
+ {"type": "text", "text": prompt},
698
+ ],
699
+ }
700
+ ],
701
+ )
702
+ text = response.content[0].text.strip()
703
+ # Extract JSON from response (may be wrapped in markdown code fences)
704
+ json_match = re.search(r"\{[^}]+\}", text)
705
+ if json_match:
706
+ return json.loads(json_match.group())
707
+ return None
708
+ except Exception as e:
709
+ log("agent", f"Vision query failed: {e}")
710
+ return None
711
+
712
+
713
+ def agent_text_query(client, model: str, prompt: str) -> dict | None:
714
+ """Send a single-turn text query to the agent. Returns parsed JSON or None."""
715
+ try:
716
+ response = client.messages.create(
717
+ model=model,
718
+ max_tokens=512,
719
+ messages=[{"role": "user", "content": prompt}],
720
+ )
721
+ text = response.content[0].text.strip()
722
+ json_match = re.search(r"\{[^}]+\}", text)
723
+ if json_match:
724
+ return json.loads(json_match.group())
725
+ return None
726
+ except Exception as e:
727
+ log("agent", f"Text query failed: {e}")
728
+ return None
729
+
730
+
731
+ # ---------------------------------------------------------------------------
732
+ # Step 1: Ensure Teams is running with CDP enabled
733
+ # ---------------------------------------------------------------------------
734
+
735
+
736
+ def ensure_teams_running(port: int, *, force: bool = False) -> None:
737
+ """Use the shared guarded restart path when CDP is unavailable."""
738
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running as guarded_ensure
739
+
740
+ class LegacyLogger:
741
+ @staticmethod
742
+ def log(step: str, msg: str) -> None:
743
+ log(step, msg)
744
+
745
+ class LegacyShutdown:
746
+ interruptible_sleep_sync = staticmethod(_interruptible_sleep_sync)
747
+
748
+ guarded_ensure(
749
+ port,
750
+ logger=LegacyLogger(),
751
+ shutdown=LegacyShutdown(),
752
+ format_json=_format_json,
753
+ force=force,
754
+ )
755
+
756
+
757
+ # ---------------------------------------------------------------------------
758
+ # Step 2: Verify CDP is responding
759
+ # ---------------------------------------------------------------------------
760
+
761
+
762
+ def verify_cdp(port: int) -> str:
763
+ """Verify CDP is responding and return the browser version string."""
764
+ for attempt in range(5):
765
+ _check_shutdown()
766
+ version = cdp_http_get(port, "/json/version")
767
+ if isinstance(version, dict) and "Browser" in version:
768
+ browser = version["Browser"]
769
+ log("step2", f"CDP verified: {browser}")
770
+ return browser
771
+ log("step2", f"Attempt {attempt + 1}/5 -- waiting...")
772
+ _interruptible_sleep_sync(3)
773
+
774
+ _exit_error(
775
+ EXIT_CDP_UNAVAILABLE,
776
+ "CDP_UNAVAILABLE",
777
+ "CDP did not respond after 5 attempts. Check Teams is running.",
778
+ step="step2",
779
+ )
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Step 3: Find main Teams CDP target
784
+ # ---------------------------------------------------------------------------
785
+
786
+
787
+ def find_teams_targets(port: int) -> list[dict]:
788
+ """Return all CDP targets that are main Teams pages (not workers)."""
789
+ targets = cdp_http_get(port, "/json")
790
+ if not targets:
791
+ _exit_error(
792
+ EXIT_CDP_UNAVAILABLE,
793
+ "CDP_UNAVAILABLE",
794
+ "Could not list CDP targets.",
795
+ step="step3",
796
+ )
797
+
798
+ results = []
799
+ for t in targets:
800
+ url = t.get("url", "")
801
+ if "teams.microsoft.com/v2" in url and "worker" not in url:
802
+ results.append(t)
803
+ return results
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # Step 4: Navigate to meeting (agent-assisted)
808
+ # ---------------------------------------------------------------------------
809
+
810
+
811
+ async def find_search_target(port: int, targets: list[dict]) -> str | None:
812
+ """Find which CDP target has the Teams search input element."""
813
+ for t in targets:
814
+ try:
815
+ ws, cdp = await connect_to_target(port, t["id"])
816
+ async with ws:
817
+ result = await cdp.evaluate("!!document.querySelector('#ms-searchux-input')")
818
+ if result is True:
819
+ log("step4", f"Search input found on target: {t['id'][:20]}...")
820
+ return t["id"]
821
+ except Exception: # noqa: S112, PERF203
822
+ continue
823
+ return None
824
+
825
+
826
+ async def type_text_via_cdp(cdp: CDPConnection, text: str) -> None:
827
+ """Type text character by character using Input.dispatchKeyEvent."""
828
+ for char in text:
829
+ await cdp.send(
830
+ "Input.dispatchKeyEvent",
831
+ {
832
+ "type": "keyDown",
833
+ "text": char,
834
+ "key": char,
835
+ "code": f"Key{char.upper()}" if char.isalpha() else "",
836
+ "windowsVirtualKeyCode": ord(char.upper()) if char.isalpha() else ord(char),
837
+ },
838
+ )
839
+ await cdp.send(
840
+ "Input.dispatchKeyEvent",
841
+ {
842
+ "type": "keyUp",
843
+ "key": char,
844
+ "code": f"Key{char.upper()}" if char.isalpha() else "",
845
+ "windowsVirtualKeyCode": ord(char.upper()) if char.isalpha() else ord(char),
846
+ },
847
+ )
848
+ await asyncio.sleep(0.05)
849
+
850
+
851
+ async def navigate_to_meeting(
852
+ port: int,
853
+ targets: list[dict],
854
+ config: dict,
855
+ agent_client,
856
+ result_index_override: int | None = None,
857
+ ) -> str | None:
858
+ """Navigate to the meeting chat. Returns the target ID used, or None
859
+ if the requested result_index_override is out of range.
860
+
861
+ If *result_index_override* is given, skip the agent call and click
862
+ the search result at that index directly (used for retries when the
863
+ first chat group turns out to be the wrong recurring-meeting instance).
864
+ """
865
+ # 4a: Find the target with the search input
866
+ search_target_id = await find_search_target(port, targets)
867
+ if not search_target_id:
868
+ _exit_error(
869
+ EXIT_CDP_UNAVAILABLE,
870
+ "NO_TARGETS",
871
+ "Could not find Teams search input on any CDP target.",
872
+ step="step4",
873
+ )
874
+
875
+ ws, cdp = await connect_to_target(port, search_target_id)
876
+ async with ws:
877
+ # 4b: Ensure we are in the Chat view (not Search results or a
878
+ # meeting chat) so the search suggestion popup works correctly.
879
+ # Use the Ctrl+3 keyboard shortcut (Teams: "Chat (Ctrl+3)").
880
+ log("step4", "Switching to Chat view (Ctrl+3)...")
881
+ await cdp.send(
882
+ "Input.dispatchKeyEvent",
883
+ {
884
+ "type": "keyDown",
885
+ "key": "3",
886
+ "code": "Digit3",
887
+ "windowsVirtualKeyCode": 51,
888
+ "modifiers": 2, # Ctrl
889
+ },
890
+ )
891
+ await cdp.send(
892
+ "Input.dispatchKeyEvent",
893
+ {
894
+ "type": "keyUp",
895
+ "key": "3",
896
+ "code": "Digit3",
897
+ "windowsVirtualKeyCode": 51,
898
+ },
899
+ )
900
+ await _interruptible_sleep(2)
901
+
902
+ # Dismiss any open popups/dropdowns
903
+ await cdp.send(
904
+ "Input.dispatchKeyEvent",
905
+ {
906
+ "type": "keyDown",
907
+ "key": "Escape",
908
+ "code": "Escape",
909
+ "windowsVirtualKeyCode": 27,
910
+ },
911
+ )
912
+ await cdp.send(
913
+ "Input.dispatchKeyEvent",
914
+ {
915
+ "type": "keyUp",
916
+ "key": "Escape",
917
+ "code": "Escape",
918
+ "windowsVirtualKeyCode": 27,
919
+ },
920
+ )
921
+ await asyncio.sleep(0.5)
922
+
923
+ # Focus the search input and clear it using the React-compatible
924
+ # nativeInputValueSetter technique (setting .value directly
925
+ # doesn't update React-controlled inputs).
926
+ await cdp.evaluate("""
927
+ (function() {
928
+ var input = document.querySelector('#ms-searchux-input');
929
+ if (!input) return;
930
+ input.focus();
931
+ var setter = Object.getOwnPropertyDescriptor(
932
+ window.HTMLInputElement.prototype, 'value'
933
+ ).set;
934
+ setter.call(input, '');
935
+ input.dispatchEvent(new Event('input', {bubbles: true}));
936
+ })()
937
+ """)
938
+ await asyncio.sleep(0.5)
939
+
940
+ log("step4", f"Typing meeting name: {config['meeting']}")
941
+ await type_text_via_cdp(cdp, config["meeting"])
942
+
943
+ # 4c: Wait for search results to appear.
944
+ # Teams has TWO search UI modes:
945
+ # Mode A (popup): Quick suggestions as ms-searchux-popup-N elements.
946
+ # Mode B (panel): Expanded search panel with [role="option"] elements
947
+ # that have random IDs (appears after first search or from
948
+ # within a chat context). Shows filter tabs (Messages, Files,
949
+ # etc.) plus result items.
950
+ # We wait until we see either:
951
+ # - ms-searchux-popup-N items that aren't "Search Filters", OR
952
+ # - [role="option"] elements whose text matches the meeting name.
953
+ log("step4", "Waiting for search results...")
954
+ meeting_name_lower = config["meeting"].lower()
955
+ search_mode = None
956
+ for _ in range(20):
957
+ await asyncio.sleep(0.5)
958
+ result = await cdp.evaluate(f"""
959
+ (function() {{
960
+ // Mode A: popup suggestions
961
+ for (var i = 0; i < 10; i++) {{
962
+ var el = document.querySelector('#ms-searchux-popup-' + i);
963
+ if (el) {{
964
+ var text = el.textContent.trim();
965
+ if (text.indexOf('Search Filters') === -1) return 'popup';
966
+ }}
967
+ }}
968
+ // Mode B: expanded search panel option elements
969
+ var opts = Array.from(document.querySelectorAll('[role="option"]'));
970
+ for (var j = 0; j < opts.length; j++) {{
971
+ var t = opts[j].textContent.trim().toLowerCase();
972
+ if (t.indexOf('{meeting_name_lower[:20]}') !== -1) return 'panel';
973
+ }}
974
+ return 'waiting';
975
+ }})()
976
+ """)
977
+ if result in ("popup", "panel"):
978
+ search_mode = result
979
+ break
980
+
981
+ if not search_mode:
982
+ _exit_error(
983
+ EXIT_MEETING_NOT_FOUND,
984
+ "SEARCH_FAILED",
985
+ "Search results did not appear after 10 seconds.",
986
+ step="step4",
987
+ )
988
+
989
+ await _interruptible_sleep(1) # let results fully render
990
+ log("step4", f"Search UI mode: {search_mode}")
991
+
992
+ # 4c-ii: Enumerate available results depending on mode.
993
+ # Build a list of result items as [{id, text, element_selector}].
994
+ if search_mode == "popup":
995
+ results_json = await cdp.evaluate("""
996
+ (function() {
997
+ var items = [];
998
+ for (var i = 0; i < 10; i++) {
999
+ var el = document.querySelector('#ms-searchux-popup-' + i);
1000
+ if (el) {
1001
+ var text = el.textContent.trim().substring(0, 120);
1002
+ if (text.indexOf('Search Filters') === -1) {
1003
+ items.push({
1004
+ id: el.id,
1005
+ text: text,
1006
+ selector: '#' + el.id
1007
+ });
1008
+ }
1009
+ } else {
1010
+ break;
1011
+ }
1012
+ }
1013
+ return JSON.stringify(items);
1014
+ })()
1015
+ """)
1016
+ else:
1017
+ # Panel mode: get [role="option"] elements, excluding filter
1018
+ # tabs (Messages, Files, Channels, etc.), "View all results",
1019
+ # and search suggestions (items containing "in:" filter syntax).
1020
+ filter_words = [
1021
+ "Messages",
1022
+ "Files",
1023
+ "Channels",
1024
+ "Group chats",
1025
+ "Meetings",
1026
+ "Images",
1027
+ "View all results",
1028
+ "Search Filters",
1029
+ ]
1030
+ results_json = await cdp.evaluate(
1031
+ """
1032
+ (function() {
1033
+ var filterWords = """
1034
+ + json.dumps(filter_words)
1035
+ + """;
1036
+ var opts = Array.from(document.querySelectorAll('[role="option"]'));
1037
+ var items = [];
1038
+ for (var i = 0; i < opts.length; i++) {
1039
+ var text = opts[i].textContent.trim().substring(0, 120);
1040
+ var isFilter = false;
1041
+ for (var f = 0; f < filterWords.length; f++) {
1042
+ if (text === filterWords[f] || text.indexOf('Search Filters') !== -1) {
1043
+ isFilter = true;
1044
+ break;
1045
+ }
1046
+ }
1047
+ // Detect search suggestions: items that contain "in:"
1048
+ // filter syntax (e.g. "forin:Channel" or "textin:scope").
1049
+ if (!isFilter && /in:/.test(text)) isFilter = true;
1050
+ if (!isFilter && text.length > 0) {
1051
+ var oid = opts[i].id || ('option-' + i);
1052
+ items.push({
1053
+ id: oid,
1054
+ text: text,
1055
+ selector: opts[i].id ? ('#' + opts[i].id) : null,
1056
+ optionIndex: i
1057
+ });
1058
+ }
1059
+ }
1060
+ return JSON.stringify(items);
1061
+ })()
1062
+ """
1063
+ )
1064
+
1065
+ search_results = json.loads(results_json)
1066
+ log("step4", f"Found {len(search_results)} search result(s):")
1067
+ for sr in search_results:
1068
+ log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
1069
+
1070
+ # If panel mode returned no results (all were filter tabs or
1071
+ # search suggestions), click the "Meetings" filter tab to get
1072
+ # meeting-specific results, then re-extract.
1073
+ if search_mode == "panel" and len(search_results) == 0:
1074
+ log("step4", "No chat results in panel. Clicking 'Meetings' filter...")
1075
+ meetings_bbox_json = await cdp.evaluate("""
1076
+ (function() {
1077
+ var opts = Array.from(document.querySelectorAll('[role="option"]'));
1078
+ var meetingsOpt = opts.find(function(o) {
1079
+ return o.textContent.trim() === 'Meetings';
1080
+ });
1081
+ if (!meetingsOpt) return JSON.stringify({error: 'not_found'});
1082
+ var rect = meetingsOpt.getBoundingClientRect();
1083
+ return JSON.stringify({
1084
+ x: Math.round(rect.left + rect.width / 2),
1085
+ y: Math.round(rect.top + rect.height / 2)
1086
+ });
1087
+ })()
1088
+ """)
1089
+ meetings_bbox = json.loads(meetings_bbox_json)
1090
+
1091
+ if meetings_bbox.get("error"):
1092
+ log("step4", "Could not find 'Meetings' filter tab.")
1093
+ else:
1094
+ await cdp.send(
1095
+ "Input.dispatchMouseEvent",
1096
+ {
1097
+ "type": "mousePressed",
1098
+ "x": meetings_bbox["x"],
1099
+ "y": meetings_bbox["y"],
1100
+ "button": "left",
1101
+ "clickCount": 1,
1102
+ },
1103
+ )
1104
+ await cdp.send(
1105
+ "Input.dispatchMouseEvent",
1106
+ {
1107
+ "type": "mouseReleased",
1108
+ "x": meetings_bbox["x"],
1109
+ "y": meetings_bbox["y"],
1110
+ "button": "left",
1111
+ "clickCount": 1,
1112
+ },
1113
+ )
1114
+ await _interruptible_sleep(3)
1115
+
1116
+ # Switch to "search_page" mode -- the full search results
1117
+ # page shows results as listitem or article elements.
1118
+ search_mode = "search_page"
1119
+ results_json = await cdp.evaluate("""
1120
+ (function() {
1121
+ // On the search results page, results may be list items,
1122
+ // articles, or divs with data-tid attributes.
1123
+ var candidates = Array.from(document.querySelectorAll(
1124
+ '[role="listitem"], article, '
1125
+ + '[data-tid*="search-result"], '
1126
+ + '[data-tid*="meeting-result"]'
1127
+ ));
1128
+ // Also try any [role="option"] that appeared after filter
1129
+ if (candidates.length === 0) {
1130
+ candidates = Array.from(
1131
+ document.querySelectorAll('[role="option"]')
1132
+ ).filter(function(o) {
1133
+ var t = o.textContent.trim();
1134
+ var filterRe = /^(Messages|Files|Channels|Group chats|Meetings|Images)$/;
1135
+ return t.length > 10
1136
+ && t !== 'View all results'
1137
+ && !filterRe.test(t)
1138
+ && t.indexOf('Search Filters') === -1
1139
+ && !/in:/.test(t);
1140
+ });
1141
+ }
1142
+ var items = [];
1143
+ for (var i = 0; i < candidates.length; i++) {
1144
+ var text = candidates[i].textContent.trim().substring(0, 120);
1145
+ if (text.length > 5) {
1146
+ items.push({
1147
+ id: candidates[i].id || ('result-' + i),
1148
+ text: text,
1149
+ optionIndex: i
1150
+ });
1151
+ }
1152
+ }
1153
+ return JSON.stringify(items);
1154
+ })()
1155
+ """)
1156
+ search_results = json.loads(results_json)
1157
+ log("step4", f"After Meetings filter: {len(search_results)} result(s):")
1158
+ for sr in search_results:
1159
+ log("step4", f" [{sr['id'][:20]}] {sr['text'][:70]}")
1160
+
1161
+ # 4d: Determine which result to click
1162
+ if result_index_override is not None:
1163
+ result_index = result_index_override
1164
+ log("step4", f"Using override result index: {result_index}")
1165
+ elif search_mode == "panel":
1166
+ # In panel mode we already have structured text for each result.
1167
+ # Match by meeting name using word-overlap scoring. Teams'
1168
+ # search panel can inject filter annotations (e.g. "in:") into
1169
+ # the textContent, garbling exact matches. We also use
1170
+ # substring matching to handle concatenated words (e.g.
1171
+ # "CallAdrian" should match the word "Call").
1172
+ name_words = set(re.findall(r"\w+", config["meeting"].lower()))
1173
+ best_idx, best_score = 0, 0
1174
+ for i, sr in enumerate(search_results):
1175
+ result_text_lower = sr["text"].lower()
1176
+ result_words = set(re.findall(r"\w+", result_text_lower))
1177
+ # Exact word overlap
1178
+ overlap = len(name_words & result_words)
1179
+ # Substring match for words not in exact overlap
1180
+ for nw in name_words - result_words:
1181
+ if nw in result_text_lower:
1182
+ overlap += 1
1183
+ # Tiebreaker: prefer results that start with the meeting
1184
+ # name (meeting chat entries) over messages about the
1185
+ # meeting from individuals (which start with "Name:").
1186
+ # Subtract 0.5 if the result text starts with a person's
1187
+ # name followed by a colon (e.g. "Tim: Recap: ...").
1188
+ score = overlap
1189
+ if re.match(r"^[A-Z][a-z]+:", sr["text"]):
1190
+ score -= 0.5
1191
+ if score > best_score:
1192
+ best_score = score
1193
+ best_idx = i
1194
+ result_index = best_idx
1195
+ log(
1196
+ "step4",
1197
+ f"Word-overlap match: index {result_index} (score {best_score}/{len(name_words)})",
1198
+ )
1199
+ else:
1200
+ # Popup mode: use the agent to identify the correct result
1201
+ log("step4", "Taking screenshot of search results...")
1202
+ screenshot_b64 = await cdp.screenshot()
1203
+
1204
+ prompt = (
1205
+ f"This is a screenshot of Microsoft Teams search results. "
1206
+ f"I am looking for the meeting called '{config['meeting']}' "
1207
+ f"from {config['date']}. "
1208
+ f"The search results may include chat entries (showing participant "
1209
+ f"names but not dates) and calendar/meeting entries (showing dates). "
1210
+ f"Chat entries are preferred for transcript downloads. "
1211
+ f"If the exact date is not visible in the results, pick the first "
1212
+ f"chat result whose name matches the meeting name. "
1213
+ f"Count the results from top to bottom starting at 0. "
1214
+ f"Return a JSON object with a single key 'index' set to the 0-based "
1215
+ f"index of the correct result to click, or null if NO result matches "
1216
+ f"the meeting name at all. "
1217
+ f'For example: {{"index": 0}} or {{"index": null}}'
1218
+ )
1219
+ log("step4", "Asking agent to identify correct search result...")
1220
+ agent_result = agent_vision_query(agent_client, config["model"], screenshot_b64, prompt)
1221
+ log("step4", f"Agent response: {agent_result}")
1222
+
1223
+ if not agent_result or agent_result.get("index") is None:
1224
+ _exit_error(
1225
+ EXIT_MEETING_NOT_FOUND,
1226
+ "MEETING_NOT_FOUND",
1227
+ "Agent could not identify the meeting in search results.",
1228
+ step="step4",
1229
+ )
1230
+
1231
+ result_index = agent_result["index"]
1232
+
1233
+ # 4e: Check that the requested index exists
1234
+ if result_index >= len(search_results):
1235
+ if result_index_override is not None:
1236
+ log(
1237
+ "step4",
1238
+ f"Search result index {result_index} out of range "
1239
+ f"(only {len(search_results)} results). No more results to try.",
1240
+ )
1241
+ return None
1242
+ _exit_error(
1243
+ EXIT_MEETING_NOT_FOUND,
1244
+ "MEETING_NOT_FOUND",
1245
+ f"Agent picked index {result_index} but only "
1246
+ f"{len(search_results)} search results exist.",
1247
+ step="step4",
1248
+ )
1249
+
1250
+ target_result = search_results[result_index]
1251
+ target_id_attr = target_result.get("id", "")
1252
+
1253
+ # 4f: Get the bounding box of the result item and click it.
1254
+ # Use getElementById (not querySelector with #id) because some
1255
+ # Teams element IDs start with digits, which breaks CSS selectors.
1256
+ log(
1257
+ "step4",
1258
+ f"Selecting result index {result_index}: {target_result['text'][:60]}...",
1259
+ )
1260
+ if target_id_attr and not target_id_attr.startswith("option-"):
1261
+ # Use getElementById for elements with an actual ID
1262
+ bbox_json = await cdp.evaluate(f"""
1263
+ (function() {{
1264
+ var item = document.getElementById('{target_id_attr}');
1265
+ if (!item) return JSON.stringify({{error: 'not_found'}});
1266
+ var rect = item.getBoundingClientRect();
1267
+ return JSON.stringify({{
1268
+ x: Math.round(rect.left + rect.width / 2),
1269
+ y: Math.round(rect.top + rect.height / 2),
1270
+ id: item.id
1271
+ }});
1272
+ }})()
1273
+ """)
1274
+ else:
1275
+ # Fallback: use nth [role="option"] by optionIndex
1276
+ oi = target_result.get("optionIndex", 0)
1277
+ bbox_json = await cdp.evaluate(f"""
1278
+ (function() {{
1279
+ var opts = Array.from(document.querySelectorAll('[role="option"]'));
1280
+ if (opts.length <= {oi}) return JSON.stringify({{error: 'not_found'}});
1281
+ var item = opts[{oi}];
1282
+ var rect = item.getBoundingClientRect();
1283
+ return JSON.stringify({{
1284
+ x: Math.round(rect.left + rect.width / 2),
1285
+ y: Math.round(rect.top + rect.height / 2),
1286
+ id: item.id || 'option-{oi}'
1287
+ }});
1288
+ }})()
1289
+ """)
1290
+ bbox = json.loads(bbox_json)
1291
+
1292
+ if bbox.get("error"):
1293
+ log("step4", f"Could not find element for result at index {result_index}.")
1294
+ return None
1295
+
1296
+ cx, cy = bbox["x"], bbox["y"]
1297
+ log("step4", f"Clicking at ({cx}, {cy}) on {bbox['id']}...")
1298
+
1299
+ # CDP-level mouse click: mousePressed + mouseReleased
1300
+ await cdp.send(
1301
+ "Input.dispatchMouseEvent",
1302
+ {
1303
+ "type": "mousePressed",
1304
+ "x": cx,
1305
+ "y": cy,
1306
+ "button": "left",
1307
+ "clickCount": 1,
1308
+ },
1309
+ )
1310
+ await cdp.send(
1311
+ "Input.dispatchMouseEvent",
1312
+ {
1313
+ "type": "mouseReleased",
1314
+ "x": cx,
1315
+ "y": cy,
1316
+ "button": "left",
1317
+ "clickCount": 1,
1318
+ },
1319
+ )
1320
+
1321
+ # 4g: Wait for navigation to the meeting chat
1322
+ log("step4", "Waiting for meeting chat to load...")
1323
+ await _interruptible_sleep(5)
1324
+
1325
+ # Verify navigation reached a chat (not the Search results page
1326
+ # or some other non-chat view).
1327
+ new_title = await cdp.evaluate("document.title")
1328
+ log("step4", f"Page title after navigation: {new_title}")
1329
+
1330
+ is_chat = (
1331
+ "| Chat" in new_title
1332
+ or "| Copilot" in new_title
1333
+ or "| Communities" in new_title
1334
+ or new_title.startswith(("Chat |", "Copilot |", "Communities"))
1335
+ )
1336
+ if "Search |" in new_title or not is_chat:
1337
+ log(
1338
+ "step4",
1339
+ "Navigation did not reach a meeting chat. "
1340
+ "Result may be a file, channel, or filter -- not a chat entry.",
1341
+ )
1342
+ return None
1343
+
1344
+ return search_target_id
1345
+
1346
+
1347
+ # ---------------------------------------------------------------------------
1348
+ # Step 5: Navigate to Recap > Transcript
1349
+ # ---------------------------------------------------------------------------
1350
+
1351
+
1352
+ def _parse_occurrence_duration(text: str) -> int | None:
1353
+ """Parse meeting duration in seconds from occurrence dropdown text.
1354
+
1355
+ Expected format: '17 April 2026 19:30 - 20:00' or similar.
1356
+ Returns duration in seconds, or None if unparseable.
1357
+ """
1358
+ m = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", text)
1359
+ if not m:
1360
+ return None
1361
+ start_min = int(m.group(1)) * 60 + int(m.group(2))
1362
+ end_min = int(m.group(3)) * 60 + int(m.group(4))
1363
+ if end_min <= start_min:
1364
+ end_min += 24 * 60 # crosses midnight
1365
+ return (end_min - start_min) * 60
1366
+
1367
+
1368
+ async def navigate_to_transcript(port: int, target_id: str, config: dict, agent_client) -> bool:
1369
+ """Navigate from meeting chat to the Transcript tab inside Recap.
1370
+
1371
+ For recurring meetings, uses the agent to select the correct
1372
+ occurrence from the date dropdown.
1373
+
1374
+ Returns True if navigation succeeded, False if the target date
1375
+ occurrence was not found (caller should try a different search result).
1376
+ """
1377
+ ws, cdp = await connect_to_target(port, target_id)
1378
+ async with ws:
1379
+ # Always attempt to click Recap first, even if Speakers tabs are
1380
+ # visible -- they may be stale from a previous meeting. If a Recap
1381
+ # tab exists (meaning we're at the chat-level tabs), click it.
1382
+ # If only Recap-internal tabs are visible (Speakers, Transcript etc.)
1383
+ # and no top-level Recap tab exists, we're already in the right view.
1384
+ recap_result = await cdp.evaluate("""
1385
+ (function() {
1386
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1387
+ var tabNames = tabs.map(function(t) { return t.textContent.trim(); });
1388
+ // Look for a top-level Recap tab to click
1389
+ var recapTab = tabs.find(function(t) {
1390
+ return t.textContent.indexOf('Recap') !== -1;
1391
+ });
1392
+ if (recapTab) {
1393
+ recapTab.click();
1394
+ return 'clicked_recap';
1395
+ }
1396
+ // No Recap tab -- check if we're already inside Recap view
1397
+ var inRecap = tabNames.some(function(n) {
1398
+ return n.indexOf('Speakers') !== -1;
1399
+ });
1400
+ if (inRecap) return 'already_in_recap';
1401
+ return 'no_recap_tab';
1402
+ })()
1403
+ """)
1404
+ log("step5", f"Recap navigation: {recap_result}")
1405
+
1406
+ if recap_result == "no_recap_tab":
1407
+ _exit_error(
1408
+ EXIT_DOWNLOAD_FAILED,
1409
+ "RECAP_NOT_FOUND",
1410
+ "Recap tab not found. The meeting may not have been recorded.",
1411
+ step="step5",
1412
+ )
1413
+
1414
+ if recap_result == "clicked_recap":
1415
+ log("step5", "Waiting for Recap content to load...")
1416
+ await _interruptible_sleep(3)
1417
+
1418
+ # Check if this is a recurring meeting and select the correct occurrence
1419
+ occurrence_ok = await _select_correct_occurrence(cdp, config, agent_client)
1420
+ if not occurrence_ok:
1421
+ log("step5", "Target date not available in this chat group.")
1422
+ return False
1423
+
1424
+ # Extract meeting date/time from the Recap header.
1425
+ # Both recurring (dropdown button) and single-occurrence meetings
1426
+ # show the date and time range in a leaf SPAN or DIV element
1427
+ # matching "DD Month YYYY HH:MM - HH:MM".
1428
+ meeting_time_text = await cdp.evaluate(r"""
1429
+ (function() {
1430
+ var months = 'January|February|March|April|May|June'
1431
+ + '|July|August|September|October|November|December';
1432
+ var re = new RegExp(
1433
+ '\\d{1,2}\\s+(' + months + ')\\s+\\d{4}\\s+'
1434
+ + '\\d{1,2}:\\d{2}\\s*-\\s*\\d{1,2}:\\d{2}', 'i'
1435
+ );
1436
+ var els = document.querySelectorAll('button, span, div');
1437
+ for (var i = 0; i < els.length; i++) {
1438
+ var t = els[i].textContent.trim();
1439
+ if (re.test(t) && t.length < 60
1440
+ && els[i].children.length === 0) {
1441
+ return t;
1442
+ }
1443
+ }
1444
+ return '';
1445
+ })()
1446
+ """)
1447
+ if meeting_time_text:
1448
+ config["meeting_time_text"] = meeting_time_text
1449
+ log("step5", f"Meeting time: {meeting_time_text}")
1450
+ else:
1451
+ log("step5", "Meeting time text not found in Recap header.")
1452
+
1453
+ # Extract speaker mapping from the Recap Speakers section.
1454
+ # This gives us the display names shown in the Teams UI (e.g.
1455
+ # "Speaker 1" instead of the raw "@1" from the transcript data).
1456
+ speaker_map = await _extract_speaker_mapping(cdp)
1457
+ if speaker_map:
1458
+ config["speaker_map"] = speaker_map
1459
+ log("step5", f"Speaker mapping: {len(speaker_map)} speaker(s)")
1460
+ for guid, name in speaker_map.items():
1461
+ log("step5", f" {guid[:12]}... -> {name}")
1462
+ else:
1463
+ log("step5", "No speaker mapping available (Speakers tab missing or empty).")
1464
+
1465
+ # Click the Transcript tab
1466
+ transcript_result = await cdp.evaluate("""
1467
+ (function() {
1468
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1469
+ var transcriptTab = tabs.find(function(t) {
1470
+ return t.textContent.indexOf('Transcript') !== -1;
1471
+ });
1472
+ if (!transcriptTab) return 'no_transcript_tab';
1473
+ transcriptTab.click();
1474
+ return 'clicked_transcript:' +
1475
+ transcriptTab.getAttribute('aria-selected');
1476
+ })()
1477
+ """)
1478
+ log("step5", f"Transcript navigation: {transcript_result}")
1479
+
1480
+ if transcript_result == "no_transcript_tab":
1481
+ _exit_error(
1482
+ EXIT_DOWNLOAD_FAILED,
1483
+ "TRANSCRIPT_NOT_FOUND",
1484
+ "Transcript tab not found. Transcription may not have been enabled.",
1485
+ step="step5",
1486
+ )
1487
+
1488
+ # Wait for transcript content to load
1489
+ await _interruptible_sleep(3)
1490
+
1491
+ return True
1492
+
1493
+
1494
+ async def _extract_speaker_mapping(cdp: CDPConnection) -> dict[str, str]:
1495
+ """Extract speaker GUID-to-displayName mapping from the Recap Speakers tab.
1496
+
1497
+ Clicks the Speakers sub-tab within Recap, then reads the React props
1498
+ of each ``meeting-recap-speakers-list-item`` element to extract the
1499
+ ``userId`` (format ``8:orgid:{GUID}``) and ``displayName``.
1500
+
1501
+ Returns a dict mapping bare GUID -> displayName (e.g.
1502
+ ``{"1d678236-a80b-...": "Speaker 1"}``). Returns an empty dict if
1503
+ the Speakers tab is not present or has no items.
1504
+ """
1505
+ # Click the Speakers sub-tab
1506
+ clicked = await cdp.evaluate(r"""
1507
+ (function() {
1508
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
1509
+ var speakersTab = tabs.find(function(t) {
1510
+ var txt = t.textContent.trim();
1511
+ return txt === 'Speakers' || txt === 'SpeakersSpeakers';
1512
+ });
1513
+ if (!speakersTab) return 'no_tab';
1514
+ speakersTab.click();
1515
+ return 'clicked';
1516
+ })()
1517
+ """)
1518
+
1519
+ if clicked != "clicked":
1520
+ return {}
1521
+
1522
+ await _interruptible_sleep(1.5)
1523
+
1524
+ # Some meetings may show a truncated speaker list with a "Show more"
1525
+ # button. Click it if present so all speakers are rendered.
1526
+ await cdp.evaluate(r"""
1527
+ (function() {
1528
+ var btns = Array.from(document.querySelectorAll('button'));
1529
+ var showMore = btns.find(function(b) {
1530
+ return b.textContent.trim().toLowerCase().indexOf('show more') >= 0
1531
+ || b.textContent.trim().toLowerCase().indexOf('show all') >= 0;
1532
+ });
1533
+ if (showMore) showMore.click();
1534
+ })()
1535
+ """)
1536
+ await asyncio.sleep(0.5)
1537
+
1538
+ # Extract userId + displayName from React props of each speaker item
1539
+ raw = await cdp.evaluate(r"""
1540
+ (function() {
1541
+ var items = document.querySelectorAll(
1542
+ '[data-tid="meeting-recap-speakers-list-item"]'
1543
+ );
1544
+ if (!items.length) return JSON.stringify([]);
1545
+
1546
+ var results = [];
1547
+ items.forEach(function(item) {
1548
+ var fiberKey = Object.keys(item).find(function(k) {
1549
+ return k.startsWith('__reactFiber$');
1550
+ });
1551
+ if (!fiberKey) return;
1552
+
1553
+ var current = item[fiberKey];
1554
+ var depth = 0;
1555
+ var userId = null;
1556
+ var displayName = null;
1557
+
1558
+ while (current && depth < 15) {
1559
+ var props = current.memoizedProps || {};
1560
+ if (props.userId && !userId) userId = props.userId;
1561
+ if (props.displayName && !displayName) displayName = props.displayName;
1562
+ if (userId && displayName) break;
1563
+ current = current.return;
1564
+ depth++;
1565
+ }
1566
+
1567
+ if (userId && displayName) {
1568
+ results.push({userId: userId, displayName: displayName});
1569
+ }
1570
+ });
1571
+ return JSON.stringify(results);
1572
+ })()
1573
+ """)
1574
+
1575
+ items = json.loads(raw)
1576
+ mapping = {}
1577
+ for item in items:
1578
+ # userId format: "8:orgid:{GUID}" -- extract the GUID
1579
+ uid = item["userId"]
1580
+ guid = uid.split(":")[-1] if ":" in uid else uid
1581
+ mapping[guid] = item["displayName"]
1582
+
1583
+ return mapping
1584
+
1585
+
1586
+ async def _select_correct_occurrence(cdp: CDPConnection, config: dict, agent_client) -> bool:
1587
+ """For recurring meetings, select the correct date occurrence.
1588
+
1589
+ Returns True if the correct occurrence is selected (or it's a single
1590
+ occurrence meeting), False if the target date is not available.
1591
+ """
1592
+ # Check for the occurrence/date dropdown
1593
+ occurrence_info = await cdp.evaluate("""
1594
+ (function() {
1595
+ var months = 'January|February|March|April|May|June'
1596
+ + '|July|August|September|October|November|December';
1597
+ var dateRe = new RegExp(
1598
+ '\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
1599
+ );
1600
+ var dateBtn = null;
1601
+ var allBtns = Array.from(document.querySelectorAll('button'));
1602
+ for (var i = 0; i < allBtns.length; i++) {
1603
+ var text = allBtns[i].textContent.trim();
1604
+ if (dateRe.test(text)) {
1605
+ dateBtn = allBtns[i];
1606
+ break;
1607
+ }
1608
+ }
1609
+ if (!dateBtn) return JSON.stringify({has_dropdown: false});
1610
+ return JSON.stringify({
1611
+ has_dropdown: true,
1612
+ current_text: dateBtn.textContent.trim().substring(0, 100)
1613
+ });
1614
+ })()
1615
+ """)
1616
+ info = json.loads(occurrence_info)
1617
+
1618
+ if not info.get("has_dropdown"):
1619
+ log("step5", "No occurrence dropdown found (single occurrence meeting).")
1620
+ return True
1621
+
1622
+ current_text = info.get("current_text", "")
1623
+ log("step5", f"Occurrence dropdown shows: {current_text}")
1624
+
1625
+ # Check if the target date is already displayed
1626
+ target_date = config["date"]
1627
+ target_time = config["time"]
1628
+ dt = date.fromisoformat(target_date)
1629
+ day_str = str(dt.day)
1630
+ month_str = dt.strftime("%B")
1631
+ year_str = str(dt.year)
1632
+
1633
+ def _occurrence_matches(text: str) -> bool:
1634
+ """Check if an occurrence dropdown text matches the target date/time."""
1635
+ if not (day_str in text and month_str in text and year_str in text):
1636
+ return False
1637
+ if target_time is None:
1638
+ return True
1639
+ # Match the start time in text like "22 April 2026 11:15 - 12:00".
1640
+ # The target_time is "HH:MM" and must appear before the separator.
1641
+ sep_idx = text.find("-")
1642
+ if sep_idx == -1:
1643
+ return target_time in text
1644
+ return target_time in text[:sep_idx]
1645
+
1646
+ if _occurrence_matches(current_text):
1647
+ log("step5", "Correct occurrence already selected.")
1648
+ dur = _parse_occurrence_duration(current_text)
1649
+ if dur:
1650
+ log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
1651
+ return True
1652
+
1653
+ target_label = f"{day_str} {month_str} {year_str}"
1654
+ if target_time:
1655
+ target_label += f" {target_time}"
1656
+ log("step5", f"Need to switch to {target_label}. Opening dropdown...")
1657
+
1658
+ # Click the date dropdown button
1659
+ bbox_json = await cdp.evaluate("""
1660
+ (function() {
1661
+ var months = 'January|February|March|April|May|June'
1662
+ + '|July|August|September|October|November|December';
1663
+ var dateRe = new RegExp(
1664
+ '\\\\d{1,2}\\\\s+(' + months + ')\\\\s+\\\\d{4}', 'i'
1665
+ );
1666
+ var allBtns = Array.from(document.querySelectorAll('button'));
1667
+ for (var i = 0; i < allBtns.length; i++) {
1668
+ var text = allBtns[i].textContent.trim();
1669
+ if (dateRe.test(text)) {
1670
+ var rect = allBtns[i].getBoundingClientRect();
1671
+ return JSON.stringify({
1672
+ x: Math.round(rect.left + rect.width / 2),
1673
+ y: Math.round(rect.top + rect.height / 2)
1674
+ });
1675
+ }
1676
+ }
1677
+ return JSON.stringify({error: 'not_found'});
1678
+ })()
1679
+ """)
1680
+ bbox = json.loads(bbox_json)
1681
+
1682
+ if bbox.get("error"):
1683
+ log("step5", "Could not find date dropdown button.")
1684
+ return False
1685
+
1686
+ await cdp.send(
1687
+ "Input.dispatchMouseEvent",
1688
+ {
1689
+ "type": "mousePressed",
1690
+ "x": bbox["x"],
1691
+ "y": bbox["y"],
1692
+ "button": "left",
1693
+ "clickCount": 1,
1694
+ },
1695
+ )
1696
+ await cdp.send(
1697
+ "Input.dispatchMouseEvent",
1698
+ {
1699
+ "type": "mouseReleased",
1700
+ "x": bbox["x"],
1701
+ "y": bbox["y"],
1702
+ "button": "left",
1703
+ "clickCount": 1,
1704
+ },
1705
+ )
1706
+ await _interruptible_sleep(2)
1707
+
1708
+ # Check if the target date is in the dropdown items (text-based check
1709
+ # before bothering the agent)
1710
+ dropdown_check = await cdp.evaluate(f"""
1711
+ (function() {{
1712
+ var items = Array.from(document.querySelectorAll(
1713
+ '[role="menuitem"], [role="option"], '
1714
+ + '[role="listbox"] > *, [role="menu"] > *'
1715
+ ));
1716
+ var allText = items.map(function(el) {{
1717
+ return el.textContent.trim();
1718
+ }});
1719
+ var hasTarget = allText.some(function(t) {{
1720
+ return t.indexOf('{month_str}') !== -1
1721
+ && t.indexOf('{year_str}') !== -1;
1722
+ }});
1723
+ return JSON.stringify({{
1724
+ count: items.length,
1725
+ has_target_month: hasTarget,
1726
+ items: allText.slice(0, 10)
1727
+ }});
1728
+ }})()
1729
+ """)
1730
+ dropdown_info = json.loads(dropdown_check)
1731
+ log(
1732
+ "step5",
1733
+ f"Dropdown items: {dropdown_info.get('count', 0)}, "
1734
+ f"has target month: {dropdown_info.get('has_target_month', False)}",
1735
+ )
1736
+
1737
+ # Coarse JS check found the target month/year -- now do a precise
1738
+ # Python-side check that also validates day and optional time.
1739
+ dropdown_items = dropdown_info.get("items", [])
1740
+ has_precise_match = dropdown_info.get("has_target_month", False) and any(
1741
+ _occurrence_matches(item) for item in dropdown_items
1742
+ )
1743
+
1744
+ if not has_precise_match:
1745
+ log(
1746
+ "step5",
1747
+ f"Target {target_label} not in dropdown. Available: {dropdown_items[:5]}",
1748
+ )
1749
+ # Close the dropdown
1750
+ await cdp.send(
1751
+ "Input.dispatchKeyEvent",
1752
+ {
1753
+ "type": "keyDown",
1754
+ "key": "Escape",
1755
+ "code": "Escape",
1756
+ "windowsVirtualKeyCode": 27,
1757
+ },
1758
+ )
1759
+ await cdp.send(
1760
+ "Input.dispatchKeyEvent",
1761
+ {
1762
+ "type": "keyUp",
1763
+ "key": "Escape",
1764
+ "code": "Escape",
1765
+ "windowsVirtualKeyCode": 27,
1766
+ },
1767
+ )
1768
+ return False
1769
+
1770
+ # Target month is in the dropdown -- use the agent to pick the exact one
1771
+ screenshot_b64 = await cdp.screenshot()
1772
+
1773
+ time_instruction = ""
1774
+ if target_time:
1775
+ time_instruction = (
1776
+ f" The meeting must start at {target_time} -- match both the date AND the start time."
1777
+ )
1778
+
1779
+ prompt = (
1780
+ f"This is a screenshot of a Microsoft Teams recurring meeting's "
1781
+ f"occurrence selector dropdown. I need to select the meeting "
1782
+ f"occurrence from {config['date']} ({target_label}). "
1783
+ f"Look at all visible dates and find the one matching "
1784
+ f"{target_label}.{time_instruction} "
1785
+ f"Count the dropdown items from top to bottom starting at 0. "
1786
+ f'Return JSON: {{"index": <n>}} for the correct occurrence, '
1787
+ f'or {{"index": null}} if none match.'
1788
+ )
1789
+
1790
+ log("step5", "Asking agent to identify correct occurrence...")
1791
+ agent_result = agent_vision_query(agent_client, config["model"], screenshot_b64, prompt)
1792
+ log("step5", f"Agent response: {agent_result}")
1793
+
1794
+ if not agent_result or agent_result.get("index") is None:
1795
+ log("step5", "Agent could not identify occurrence.")
1796
+ await cdp.send(
1797
+ "Input.dispatchKeyEvent",
1798
+ {
1799
+ "type": "keyDown",
1800
+ "key": "Escape",
1801
+ "code": "Escape",
1802
+ "windowsVirtualKeyCode": 27,
1803
+ },
1804
+ )
1805
+ await cdp.send(
1806
+ "Input.dispatchKeyEvent",
1807
+ {
1808
+ "type": "keyUp",
1809
+ "key": "Escape",
1810
+ "code": "Escape",
1811
+ "windowsVirtualKeyCode": 27,
1812
+ },
1813
+ )
1814
+ return False
1815
+
1816
+ occurrence_index = agent_result["index"]
1817
+
1818
+ # Click the correct dropdown option
1819
+ opt_bbox_json = await cdp.evaluate(f"""
1820
+ (function() {{
1821
+ var items = Array.from(document.querySelectorAll(
1822
+ '[role="menuitem"], [role="option"], '
1823
+ + '[role="listbox"] > *, [role="menu"] > *'
1824
+ ));
1825
+ var dateItems = items.filter(function(el) {{
1826
+ var months = 'January|February|March|April|May|June'
1827
+ + '|July|August|September|October|November|December';
1828
+ var re = new RegExp('\\\\d{{1,2}}\\\\s+(' + months + ')', 'i');
1829
+ return re.test(el.textContent);
1830
+ }});
1831
+ if (dateItems.length > {occurrence_index}) {{
1832
+ var rect = dateItems[{occurrence_index}].getBoundingClientRect();
1833
+ return JSON.stringify({{
1834
+ x: Math.round(rect.left + rect.width / 2),
1835
+ y: Math.round(rect.top + rect.height / 2),
1836
+ text: dateItems[{occurrence_index}].textContent.trim().substring(0, 80)
1837
+ }});
1838
+ }}
1839
+ if (items.length > {occurrence_index}) {{
1840
+ var rect = items[{occurrence_index}].getBoundingClientRect();
1841
+ return JSON.stringify({{
1842
+ x: Math.round(rect.left + rect.width / 2),
1843
+ y: Math.round(rect.top + rect.height / 2),
1844
+ text: items[{occurrence_index}].textContent.trim().substring(0, 80)
1845
+ }});
1846
+ }}
1847
+ return JSON.stringify({{error: 'not_found'}});
1848
+ }})()
1849
+ """)
1850
+ opt_bbox = json.loads(opt_bbox_json)
1851
+
1852
+ if opt_bbox.get("error"):
1853
+ log("step5", f"Could not find occurrence option at index {occurrence_index}.")
1854
+ return False
1855
+
1856
+ log("step5", f"Clicking occurrence: {opt_bbox.get('text', '?')}")
1857
+ await cdp.send(
1858
+ "Input.dispatchMouseEvent",
1859
+ {
1860
+ "type": "mousePressed",
1861
+ "x": opt_bbox["x"],
1862
+ "y": opt_bbox["y"],
1863
+ "button": "left",
1864
+ "clickCount": 1,
1865
+ },
1866
+ )
1867
+ await cdp.send(
1868
+ "Input.dispatchMouseEvent",
1869
+ {
1870
+ "type": "mouseReleased",
1871
+ "x": opt_bbox["x"],
1872
+ "y": opt_bbox["y"],
1873
+ "button": "left",
1874
+ "clickCount": 1,
1875
+ },
1876
+ )
1877
+
1878
+ log("step5", "Waiting for occurrence content to load...")
1879
+ await _interruptible_sleep(4)
1880
+ dur = _parse_occurrence_duration(opt_bbox.get("text", ""))
1881
+ if dur:
1882
+ log("step5", f"Meeting duration: {dur}s ({dur // 60}min)")
1883
+ return True
1884
+
1885
+
1886
+ # ---------------------------------------------------------------------------
1887
+ # Step 6: Find the transcript iframe's CDP target (agent-assisted)
1888
+ # ---------------------------------------------------------------------------
1889
+
1890
+
1891
+ def find_xplatplugins_targets(port: int) -> list[dict]:
1892
+ """Find all xplatplugins SharePoint iframe CDP targets."""
1893
+ targets = cdp_http_get(port, "/json")
1894
+ if not targets:
1895
+ return []
1896
+
1897
+ matches = []
1898
+ for t in targets:
1899
+ url = t.get("url", "")
1900
+ if "sharepoint" in url and "xplatplugins" in url:
1901
+ matches.append(t)
1902
+ return matches
1903
+
1904
+
1905
+ def find_iframe_target(
1906
+ port: int, config: dict, agent_client, pre_nav_iframe_ids: set[str] | None = None
1907
+ ) -> str:
1908
+ """Find the correct xplatplugins iframe target. Uses agent if ambiguous.
1909
+
1910
+ If pre_nav_iframe_ids is provided, prefer targets that appeared AFTER
1911
+ navigation (i.e., not in the pre-existing set).
1912
+ """
1913
+ log("step6", "Looking for transcript iframe target...")
1914
+ pre_nav_iframe_ids = pre_nav_iframe_ids or set()
1915
+
1916
+ # Retry a few times as the iframe may still be loading
1917
+ matches = []
1918
+ new_matches = []
1919
+ for attempt in range(10):
1920
+ _check_shutdown()
1921
+ matches = find_xplatplugins_targets(port)
1922
+ new_matches = [t for t in matches if t["id"] not in pre_nav_iframe_ids]
1923
+ if new_matches:
1924
+ break
1925
+ if matches and attempt >= 5:
1926
+ # After 5 attempts, if we have targets but none are new,
1927
+ # the meeting might reuse an existing iframe
1928
+ log("step6", "No new iframe targets appeared. Using existing targets.")
1929
+ new_matches = matches
1930
+ break
1931
+ label = "new iframe" if matches else "any iframe"
1932
+ log("step6", f"Waiting for {label} target (attempt {attempt + 1}/10)...")
1933
+ _interruptible_sleep_sync(2)
1934
+
1935
+ # Use new targets if available, otherwise fall back to all
1936
+ candidates = new_matches or matches
1937
+
1938
+ if not candidates:
1939
+ _exit_error(
1940
+ EXIT_DOWNLOAD_FAILED,
1941
+ "IFRAME_NOT_FOUND",
1942
+ "No xplatplugins iframe target found. "
1943
+ "Check that Step 5 completed and the Transcript tab loaded.",
1944
+ step="step6",
1945
+ )
1946
+
1947
+ if len(candidates) == 1:
1948
+ tid = candidates[0]["id"]
1949
+ is_new = tid not in pre_nav_iframe_ids
1950
+ log("step6", f"{'New' if is_new else 'Existing'} iframe target: {tid[:20]}...")
1951
+ return tid
1952
+
1953
+ # Multiple targets -- ask the agent
1954
+ log("step6", f"{len(candidates)} iframe targets to choose from. Asking agent...")
1955
+
1956
+ target_list = "\n".join(
1957
+ f" [{i}] ID: {t['id']}, URL: {t['url'][:200]}" for i, t in enumerate(candidates)
1958
+ )
1959
+ prompt = (
1960
+ f"Multiple SharePoint iframe targets were found in a Teams CDP session. "
1961
+ f"I am looking for the transcript of the meeting "
1962
+ f"'{config['meeting']}' on {config['date']}.\n\n"
1963
+ f"Targets:\n{target_list}\n\n"
1964
+ f"Which target most likely corresponds to this meeting? "
1965
+ f"Look at the URL paths for clues (e.g., organiser name, meeting context). "
1966
+ f'Return JSON: {{"target_id": "<full target ID>"}} '
1967
+ f"for the best match."
1968
+ )
1969
+
1970
+ result = agent_text_query(agent_client, config["model"], prompt)
1971
+ log("step6", f"Agent response: {result}")
1972
+
1973
+ if result and result.get("target_id"):
1974
+ # Validate the agent's choice is actually one of our targets
1975
+ for t in candidates:
1976
+ if t["id"] == result["target_id"]:
1977
+ log("step6", f"Agent selected target: {t['id'][:20]}...")
1978
+ return t["id"]
1979
+ # Partial match (agent may have truncated)
1980
+ for t in candidates:
1981
+ if result["target_id"] in t["id"] or t["id"] in result["target_id"]:
1982
+ log("step6", f"Agent selected target (partial match): {t['id'][:20]}...")
1983
+ return t["id"]
1984
+
1985
+ # Fallback: use the last target (most recently created)
1986
+ log("step6", "Agent could not disambiguate. Using most recent target.")
1987
+ return candidates[-1]["id"]
1988
+
1989
+
1990
+ # ---------------------------------------------------------------------------
1991
+ # Step 7: Check download permission
1992
+ # ---------------------------------------------------------------------------
1993
+
1994
+
1995
+ async def check_permission(port: int, target_id: str) -> str:
1996
+ """Check the Download button state. Returns 'has_permission',
1997
+ 'no_permission', or 'no_button'."""
1998
+ ws, cdp = await connect_to_target(port, target_id)
1999
+ async with ws:
2000
+ for attempt in range(5):
2001
+ _check_shutdown()
2002
+ result_json = await cdp.evaluate("""
2003
+ (function() {
2004
+ var btn = document.querySelector('button[aria-haspopup="true"]');
2005
+ if (!btn) return JSON.stringify({state: "no_button"});
2006
+ var text = btn.textContent || "";
2007
+ if (text.indexOf("permission") !== -1)
2008
+ return JSON.stringify({
2009
+ state: "no_permission",
2010
+ message: text.trim().substring(0, 100)
2011
+ });
2012
+ return JSON.stringify({state: "has_permission"});
2013
+ })()
2014
+ """)
2015
+ if result_json:
2016
+ result = json.loads(result_json)
2017
+ log("step7", f"Permission state: {result['state']}")
2018
+ if result.get("message"):
2019
+ log("step7", f"Message: {result['message']}")
2020
+ return result["state"]
2021
+ log("step7", f"Attempt {attempt + 1}/5 -- waiting for button...")
2022
+ await _interruptible_sleep(2)
2023
+
2024
+ return "no_button"
2025
+
2026
+
2027
+ # ---------------------------------------------------------------------------
2028
+ # Step 7A: Native download via blob interception
2029
+ # ---------------------------------------------------------------------------
2030
+
2031
+
2032
+ async def download_native_vtt(port: int, target_id: str, output_path: str) -> bool:
2033
+ """Download the transcript via blob interception. Returns True on success."""
2034
+ ws, cdp = await connect_to_target(port, target_id)
2035
+ async with ws:
2036
+ # 1. Monkey-patch URL.createObjectURL to capture VTT blob content
2037
+ patch_result = await cdp.evaluate("""
2038
+ (function() {
2039
+ window.__capturedVTT = null;
2040
+ var origCreateObjectURL = URL.createObjectURL;
2041
+ URL.createObjectURL = function(blob) {
2042
+ if (blob instanceof Blob) {
2043
+ blob.text().then(function(text) {
2044
+ if (text.indexOf('WEBVTT') !== -1) {
2045
+ window.__capturedVTT = text;
2046
+ }
2047
+ });
2048
+ }
2049
+ return origCreateObjectURL.call(URL, blob);
2050
+ };
2051
+ return 'interceptor installed';
2052
+ })()
2053
+ """)
2054
+ log("step7a", f"Blob interceptor: {patch_result}")
2055
+
2056
+ # 2. Open the Download dropdown menu
2057
+ menu_result_json = await cdp.evaluate("""
2058
+ (function() {
2059
+ var btn = document.querySelector('button[aria-haspopup="true"]');
2060
+ if (!btn) return JSON.stringify({error: 'Download button not found'});
2061
+ if (btn.getAttribute('aria-expanded') !== 'true') btn.click();
2062
+ return JSON.stringify({ok: true});
2063
+ })()
2064
+ """)
2065
+ menu_result = json.loads(menu_result_json)
2066
+ if menu_result.get("error"):
2067
+ log("step7a", f"Error: {menu_result['error']}")
2068
+ return False
2069
+ log("step7a", "Download menu opened")
2070
+
2071
+ # 3. Wait for menu to render, drain pending messages
2072
+ await _interruptible_sleep(1)
2073
+ await cdp.drain()
2074
+
2075
+ # 4. Click "Download as .vtt"
2076
+ click_result_json = await cdp.evaluate("""
2077
+ (function() {
2078
+ var vttBtn = document.querySelector(
2079
+ 'button[aria-label="Download as .vtt"]'
2080
+ );
2081
+ if (!vttBtn) {
2082
+ var allBtns = Array.from(
2083
+ document.querySelectorAll('button[role="menuitem"]')
2084
+ );
2085
+ vttBtn = allBtns.find(function(b) {
2086
+ return b.textContent && b.textContent.indexOf('.vtt') !== -1;
2087
+ });
2088
+ }
2089
+ if (!vttBtn) return JSON.stringify({error: 'VTT menu item not found'});
2090
+ vttBtn.click();
2091
+ return JSON.stringify({ok: true});
2092
+ })()
2093
+ """)
2094
+ click_result = json.loads(click_result_json)
2095
+ if click_result.get("error"):
2096
+ log("step7a", f"Error: {click_result['error']}")
2097
+ return False
2098
+ log("step7a", "Clicked 'Download as .vtt'")
2099
+
2100
+ # 5. Wait for the blob to be captured
2101
+ log("step7a", "Waiting for VTT blob capture...")
2102
+ await _interruptible_sleep(3)
2103
+ await cdp.drain()
2104
+
2105
+ # 6. Check if the VTT was captured
2106
+ status_json = await cdp.evaluate("""
2107
+ (function() {
2108
+ if (window.__capturedVTT) {
2109
+ return JSON.stringify({
2110
+ captured: true,
2111
+ length: window.__capturedVTT.length
2112
+ });
2113
+ }
2114
+ return JSON.stringify({captured: false});
2115
+ })()
2116
+ """)
2117
+ status = json.loads(status_json)
2118
+
2119
+ if not status.get("captured"):
2120
+ log("step7a", "VTT blob was not captured.")
2121
+ return False
2122
+
2123
+ length = status["length"]
2124
+ log("step7a", f"Captured {length} chars of VTT content")
2125
+
2126
+ # 7. Retrieve content in chunks (large transcripts may exceed
2127
+ # the single-expression return limit)
2128
+ chunks = []
2129
+ chunk_size = 50000
2130
+ for offset in range(0, length, chunk_size):
2131
+ chunk = await cdp.evaluate(
2132
+ f"window.__capturedVTT.substring({offset}, {offset + chunk_size})"
2133
+ )
2134
+ chunks.append(chunk)
2135
+
2136
+ vtt_content = "".join(chunks)
2137
+
2138
+ # 8. Write to output path
2139
+ with open(output_path, "w") as f:
2140
+ f.write(vtt_content)
2141
+
2142
+ file_size = os.path.getsize(output_path)
2143
+ log("step7a", f"Written {file_size} bytes to {output_path}")
2144
+ return True
2145
+
2146
+
2147
+ # ---------------------------------------------------------------------------
2148
+ # Step 7B: DOM extraction (when download is blocked by permissions)
2149
+ # ---------------------------------------------------------------------------
2150
+
2151
+
2152
+ async def _extract_via_react_props(
2153
+ port: int,
2154
+ target_id: str,
2155
+ output_path: str,
2156
+ speaker_map: dict[str, str] | None = None,
2157
+ ) -> bool | None:
2158
+ """Try to extract transcript data from the React fibre tree.
2159
+
2160
+ The TranscriptList component holds all entries in its ``entries``
2161
+ prop, bypassing the virtualised list renderer entirely. All Text
2162
+ entries are extracted — no duration filtering is applied.
2163
+
2164
+ If *speaker_map* is provided (GUID -> display name), speaker names
2165
+ are resolved using it before generating VTT.
2166
+
2167
+ Returns True if successful, None if the React props aren't
2168
+ accessible (caller should fall back to DOM scrolling).
2169
+ """
2170
+ ws, cdp = await connect_to_target(port, target_id)
2171
+ async with ws:
2172
+ entries_json = await cdp.evaluate(r"""
2173
+ (function() {
2174
+ var el = document.querySelector('[role="list"]');
2175
+ if (!el) return JSON.stringify({error: 'no list element'});
2176
+ var fiberKey = Object.keys(el).find(function(k) {
2177
+ return k.startsWith('__reactFiber$');
2178
+ });
2179
+ if (!fiberKey) return JSON.stringify({error: 'no fiber key'});
2180
+
2181
+ // Walk up the fibre tree to find the component with
2182
+ // an 'entries' prop (TranscriptList at ~depth 9).
2183
+ var current = el[fiberKey];
2184
+ var depth = 0;
2185
+ var entries = null;
2186
+ while (current && depth < 25) {
2187
+ var props = current.memoizedProps || {};
2188
+ if (props.entries && Array.isArray(props.entries)
2189
+ && props.entries.length > 0) {
2190
+ entries = props.entries;
2191
+ break;
2192
+ }
2193
+ current = current.return;
2194
+ depth++;
2195
+ }
2196
+ if (!entries)
2197
+ return JSON.stringify({error: 'entries not found'});
2198
+
2199
+ function parseDur(d) {
2200
+ if (!d) return null;
2201
+ var m = d.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
2202
+ if (!m) return null;
2203
+ return (parseInt(m[1]||0)*3600)
2204
+ + (parseInt(m[2]||0)*60)
2205
+ + parseFloat(m[3]||0);
2206
+ }
2207
+
2208
+ var result = [];
2209
+ for (var i = 0; i < entries.length; i++) {
2210
+ var e = entries[i];
2211
+ if (e.type !== 'Text') continue;
2212
+ var ts = parseDur(e.timestamp);
2213
+ result.push({
2214
+ speaker: e.speakerDisplayName || 'Unknown',
2215
+ speakerId: e.speakerId || '',
2216
+ ts: ts,
2217
+ text: e.text || ''
2218
+ });
2219
+ }
2220
+ return JSON.stringify({ok: true, items: result});
2221
+ })()
2222
+ """)
2223
+
2224
+ data = json.loads(entries_json)
2225
+
2226
+ if data.get("error"):
2227
+ log("step7b", f"React props extraction failed: {data['error']}")
2228
+ return None
2229
+
2230
+ items = data["items"]
2231
+ if not items:
2232
+ log("step7b", "React props returned 0 text entries.")
2233
+ return None
2234
+
2235
+ log("step7b", f"React props: extracted {len(items)} text entries")
2236
+
2237
+ # Resolve speaker names using the Recap Speakers mapping.
2238
+ # Transcript entries have speakerId like "{GUID}@{tenant}" -- extract
2239
+ # the GUID and look it up in the mapping for a better display name.
2240
+ #
2241
+ # The Recap Speakers section shows the canonical display name for
2242
+ # each speaker (e.g. "Gabor Veres"), while the transcript data often
2243
+ # has vendor-prefixed names (e.g. "v-Gabor Veres (Metosin)") or
2244
+ # synthetic placeholders ("@1"). Always prefer the Recap name.
2245
+ if speaker_map:
2246
+ resolved_pairs = {} # old_name -> new_name (for logging)
2247
+ for item in items:
2248
+ sid = item.get("speakerId", "")
2249
+ guid = sid.split("@")[0] if "@" in sid else sid
2250
+ if guid not in speaker_map:
2251
+ continue
2252
+ recap_name = speaker_map[guid]
2253
+ transcript_name = item["speaker"]
2254
+ if recap_name == transcript_name:
2255
+ continue
2256
+ item["speaker"] = recap_name
2257
+ resolved_pairs[transcript_name] = recap_name
2258
+ if resolved_pairs:
2259
+ for old, new in resolved_pairs.items():
2260
+ log("step7b", f" Resolved speaker: {old!r} -> {new!r}")
2261
+ total = sum(1 for it in items if it["speaker"] in resolved_pairs.values())
2262
+ log(
2263
+ "step7b",
2264
+ f"Resolved {len(resolved_pairs)} speaker name(s) ({total} entries affected)",
2265
+ )
2266
+
2267
+ # Filter out meta entries
2268
+ segments = []
2269
+ for item in items:
2270
+ text = item["text"]
2271
+ if "started transcription" in text or "stopped transcription" in text:
2272
+ continue
2273
+ segments.append(item)
2274
+
2275
+ # Generate VTT
2276
+ def sec_to_vtt(s):
2277
+ s = max(0, s)
2278
+ h = int(s) // 3600
2279
+ m = (int(s) % 3600) // 60
2280
+ sec = int(s) % 60
2281
+ ms = int((s % 1) * 1000)
2282
+ return f"{h:02d}:{m:02d}:{sec:02d}.{ms:03d}"
2283
+
2284
+ vtt = ["WEBVTT", ""]
2285
+ for i, seg in enumerate(segments):
2286
+ ss = seg["ts"] if seg["ts"] is not None else 0
2287
+ es = segments[i + 1]["ts"] if i + 1 < len(segments) else ss + 10
2288
+ if es is None or es <= ss:
2289
+ es = ss + 10
2290
+ vtt.extend(
2291
+ [
2292
+ str(i + 1),
2293
+ f"{sec_to_vtt(ss)} --> {sec_to_vtt(es)}",
2294
+ f"<v {seg['speaker']}>{seg['text']}</v>",
2295
+ "",
2296
+ ]
2297
+ )
2298
+
2299
+ content = "\n".join(vtt)
2300
+ with open(output_path, "w") as f:
2301
+ f.write(content)
2302
+
2303
+ log("step7b", f"Written {len(content)} bytes, {len(segments)} segments")
2304
+ log("step7b", "Speakers:")
2305
+ for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
2306
+ log("step7b", f" {sp}: {cnt}")
2307
+
2308
+ return True
2309
+
2310
+
2311
+ async def extract_dom_vtt(port: int, target_id: str, output_path: str) -> bool:
2312
+ """Extract transcript from the DOM and generate a .vtt file."""
2313
+ headers = {} # headerNum -> {speaker, timestamp}
2314
+ texts = {} # textNum -> {text, headerNum}
2315
+ header_to_texts = {} # headerNum -> set of textNums
2316
+ orphaned_texts = []
2317
+
2318
+ ws, cdp = await connect_to_target(port, target_id)
2319
+ async with ws:
2320
+ # Scroll to top of transcript list
2321
+ await cdp.evaluate("""
2322
+ (function() {
2323
+ var el = document.querySelector('[role="list"]');
2324
+ var p = el;
2325
+ while (p && p.scrollHeight <= p.clientHeight) p = p.parentElement;
2326
+ if (p) p.scrollTop = 0;
2327
+ })()
2328
+ """)
2329
+ await asyncio.sleep(0.5)
2330
+
2331
+ # Scroll through the virtualised list, extracting entries.
2332
+ # Use scrollIntoView on the last visible item to advance.
2333
+ # Use block:'center' to avoid overshooting at the end of the list.
2334
+ consecutive_empty = 0
2335
+ for scroll_pass in range(200):
2336
+ items_json = await cdp.evaluate("""
2337
+ (function() {
2338
+ var items = Array.from(
2339
+ document.querySelectorAll('[role="listitem"]')
2340
+ );
2341
+ var result = [];
2342
+ var currentHeaderNum = null;
2343
+ for (var i = 0; i < items.length; i++) {
2344
+ var id = items[i].id || '';
2345
+ var num = parseInt(id.replace(/[^0-9]/g, ''));
2346
+ if (id.startsWith('itemHeader')) {
2347
+ currentHeaderNum = num;
2348
+ result.push({
2349
+ type: 'h', n: num,
2350
+ s: items[i].querySelector(
2351
+ '[class*="itemDisplayName"]'
2352
+ )?.textContent?.trim(),
2353
+ t: items[i].querySelector(
2354
+ '[class*="baseTimestamp"] span[aria-hidden]'
2355
+ )?.textContent?.trim()
2356
+ });
2357
+ } else if (id.startsWith('sub-entry')) {
2358
+ result.push({
2359
+ type: 'e', n: num, hn: currentHeaderNum,
2360
+ x: items[i].textContent?.trim()
2361
+ });
2362
+ }
2363
+ }
2364
+ // Scroll the last item to viewport centre to advance
2365
+ if (items.length > 0) {
2366
+ items[items.length - 1].scrollIntoView(
2367
+ {behavior: 'instant', block: 'center'}
2368
+ );
2369
+ }
2370
+ return JSON.stringify(result);
2371
+ })()
2372
+ """)
2373
+ items = json.loads(items_json)
2374
+
2375
+ new_count = 0
2376
+ for item in items:
2377
+ if item["type"] == "h" and item["n"] not in headers:
2378
+ headers[item["n"]] = {
2379
+ "speaker": item["s"],
2380
+ "timestamp": item["t"],
2381
+ }
2382
+ new_count += 1
2383
+ elif item["type"] == "e" and item["n"] not in texts:
2384
+ texts[item["n"]] = {
2385
+ "text": item["x"],
2386
+ "headerNum": item.get("hn"),
2387
+ }
2388
+ new_count += 1
2389
+ hn = item.get("hn")
2390
+ if hn is None:
2391
+ orphaned_texts.append(item["n"])
2392
+ else:
2393
+ if hn not in header_to_texts:
2394
+ header_to_texts[hn] = set()
2395
+ header_to_texts[hn].add(item["n"])
2396
+
2397
+ if scroll_pass % 10 == 0:
2398
+ log(
2399
+ "step7b",
2400
+ f"Pass {scroll_pass}: +{new_count} | headers={len(headers)} texts={len(texts)}",
2401
+ )
2402
+
2403
+ if new_count == 0:
2404
+ consecutive_empty += 1
2405
+ else:
2406
+ consecutive_empty = 0
2407
+
2408
+ if consecutive_empty >= 3 and scroll_pass >= 3:
2409
+ log("step7b", f"Extraction complete at pass {scroll_pass}")
2410
+ break
2411
+
2412
+ await asyncio.sleep(0.25)
2413
+
2414
+ # Diagnostic: log header/text number ranges and gaps
2415
+ if headers:
2416
+ h_nums = sorted(headers.keys())
2417
+ t_nums = sorted(texts.keys())
2418
+ log(
2419
+ "step7b",
2420
+ f"Header IDs: min={h_nums[0]} max={h_nums[-1]} count={len(h_nums)}",
2421
+ )
2422
+ log("step7b", f"Text IDs: min={t_nums[0]} max={t_nums[-1]} count={len(t_nums)}")
2423
+ # Check for gaps in header numbering
2424
+ h_gaps = []
2425
+ for i in range(1, len(h_nums)):
2426
+ gap = h_nums[i] - h_nums[i - 1]
2427
+ if gap > 1:
2428
+ h_gaps.append((h_nums[i - 1], h_nums[i], gap))
2429
+ if h_gaps:
2430
+ log("step7b", f"Header gaps: {h_gaps[:10]}")
2431
+ # Check for gaps in text numbering
2432
+ t_gaps = []
2433
+ for i in range(1, len(t_nums)):
2434
+ gap = t_nums[i] - t_nums[i - 1]
2435
+ if gap > 1:
2436
+ t_gaps.append((t_nums[i - 1], t_nums[i], gap))
2437
+ if t_gaps:
2438
+ log("step7b", f"Text gaps: {t_gaps[:10]}")
2439
+
2440
+ # Sort text lists per header
2441
+ for hn in header_to_texts:
2442
+ header_to_texts[hn] = sorted(header_to_texts[hn])
2443
+
2444
+ if orphaned_texts:
2445
+ log(
2446
+ "step7b",
2447
+ f"WARNING: {len(orphaned_texts)} sub-entries had no header "
2448
+ f"in view: {orphaned_texts[:5]}...",
2449
+ )
2450
+
2451
+ # Convert to VTT
2452
+ def ts_to_sec(ts):
2453
+ if not ts:
2454
+ return None
2455
+ parts = ts.split(":")
2456
+ if len(parts) == 2:
2457
+ return int(parts[0]) * 60 + int(parts[1])
2458
+ if len(parts) == 3:
2459
+ return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
2460
+ return 0
2461
+
2462
+ def sec_to_vtt(s):
2463
+ return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}.000"
2464
+
2465
+ # Build ordered segments
2466
+ segments = []
2467
+ null_ts_count = 0
2468
+ for hn in sorted(header_to_texts.keys()):
2469
+ h = headers.get(hn, {})
2470
+ ts_raw = h.get("timestamp")
2471
+ ts_sec = ts_to_sec(ts_raw)
2472
+ if ts_sec is None:
2473
+ null_ts_count += 1
2474
+ for tn in header_to_texts[hn]:
2475
+ t = texts.get(tn, {})
2476
+ text = t.get("text", "")
2477
+ # Skip meta-entries
2478
+ if "started transcription" in text or "stopped transcription" in text:
2479
+ continue
2480
+ segments.append(
2481
+ {
2482
+ "speaker": h.get("speaker", "Unknown"),
2483
+ "ts_sec": ts_sec,
2484
+ "text": text,
2485
+ }
2486
+ )
2487
+
2488
+ if null_ts_count > 0:
2489
+ log(
2490
+ "step7b",
2491
+ f"WARNING: {null_ts_count} headers had null/empty timestamps.",
2492
+ )
2493
+
2494
+ # Forward-fill null timestamps
2495
+ last_good = None
2496
+ for seg in segments:
2497
+ if seg["ts_sec"] is not None:
2498
+ last_good = seg["ts_sec"]
2499
+ elif last_good is not None:
2500
+ seg["ts_sec"] = last_good + 10
2501
+ last_good = seg["ts_sec"]
2502
+
2503
+ # Backfill remaining nulls
2504
+ next_good = None
2505
+ for seg in reversed(segments):
2506
+ if seg["ts_sec"] is not None:
2507
+ next_good = seg["ts_sec"]
2508
+ elif next_good is not None:
2509
+ seg["ts_sec"] = max(0, next_good - 10)
2510
+ next_good = seg["ts_sec"]
2511
+
2512
+ # Final fallback
2513
+ for seg in segments:
2514
+ if seg["ts_sec"] is None:
2515
+ seg["ts_sec"] = 0
2516
+
2517
+ # Detect timestamp discontinuities caused by virtualised scroll
2518
+ # artefacts. If a timestamp jumps by more than 10 minutes from the
2519
+ # previous segment AND the jump is to a timestamp more than 2x the
2520
+ # running max, discard from that point onwards.
2521
+ if len(segments) > 2:
2522
+ running_max = 0
2523
+ cut_at = None
2524
+ for i, seg in enumerate(segments):
2525
+ ts = seg["ts_sec"]
2526
+ if i > 0 and ts is not None and running_max > 0:
2527
+ gap = ts - running_max
2528
+ if gap > 600 and ts > running_max * 2:
2529
+ log(
2530
+ "step7b",
2531
+ f"WARNING: Timestamp discontinuity at segment {i}: "
2532
+ f"{sec_to_vtt(running_max)} -> {sec_to_vtt(ts)} "
2533
+ f"(gap {gap:.0f}s). Truncating {len(segments) - i} "
2534
+ f"segment(s).",
2535
+ )
2536
+ cut_at = i
2537
+ break
2538
+ if ts is not None and ts > running_max:
2539
+ running_max = ts
2540
+ if cut_at is not None:
2541
+ segments = segments[:cut_at]
2542
+
2543
+ # Generate VTT content
2544
+ vtt = ["WEBVTT", ""]
2545
+ for i, seg in enumerate(segments):
2546
+ ss = seg["ts_sec"]
2547
+ es = segments[i + 1]["ts_sec"] if i + 1 < len(segments) else ss + 10
2548
+ if es <= ss:
2549
+ es = ss + 10
2550
+ vtt.extend(
2551
+ [
2552
+ str(i + 1),
2553
+ f"{sec_to_vtt(ss)} --> {sec_to_vtt(es)}",
2554
+ f"<v {seg['speaker']}>{seg['text']}</v>",
2555
+ "",
2556
+ ]
2557
+ )
2558
+
2559
+ content = "\n".join(vtt)
2560
+ with open(output_path, "w") as f:
2561
+ f.write(content)
2562
+
2563
+ log("step7b", f"Written {len(content)} bytes, {len(segments)} segments")
2564
+ log("step7b", "Speakers:")
2565
+ for sp, cnt in Counter(s["speaker"] for s in segments).most_common():
2566
+ log("step7b", f" {sp}: {cnt}")
2567
+
2568
+ return True
2569
+
2570
+
2571
+ # ---------------------------------------------------------------------------
2572
+ # Step 8: Verify the output
2573
+ # ---------------------------------------------------------------------------
2574
+
2575
+
2576
+ # ---------------------------------------------------------------------------
2577
+ # Multi-part support helpers
2578
+ # ---------------------------------------------------------------------------
2579
+
2580
+
2581
+ async def _detect_part_tabs(port: int, target_id: str) -> int:
2582
+ """Detect Part N tabs in the Recap view.
2583
+
2584
+ Returns the number of parts (1 if no Part tabs are present, meaning
2585
+ a single-part meeting).
2586
+ """
2587
+ ws, cdp = await connect_to_target(port, target_id)
2588
+ async with ws:
2589
+ count_json = await cdp.evaluate(r"""
2590
+ (function() {
2591
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
2592
+ var parts = tabs.filter(function(t) {
2593
+ return /^Part\s*\d/i.test(t.textContent.trim());
2594
+ });
2595
+ return JSON.stringify({count: parts.length});
2596
+ })()
2597
+ """)
2598
+ data = json.loads(count_json)
2599
+ return max(1, data.get("count", 0))
2600
+
2601
+
2602
+ async def _click_part_and_transcript(port: int, target_id: str, part_number: int) -> bool:
2603
+ """Click the Part N tab and then the Transcript sub-tab.
2604
+
2605
+ Returns True if both clicks succeeded.
2606
+ """
2607
+ ws, cdp = await connect_to_target(port, target_id)
2608
+ async with ws:
2609
+ # Click the Part tab
2610
+ part_result = await cdp.evaluate(rf"""
2611
+ (function() {{
2612
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
2613
+ var partTab = tabs.find(function(t) {{
2614
+ return /^Part\s*{part_number}($|Part)/i.test(t.textContent.trim());
2615
+ }});
2616
+ if (!partTab) return 'no_part_tab';
2617
+ partTab.click();
2618
+ return 'clicked_part';
2619
+ }})()
2620
+ """)
2621
+ log("multi-part", f"Part {part_number} tab: {part_result}")
2622
+
2623
+ if part_result == "no_part_tab":
2624
+ return False
2625
+
2626
+ # Wait for the part content to load
2627
+ await _interruptible_sleep(2)
2628
+
2629
+ # Click the Transcript sub-tab
2630
+ transcript_result = await cdp.evaluate("""
2631
+ (function() {
2632
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
2633
+ var transcriptTab = tabs.find(function(t) {
2634
+ return t.textContent.indexOf('Transcript') !== -1;
2635
+ });
2636
+ if (!transcriptTab) return 'no_transcript_tab';
2637
+ transcriptTab.click();
2638
+ return 'clicked_transcript';
2639
+ })()
2640
+ """)
2641
+ log("multi-part", f"Transcript tab for Part {part_number}: {transcript_result}")
2642
+
2643
+ if transcript_result == "no_transcript_tab":
2644
+ return False
2645
+
2646
+ # Wait for transcript iframe to load
2647
+ await _interruptible_sleep(3)
2648
+
2649
+ return True
2650
+
2651
+
2652
+ def _parse_vtt_timestamp(ts: str) -> float:
2653
+ """Parse a VTT timestamp (HH:MM:SS.mmm) to seconds."""
2654
+ parts = ts.split(":")
2655
+ h = int(parts[0])
2656
+ m = int(parts[1])
2657
+ s_ms = parts[2].split(".")
2658
+ s = int(s_ms[0])
2659
+ ms = int(s_ms[1]) if len(s_ms) > 1 else 0
2660
+ return h * 3600 + m * 60 + s + ms / 1000
2661
+
2662
+
2663
+ def _format_vtt_timestamp(seconds: float) -> str:
2664
+ """Format seconds as a VTT timestamp (HH:MM:SS.mmm)."""
2665
+ seconds = max(0.0, seconds)
2666
+ h = int(seconds) // 3600
2667
+ m = (int(seconds) % 3600) // 60
2668
+ s = int(seconds) % 60
2669
+ ms = int((seconds % 1) * 1000)
2670
+ return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
2671
+
2672
+
2673
+ def _merge_vtt_parts(vtt_contents: list[str]) -> str:
2674
+ """Merge multiple VTT file contents into a single VTT.
2675
+
2676
+ Each part's timestamps are offset so that Part 2 continues from
2677
+ Part 1's last cue end time, and so on. Cues are renumbered
2678
+ sequentially across all parts.
2679
+
2680
+ Args:
2681
+ vtt_contents: list of VTT file contents (strings), one per part.
2682
+
2683
+ Returns:
2684
+ Merged VTT content as a single string.
2685
+ """
2686
+ timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
2687
+
2688
+ all_cues: list[tuple[float, float, list[str]]] = []
2689
+ cumulative_offset = 0.0
2690
+
2691
+ for part_idx, vtt_text in enumerate(vtt_contents):
2692
+ lines = vtt_text.split("\n")
2693
+ part_cues: list[tuple[float, float, list[str]]] = []
2694
+
2695
+ i = 0
2696
+ while i < len(lines):
2697
+ line = lines[i].strip()
2698
+ ts_match = timestamp_re.match(line)
2699
+ if ts_match:
2700
+ start = _parse_vtt_timestamp(ts_match.group(1))
2701
+ end = _parse_vtt_timestamp(ts_match.group(2))
2702
+ # Collect cue content lines (everything until next blank line)
2703
+ content_lines = []
2704
+ i += 1
2705
+ while i < len(lines) and lines[i].strip():
2706
+ content_lines.append(lines[i])
2707
+ i += 1
2708
+ part_cues.append(
2709
+ (start + cumulative_offset, end + cumulative_offset, content_lines)
2710
+ )
2711
+ else:
2712
+ i += 1
2713
+
2714
+ # Update offset: next part starts after this part's last cue end
2715
+ if part_cues:
2716
+ last_end = max(end for _, end, _ in part_cues)
2717
+ cumulative_offset = last_end
2718
+ log(
2719
+ "multi-part",
2720
+ f"Part {part_idx + 1}: {len(part_cues)} cues, "
2721
+ f"ends at {_format_vtt_timestamp(last_end)}",
2722
+ )
2723
+
2724
+ all_cues.extend(part_cues)
2725
+
2726
+ # Build merged VTT
2727
+ result = ["WEBVTT", ""]
2728
+ for idx, (start, end, content_lines) in enumerate(all_cues, 1):
2729
+ result.append(str(idx))
2730
+ result.append(f"{_format_vtt_timestamp(start)} --> {_format_vtt_timestamp(end)}")
2731
+ result.extend(content_lines)
2732
+ result.append("")
2733
+
2734
+ return "\n".join(result)
2735
+
2736
+
2737
+ # ---------------------------------------------------------------------------
2738
+ # Chat message extraction
2739
+ # ---------------------------------------------------------------------------
2740
+
2741
+
2742
+ def _strip_html_to_text(html: str) -> str:
2743
+ """Convert HTML message content to plain text.
2744
+
2745
+ Handles common Teams message markup: ``<p>``, ``<br>``, ``<div>``,
2746
+ ``<a href="...">``, ``<span>``, and HTML entities. Strips all other
2747
+ tags. Returns empty string for blank or whitespace-only content.
2748
+ """
2749
+ import html as html_mod
2750
+
2751
+ if not html:
2752
+ return ""
2753
+
2754
+ text = html
2755
+
2756
+ # Convert block-level elements to newlines
2757
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
2758
+ text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
2759
+ text = re.sub(r"</div>", "\n", text, flags=re.IGNORECASE)
2760
+ text = re.sub(r"</li>", "\n", text, flags=re.IGNORECASE)
2761
+
2762
+ # Extract link text with URL: <a href="URL">text</a> -> text (URL)
2763
+ text = re.sub(
2764
+ r'<a\s[^>]*href="([^"]*)"[^>]*>([^<]*)</a>',
2765
+ r"\2 (\1)",
2766
+ text,
2767
+ flags=re.IGNORECASE,
2768
+ )
2769
+
2770
+ # Remove all remaining HTML tags
2771
+ text = re.sub(r"<[^>]+>", "", text)
2772
+
2773
+ # Decode HTML entities
2774
+ text = html_mod.unescape(text)
2775
+
2776
+ # Collapse multiple blank lines into a single newline
2777
+ text = re.sub(r"\n{3,}", "\n\n", text)
2778
+
2779
+ return text.strip()
2780
+
2781
+
2782
+ def _resolve_chat_author(
2783
+ from_user_id: str,
2784
+ im_display_name: str,
2785
+ speaker_map: dict[str, str] | None,
2786
+ ) -> str:
2787
+ """Resolve a chat message author name using the speaker mapping.
2788
+
2789
+ Extracts the bare GUID from ``fromUserId`` (format ``8:orgid:{GUID}``)
2790
+ and checks the speaker_map for a cleaner display name. Falls back to
2791
+ ``imDisplayName`` from the message props.
2792
+ """
2793
+ if speaker_map and from_user_id and ":" in from_user_id:
2794
+ guid = from_user_id.split(":")[-1]
2795
+ mapped = speaker_map.get(guid)
2796
+ if mapped:
2797
+ return mapped
2798
+
2799
+ return im_display_name or "Unknown"
2800
+
2801
+
2802
+ async def _extract_roster_members(
2803
+ port: int,
2804
+ target_id: str,
2805
+ speaker_map: dict[str, str] | None = None,
2806
+ ) -> list[dict]:
2807
+ """Extract the full meeting chat member list from the roster popover.
2808
+
2809
+ Opens the participant count button, reads member data from the React
2810
+ fibre tree, and closes the popover. Returns a list of dicts with
2811
+ ``name`` (cleaned display name), ``email``, and ``role`` (``organiser``
2812
+ if the member is marked as organiser, otherwise ``member``).
2813
+
2814
+ The ``speaker_map`` is used to resolve vendor-prefixed names
2815
+ (e.g. ``v-Gabor Veres (Metosin)`` -> ``Gabor Veres``). Names not
2816
+ in the map have the ``v-`` prefix and parenthesised suffix stripped.
2817
+ """
2818
+ ws, cdp = await connect_to_target(port, target_id)
2819
+ try:
2820
+ # Click the participant count button to open the roster
2821
+ result = await cdp.evaluate("""
2822
+ (function() {
2823
+ var btn = document.querySelector(
2824
+ '[data-tid="chat-header-participant-count"]'
2825
+ );
2826
+ if (!btn) return JSON.stringify({error: 'no_button'});
2827
+ btn.click();
2828
+ return JSON.stringify({ok: true});
2829
+ })()
2830
+ """)
2831
+ data = json.loads(result) if result else {}
2832
+ if data.get("error"):
2833
+ log("roster", f"Could not open roster: {data['error']}")
2834
+ return []
2835
+
2836
+ # Wait for the roster popover to render
2837
+ await _interruptible_sleep(2)
2838
+
2839
+ # Extract member data from the grid
2840
+ raw = await cdp.evaluate("""
2841
+ (function() {
2842
+ var grid = document.querySelector('[aria-label="grid"]');
2843
+ if (!grid) return JSON.stringify({error: 'no_grid'});
2844
+
2845
+ var presentation = grid.querySelector('[role="presentation"]');
2846
+ if (!presentation)
2847
+ return JSON.stringify({error: 'no_presentation'});
2848
+
2849
+ var items = presentation.children;
2850
+ var results = [];
2851
+
2852
+ for (var i = 0; i < items.length; i++) {
2853
+ var item = items[i];
2854
+ var text = item.textContent.trim();
2855
+
2856
+ // Skip the header row ("People (N)")
2857
+ if (/^People\\s*\\(/.test(text)) continue;
2858
+
2859
+ var fk = Object.keys(item).find(
2860
+ function(k) { return k.startsWith('__reactFiber$'); }
2861
+ );
2862
+ if (!fk) continue;
2863
+
2864
+ var fiber = item[fk];
2865
+ var c = fiber;
2866
+ var member = null;
2867
+ for (var d = 0; d < 10 && c; d++) {
2868
+ var p = c.memoizedProps || {};
2869
+ if (p.member && p.member.displayName) {
2870
+ member = {
2871
+ displayName: p.member.displayName,
2872
+ mri: p.member.mri || '',
2873
+ objectId: p.member.objectId || '',
2874
+ email: p.member.email || ''
2875
+ };
2876
+ break;
2877
+ }
2878
+ c = c.return;
2879
+ }
2880
+
2881
+ if (member) {
2882
+ member.isOrganiser = /Organiser/i.test(text);
2883
+ results.push(member);
2884
+ }
2885
+ }
2886
+
2887
+ return JSON.stringify({members: results});
2888
+ })()
2889
+ """)
2890
+
2891
+ # Close the popover with Escape
2892
+ await cdp.send(
2893
+ "Input.dispatchKeyEvent",
2894
+ {
2895
+ "type": "keyDown",
2896
+ "key": "Escape",
2897
+ "code": "Escape",
2898
+ "windowsVirtualKeyCode": 27,
2899
+ },
2900
+ )
2901
+ await cdp.send(
2902
+ "Input.dispatchKeyEvent",
2903
+ {
2904
+ "type": "keyUp",
2905
+ "key": "Escape",
2906
+ "code": "Escape",
2907
+ "windowsVirtualKeyCode": 27,
2908
+ },
2909
+ )
2910
+
2911
+ if not raw:
2912
+ log("roster", "Failed to extract roster data.")
2913
+ return []
2914
+
2915
+ data = json.loads(raw)
2916
+ if data.get("error"):
2917
+ log("roster", f"Roster extraction error: {data['error']}")
2918
+ return []
2919
+
2920
+ raw_members = data.get("members", [])
2921
+ if not raw_members:
2922
+ log("roster", "No members found in roster.")
2923
+ return []
2924
+
2925
+ # Clean names: prefer speaker_map lookup, then strip vendor prefix
2926
+ members = []
2927
+ for m in raw_members:
2928
+ name = m["displayName"]
2929
+ guid = m["objectId"]
2930
+
2931
+ # Try speaker_map first
2932
+ if speaker_map and guid and guid in speaker_map:
2933
+ name = speaker_map[guid]
2934
+ else:
2935
+ # Strip vendor prefix: "v-Name (Company)" -> "Name"
2936
+ cleaned = re.sub(r"^v-", "", name)
2937
+ cleaned = re.sub(r"\s*\([^)]+\)\s*$", "", cleaned)
2938
+ if cleaned:
2939
+ name = cleaned
2940
+
2941
+ role = "organiser" if m.get("isOrganiser") else "member"
2942
+ members.append(
2943
+ {
2944
+ "name": name,
2945
+ "email": m["email"],
2946
+ "objectId": guid,
2947
+ "role": role,
2948
+ }
2949
+ )
2950
+
2951
+ log("roster", f"Extracted {len(members)} member(s)")
2952
+ return members
2953
+
2954
+ finally:
2955
+ await ws.close()
2956
+
2957
+
2958
+ def _utc_to_local_iso(utc_timestamp: str) -> str:
2959
+ """Convert a UTC ISO 8601 timestamp to local time with UTC offset.
2960
+
2961
+ Accepts strings like ``2026-04-22T10:15:48.462Z`` and returns
2962
+ ``2026-04-22T11:15:48.462+01:00`` (for BST). If parsing fails
2963
+ the original string is returned unchanged.
2964
+ """
2965
+ from datetime import datetime, timedelta
2966
+
2967
+ try:
2968
+ dt = datetime.fromisoformat(utc_timestamp.replace("Z", "+00:00"))
2969
+ local_dt = dt.astimezone(tz=None)
2970
+ offset = local_dt.utcoffset() or timedelta(0)
2971
+ total = int(offset.total_seconds())
2972
+ sign = "+" if total >= 0 else "-"
2973
+ a = abs(total)
2974
+ offset_str = f"{sign}{a // 3600:02d}:{(a % 3600) // 60:02d}"
2975
+ # Format with milliseconds if present in original
2976
+ if "." in utc_timestamp:
2977
+ ms = f"{local_dt.microsecond // 1000:03d}"
2978
+ return f"{local_dt.strftime('%Y-%m-%dT%H:%M:%S')}.{ms}{offset_str}"
2979
+ return local_dt.strftime(f"%Y-%m-%dT%H:%M:%S{offset_str}")
2980
+ except (ValueError, OSError):
2981
+ return utc_timestamp
2982
+
2983
+
2984
+ async def _extract_chat_messages(
2985
+ port: int,
2986
+ target_id: str,
2987
+ config: dict,
2988
+ ) -> list[dict]:
2989
+ """Extract meeting chat messages from the Chat tab via React fibre tree.
2990
+
2991
+ Navigates to the Chat tab, walks the React fibre tree to find the
2992
+ virtualised list's ``items`` array, and extracts user messages that
2993
+ fall within the meeting time window.
2994
+
2995
+ The time window is determined from ``Event/Call`` control messages
2996
+ (meeting start/end signals) which carry actual UTC timestamps. This
2997
+ avoids the ambiguity of the Recap header times which are in local
2998
+ timezone.
2999
+
3000
+ Returns a list of dicts with keys: ``timestamp``, ``author``, ``text``,
3001
+ ``reactions``. Returns an empty list if no chat messages are found.
3002
+ """
3003
+ target_date = config["date"] # YYYY-MM-DD
3004
+
3005
+ ws, cdp = await connect_to_target(port, target_id)
3006
+ try:
3007
+ # Click the Chat tab
3008
+ chat_tab_result = await cdp.evaluate(r"""
3009
+ (function() {
3010
+ var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
3011
+ var chatTab = tabs.find(function(t) {
3012
+ return t.getAttribute('data-tid') ===
3013
+ 'tab-item-com.microsoft.chattabs.chat';
3014
+ });
3015
+ if (!chatTab) return 'no_tab';
3016
+ chatTab.click();
3017
+ return 'clicked';
3018
+ })()
3019
+ """)
3020
+
3021
+ if chat_tab_result != "clicked":
3022
+ log("chat", "Chat tab not found -- skipping chat extraction.")
3023
+ return []
3024
+
3025
+ await _interruptible_sleep(2)
3026
+
3027
+ # Extract all messages from the React fibre tree.
3028
+ # The virtualised list component at ~depth 21 (walking up from any
3029
+ # chat-pane-item) holds the complete items array.
3030
+ raw = await cdp.evaluate(r"""
3031
+ (function() {
3032
+ var els = document.querySelectorAll('[data-tid="chat-pane-item"]');
3033
+ if (!els.length) return JSON.stringify({error: 'no_items'});
3034
+
3035
+ // Walk up the fibre tree from the first item to find the
3036
+ // component whose props.items is an array of messages.
3037
+ var el = els[0];
3038
+ var fk = Object.keys(el).find(function(k) {
3039
+ return k.startsWith('__reactFiber$');
3040
+ });
3041
+ if (!fk) return JSON.stringify({error: 'no_fiber'});
3042
+
3043
+ var fiber = el[fk];
3044
+ var c = fiber;
3045
+ var itemsArr = null;
3046
+ var loadPrev = null;
3047
+ var loadNext = null;
3048
+ for (var d = 0; d < 30 && c; d++) {
3049
+ var p = c.memoizedProps || {};
3050
+ if (p.items && Array.isArray(p.items) && p.items.length > 0
3051
+ && p.items[0] && p.items[0].originalArrivalTime) {
3052
+ itemsArr = p.items;
3053
+ loadPrev = p.loadPrev;
3054
+ loadNext = p.loadNext;
3055
+ break;
3056
+ }
3057
+ c = c.return;
3058
+ }
3059
+
3060
+ if (!itemsArr) return JSON.stringify({error: 'no_items_array'});
3061
+
3062
+ // Extract relevant fields from each item
3063
+ var messages = [];
3064
+ for (var i = 0; i < itemsArr.length; i++) {
3065
+ var m = itemsArr[i];
3066
+ messages.push({
3067
+ id: m.id,
3068
+ time: m.originalArrivalTime,
3069
+ itemType: m.itemType,
3070
+ messageType: m.messageType,
3071
+ fromUserId: m.fromUserId || '',
3072
+ displayName: m.imDisplayName || '',
3073
+ content: m.content || '',
3074
+ emotions: m.emotions,
3075
+ emotionsSummary: m.emotionsSummary,
3076
+ files: m.files ? m.files.length : 0,
3077
+ mentions: m.mentions
3078
+ });
3079
+ }
3080
+
3081
+ return JSON.stringify({
3082
+ total: itemsArr.length,
3083
+ hasPagination: !!(loadPrev || loadNext),
3084
+ messages: messages
3085
+ });
3086
+ })()
3087
+ """)
3088
+
3089
+ if not raw:
3090
+ log("chat", "Failed to extract chat data from React fibre tree.")
3091
+ return []
3092
+
3093
+ data = json.loads(raw)
3094
+
3095
+ if "error" in data:
3096
+ log("chat", f"Chat extraction error: {data['error']}")
3097
+ return []
3098
+
3099
+ if data.get("hasPagination"):
3100
+ log(
3101
+ "chat",
3102
+ f"WARNING: Chat has pagination ({data['total']} items loaded). "
3103
+ "Some messages may be missing.",
3104
+ )
3105
+
3106
+ log("chat", f"Total items in chat: {data['total']}")
3107
+
3108
+ # Determine the meeting time window from Event/Call control messages.
3109
+ # These are the meeting start/end signals with actual UTC timestamps,
3110
+ # avoiding the ambiguity of the Recap header which shows local time.
3111
+ #
3112
+ # The user-specified --datetime may differ from the actual meeting
3113
+ # date shown in the Recap header (e.g. user says 2026-04-22 but
3114
+ # meeting was 2026-04-21). Try multiple candidate dates.
3115
+ candidate_dates = [target_date]
3116
+
3117
+ # Extract the actual meeting date from the Recap header if available
3118
+ time_text = config.get("meeting_time_text", "")
3119
+ if time_text:
3120
+ recap_start, _ = _parse_meeting_times(time_text, target_date)
3121
+ recap_date = recap_start[:10] # YYYY-MM-DD
3122
+ if recap_date != target_date:
3123
+ candidate_dates.insert(0, recap_date)
3124
+
3125
+ event_calls = []
3126
+ matched_date = None
3127
+ for cdate in candidate_dates:
3128
+ event_calls = [
3129
+ m
3130
+ for m in data["messages"]
3131
+ if m.get("messageType") == "Event/Call" and m.get("time", "").startswith(cdate)
3132
+ ]
3133
+ if event_calls:
3134
+ matched_date = cdate
3135
+ break
3136
+
3137
+ if event_calls:
3138
+ # Sort by timestamp; first is meeting start, last is meeting end
3139
+ event_calls.sort(key=lambda m: m["time"])
3140
+ start_iso = event_calls[0]["time"]
3141
+ end_iso = event_calls[-1]["time"]
3142
+ # If only one Event/Call found (start or end only), expand the
3143
+ # window by 2 hours in the missing direction to catch messages.
3144
+ if start_iso == end_iso:
3145
+ from datetime import datetime, timedelta
3146
+
3147
+ dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
3148
+ start_iso = (dt - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
3149
+ end_iso = (dt + timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
3150
+ log(
3151
+ "chat",
3152
+ f"Time window (from Event/Call on {matched_date}): {start_iso} to {end_iso}",
3153
+ )
3154
+ else:
3155
+ # Fallback: use the best available date for a full-day window.
3156
+ # Prefer the Recap header date over the user-specified date.
3157
+ fallback_date = candidate_dates[0]
3158
+ log("chat", f"No Event/Call markers found; falling back to {fallback_date}")
3159
+ start_iso = f"{fallback_date}T00:00:00Z"
3160
+ end_iso = f"{fallback_date}T23:59:59Z"
3161
+ log("chat", f"Time window (full day fallback): {start_iso} to {end_iso}")
3162
+ # Prefer member_map (roster + speakers) for resolving chat authors
3163
+ # and reaction users; fall back to speaker_map if roster unavailable.
3164
+ speaker_map = config.get("member_map") or config.get("speaker_map")
3165
+ results = []
3166
+
3167
+ for msg in data["messages"]:
3168
+ # Only user messages (itemType 3), text type
3169
+ if msg["itemType"] != 3:
3170
+ continue
3171
+ if msg["messageType"] != "RichText/Html":
3172
+ continue
3173
+ # Skip messages with file attachments only (no text)
3174
+ if msg["files"] and msg["files"] > 0:
3175
+ text = _strip_html_to_text(msg["content"])
3176
+ if not text:
3177
+ continue
3178
+
3179
+ # Filter by time window
3180
+ msg_time = msg["time"]
3181
+ if not msg_time:
3182
+ continue
3183
+ if msg_time < start_iso or msg_time > end_iso:
3184
+ continue
3185
+
3186
+ # Build the chat message entry
3187
+ text = _strip_html_to_text(msg["content"])
3188
+ if not text:
3189
+ continue
3190
+
3191
+ author = _resolve_chat_author(msg["fromUserId"], msg["displayName"], speaker_map)
3192
+
3193
+ # Parse reactions — use the detailed ``emotions`` array which
3194
+ # includes per-user data, falling back to ``emotionsSummary``.
3195
+ reactions = None
3196
+ emotions_list = msg.get("emotions") or []
3197
+ if emotions_list:
3198
+ reactions = []
3199
+ for emo in emotions_list:
3200
+ reaction_type = emo.get("key", "")
3201
+ users = []
3202
+ for u in emo.get("users") or []:
3203
+ uid = u.get("userId", "")
3204
+ display = u.get("displayName", "")
3205
+ name = _resolve_chat_author(uid, display, speaker_map)
3206
+ users.append(name)
3207
+ reactions.append({"type": reaction_type, "users": users})
3208
+ elif msg.get("emotionsSummary"):
3209
+ reactions = []
3210
+ for r in msg["emotionsSummary"]:
3211
+ reactions.append(
3212
+ {
3213
+ "type": r.get("key", ""),
3214
+ "users": [],
3215
+ }
3216
+ )
3217
+
3218
+ results.append(
3219
+ {
3220
+ "timestamp": _utc_to_local_iso(msg_time),
3221
+ "author": author,
3222
+ "text": text,
3223
+ "reactions": reactions,
3224
+ }
3225
+ )
3226
+
3227
+ # Sort chronologically (oldest first)
3228
+ results.sort(key=lambda m: m["timestamp"])
3229
+
3230
+ log("chat", f"Extracted {len(results)} chat message(s) in meeting window")
3231
+ return results
3232
+
3233
+ finally:
3234
+ await ws.close()
3235
+
3236
+
3237
+ def _append_chat_to_vtt(output_path: str, messages: list[dict], meeting_start: str) -> None:
3238
+ """Append meeting chat messages as a NOTE block in the VTT file.
3239
+
3240
+ The NOTE block is inserted after the metadata NOTE block (if present)
3241
+ and before the first cue. Chat messages are stored as a JSON array
3242
+ for machine readability.
3243
+
3244
+ Each message's ``timestamp`` is converted from an absolute ISO 8601
3245
+ datetime to a meeting-start-relative offset in ``HH:MM:SS.mmm``
3246
+ format, matching the cue timestamp convention used elsewhere in the
3247
+ VTT file.
3248
+
3249
+ Args:
3250
+ output_path: Path to the VTT file to modify.
3251
+ messages: Chat messages with absolute ``timestamp`` values.
3252
+ meeting_start: Meeting start time as ISO 8601 (e.g.
3253
+ ``2026-04-22T11:15+01:00``).
3254
+ """
3255
+ if not messages:
3256
+ return
3257
+
3258
+ from datetime import datetime
3259
+
3260
+ try:
3261
+ start_dt = datetime.fromisoformat(meeting_start)
3262
+ except (ValueError, TypeError):
3263
+ # Cannot compute offsets; fall back to absolute timestamps
3264
+ start_dt = None
3265
+
3266
+ offset_messages = []
3267
+ for msg in messages:
3268
+ entry = dict(msg)
3269
+ if start_dt is not None:
3270
+ try:
3271
+ msg_dt = datetime.fromisoformat(entry["timestamp"])
3272
+ delta = msg_dt - start_dt
3273
+ total_ms = int(delta.total_seconds() * 1000)
3274
+ if total_ms < 0:
3275
+ total_ms = 0
3276
+ hours = total_ms // 3_600_000
3277
+ minutes = (total_ms % 3_600_000) // 60_000
3278
+ seconds = (total_ms % 60_000) // 1000
3279
+ millis = total_ms % 1000
3280
+ entry["offset"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
3281
+ except (ValueError, TypeError):
3282
+ entry["offset"] = entry["timestamp"]
3283
+ else:
3284
+ entry["offset"] = entry["timestamp"]
3285
+ del entry["timestamp"]
3286
+ offset_messages.append(entry)
3287
+
3288
+ with open(output_path) as f:
3289
+ content = f.read()
3290
+
3291
+ chat_json = json.dumps(offset_messages, indent=2, ensure_ascii=False)
3292
+ note_block = f"NOTE Chat\n{chat_json}"
3293
+
3294
+ # Insert after the existing NOTE block (metadata) if present.
3295
+ # The metadata NOTE block ends at the first blank line after
3296
+ # "NOTE Meeting Metadata\n". We insert the chat NOTE block after it.
3297
+ #
3298
+ # Pattern: WEBVTT\n\nNOTE Meeting Metadata\n...\n\n
3299
+ # -> WEBVTT\n\nNOTE Meeting Metadata\n...\n\nNOTE Chat\n...\n\n
3300
+ #
3301
+ # Find the end of the first NOTE block.
3302
+ note_end = content.find("\n\nNOTE")
3303
+ if note_end != -1:
3304
+ # Find the blank line after this NOTE block
3305
+ after_note = content.find("\n\n", note_end + 2)
3306
+ if after_note != -1:
3307
+ insert_pos = after_note + 2
3308
+ content = content[:insert_pos] + note_block + "\n\n" + content[insert_pos:]
3309
+ else:
3310
+ content += "\n\n" + note_block + "\n"
3311
+ elif content.startswith("WEBVTT"):
3312
+ # No existing NOTE block -- insert after WEBVTT header
3313
+ content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
3314
+ else:
3315
+ content = f"WEBVTT\n\n{note_block}\n\n{content}"
3316
+
3317
+ with open(output_path, "w") as f:
3318
+ f.write(content)
3319
+
3320
+ log("chat", f"Added {len(messages)} chat message(s) to VTT")
3321
+
3322
+
3323
+ def _parse_meeting_times(text: str, fallback_date: str) -> tuple[str, str]:
3324
+ """Parse occurrence text into ISO 8601 start and end datetime strings.
3325
+
3326
+ *text* has the form ``22 April 2026 11:15 - 12:00``.
3327
+ *fallback_date* is ``YYYY-MM-DD`` used if the text cannot be parsed.
3328
+
3329
+ The Recap header displays times in the user's local timezone. The
3330
+ local UTC offset for the parsed date is appended so the output is a
3331
+ fully-qualified ISO 8601 datetime (e.g. ``2026-04-22T11:15+01:00``).
3332
+
3333
+ Returns ``(start, end)`` where each is ``YYYY-MM-DDTHH:MM+HH:MM`` or
3334
+ just ``YYYY-MM-DD`` if the time component is missing.
3335
+ """
3336
+ from datetime import datetime, timedelta, timezone
3337
+
3338
+ month_names = {
3339
+ "january": "01",
3340
+ "february": "02",
3341
+ "march": "03",
3342
+ "april": "04",
3343
+ "may": "05",
3344
+ "june": "06",
3345
+ "july": "07",
3346
+ "august": "08",
3347
+ "september": "09",
3348
+ "october": "10",
3349
+ "november": "11",
3350
+ "december": "12",
3351
+ }
3352
+
3353
+ m = re.match(
3354
+ r"(\d{1,2})\s+(\w+)\s+(\d{4})\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})",
3355
+ text.strip(),
3356
+ )
3357
+ if not m:
3358
+ return fallback_date, fallback_date
3359
+
3360
+ day = int(m.group(1))
3361
+ month = month_names.get(m.group(2).lower(), "01")
3362
+ year = m.group(3)
3363
+ start_time = m.group(4).zfill(5) # ensure HH:MM
3364
+ end_time = m.group(5).zfill(5)
3365
+
3366
+ date_str = f"{year}-{month}-{day:02d}"
3367
+
3368
+ # Determine the local UTC offset for this specific date. The offset
3369
+ # may differ from today's offset due to DST transitions, so we
3370
+ # construct a datetime on the meeting date and ask the platform
3371
+ # for the corresponding UTC offset.
3372
+ sh, sm = (int(x) for x in start_time.split(":"))
3373
+ try:
3374
+ naive = datetime(int(year), int(month), day, sh, sm, tzinfo=timezone.utc)
3375
+ local_dt = naive.astimezone(tz=None) # convert to local tz
3376
+ utc_offset = local_dt.utcoffset() or timedelta(0)
3377
+ except (ValueError, OSError):
3378
+ utc_offset = datetime.now(timezone.utc).astimezone().utcoffset() or timedelta(0)
3379
+
3380
+ total_seconds = int(utc_offset.total_seconds())
3381
+ sign = "+" if total_seconds >= 0 else "-"
3382
+ abs_seconds = abs(total_seconds)
3383
+ offset_str = f"{sign}{abs_seconds // 3600:02d}:{(abs_seconds % 3600) // 60:02d}"
3384
+
3385
+ return f"{date_str}T{start_time}{offset_str}", f"{date_str}T{end_time}{offset_str}"
3386
+
3387
+
3388
+ def _prepend_vtt_metadata(output_path: str, config: dict) -> None:
3389
+ """Insert a VTT NOTE block with meeting metadata after the WEBVTT header.
3390
+
3391
+ The NOTE block contains the meeting title, attendee names (from the
3392
+ speaker mapping), and start/end times (from the Recap header).
3393
+ """
3394
+ with open(output_path) as f:
3395
+ content = f.read()
3396
+
3397
+ # Build the NOTE block
3398
+ lines = ["NOTE Meeting Metadata"]
3399
+ lines.append(f"Meeting: {config['meeting']}")
3400
+
3401
+ # Members from roster (sorted alphabetically), falling back to speakers
3402
+ roster = config.get("roster_members")
3403
+ if roster:
3404
+ organiser_names = sorted(m["name"] for m in roster if m["role"] == "organiser")
3405
+ member_names = sorted(m["name"] for m in roster)
3406
+ lines.append(f"Members: {', '.join(member_names)}")
3407
+ if organiser_names:
3408
+ lines.append(f"Organiser: {', '.join(organiser_names)}")
3409
+
3410
+ # Email addresses for all members that have one
3411
+ email_entries = sorted((m["name"], m["email"]) for m in roster if m.get("email"))
3412
+ if email_entries:
3413
+ email_strs = [f"{name} <{email}>" for name, email in email_entries]
3414
+ lines.append(f"Emails: {', '.join(email_strs)}")
3415
+ else:
3416
+ # Fallback: speakers only (from Recap)
3417
+ speaker_map = config.get("speaker_map")
3418
+ if speaker_map:
3419
+ names = sorted(speaker_map.values())
3420
+ lines.append(f"Speakers: {', '.join(names)}")
3421
+
3422
+ # Start/end times
3423
+ time_text = config.get("meeting_time_text", "")
3424
+ if time_text:
3425
+ start_dt, end_dt = _parse_meeting_times(time_text, config["date"])
3426
+ else:
3427
+ start_dt = config["date"]
3428
+ if config.get("time"):
3429
+ start_dt = f"{config['date']}T{config['time']}"
3430
+ end_dt = ""
3431
+
3432
+ lines.append(f"Start: {start_dt}")
3433
+ if end_dt:
3434
+ lines.append(f"End: {end_dt}")
3435
+
3436
+ note_block = "\n".join(lines)
3437
+
3438
+ # Insert after the WEBVTT header line (before the first blank line)
3439
+ if content.startswith("WEBVTT"):
3440
+ # Replace "WEBVTT\n\n" with "WEBVTT\n\nNOTE Meeting Metadata\n...\n\n"
3441
+ content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
3442
+ else:
3443
+ # Unexpected — prepend both header and note
3444
+ content = f"WEBVTT\n\n{note_block}\n\n{content}"
3445
+
3446
+ with open(output_path, "w") as f:
3447
+ f.write(content)
3448
+
3449
+ log("post", "Added VTT metadata comment block")
3450
+
3451
+
3452
+ def _apply_speaker_mapping_to_vtt(output_path: str, speaker_map: dict[str, str]) -> None:
3453
+ """Post-process a VTT file to replace speaker names using the mapping.
3454
+
3455
+ Native VTT downloads (Approach A) contain speaker names in
3456
+ ``<v SPEAKER_NAME>`` tags. This function matches each VTT speaker
3457
+ name to a Recap display name and replaces it.
3458
+
3459
+ The mapping keys are GUIDs, but VTT files don't contain GUIDs --
3460
+ they contain display names. Matching is done by:
3461
+
3462
+ 1. ``@N`` pattern: Recap shows ``Speaker N``.
3463
+ 2. Substring match: VTT has ``v-Gabor Veres (Metosin)``, Recap has
3464
+ ``Gabor Veres``. The Recap name is a substring of the VTT name.
3465
+ 3. Exact match: names are already identical -- no replacement needed.
3466
+ """
3467
+ import re as _re
3468
+
3469
+ with open(output_path) as f:
3470
+ content = f.read()
3471
+
3472
+ # Extract all unique speaker names from the VTT.
3473
+ vtt_names = set(_re.findall(r"<v ([^>]+)>", content))
3474
+ recap_names = set(speaker_map.values())
3475
+
3476
+ replacements = {} # old_vtt_name -> recap_name
3477
+
3478
+ for vtt_name in vtt_names:
3479
+ if vtt_name in recap_names:
3480
+ # Already matches a Recap name exactly -- no change needed.
3481
+ continue
3482
+
3483
+ # Try to find a matching Recap name.
3484
+ # 1. @N pattern: look for "Speaker N" in Recap names.
3485
+ at_match = _re.match(r"^@(\d+)$", vtt_name)
3486
+ if at_match:
3487
+ speaker_label = f"Speaker {at_match.group(1)}"
3488
+ if speaker_label in recap_names:
3489
+ replacements[vtt_name] = speaker_label
3490
+ continue
3491
+
3492
+ # 2. Substring match: a Recap name is contained in the VTT name.
3493
+ # e.g. "Gabor Veres" in "v-Gabor Veres (Metosin)"
3494
+ # Pick the longest matching Recap name to avoid false positives.
3495
+ best_match = None
3496
+ best_len = 0
3497
+ for recap_name in recap_names:
3498
+ if recap_name in vtt_name and len(recap_name) > best_len:
3499
+ best_match = recap_name
3500
+ best_len = len(recap_name)
3501
+ if best_match:
3502
+ replacements[vtt_name] = best_match
3503
+
3504
+ if not replacements:
3505
+ return
3506
+
3507
+ for old_name, new_name in replacements.items():
3508
+ old_tag = f"<v {old_name}>"
3509
+ new_tag = f"<v {new_name}>"
3510
+ content = content.replace(old_tag, new_tag)
3511
+ log("post", f"Replaced speaker: {old_name!r} -> {new_name!r}")
3512
+
3513
+ with open(output_path, "w") as f:
3514
+ f.write(content)
3515
+
3516
+
3517
+ def _ensure_trailing_newline(output_path: str) -> None:
3518
+ """Ensure the VTT file ends with a trailing newline."""
3519
+ with open(output_path, "rb") as f:
3520
+ f.seek(-1, 2)
3521
+ if f.read(1) != b"\n":
3522
+ with open(output_path, "a") as fa:
3523
+ fa.write("\n")
3524
+
3525
+
3526
+ def _build_result_summary(
3527
+ config: dict,
3528
+ *,
3529
+ num_parts: int = 1,
3530
+ verification: dict | None = None,
3531
+ chat_count: int = 0,
3532
+ ) -> dict:
3533
+ """Build a structured result summary from config and verification data.
3534
+
3535
+ Used for both ``--format json`` output and ``--dry-run`` summaries.
3536
+ """
3537
+ summary: dict[str, object] = {"status": "ok"}
3538
+
3539
+ # Meeting metadata
3540
+ summary["meeting"] = config["meeting"]
3541
+
3542
+ time_text = config.get("meeting_time_text", "")
3543
+ if time_text:
3544
+ start_dt, end_dt = _parse_meeting_times(time_text, config["date"])
3545
+ summary["start"] = start_dt
3546
+ if end_dt:
3547
+ summary["end"] = end_dt
3548
+ else:
3549
+ summary["start"] = config["date"]
3550
+ if config.get("time"):
3551
+ summary["start"] = f"{config['date']}T{config['time']}"
3552
+
3553
+ # Members and organiser
3554
+ roster = config.get("roster_members", [])
3555
+ if roster:
3556
+ summary["members"] = sorted(m["name"] for m in roster)
3557
+ organisers = sorted(m["name"] for m in roster if m["role"] == "organiser")
3558
+ if organisers:
3559
+ summary["organiser"] = organisers[0] if len(organisers) == 1 else organisers
3560
+ else:
3561
+ speaker_map = config.get("speaker_map")
3562
+ if speaker_map:
3563
+ summary["members"] = sorted(speaker_map.values())
3564
+
3565
+ summary["parts"] = num_parts
3566
+
3567
+ # Verification data (populated after download, absent for dry-run)
3568
+ if verification and verification.get("valid"):
3569
+ summary["cues"] = verification["cue_count"]
3570
+ summary["speakers"] = verification["speakers"]
3571
+ summary["file_size"] = verification["file_size"]
3572
+ else:
3573
+ # Dry-run: include speaker names with zero counts
3574
+ speaker_map = config.get("speaker_map")
3575
+ if speaker_map:
3576
+ summary["speakers"] = dict.fromkeys(sorted(speaker_map.values()), 0)
3577
+
3578
+ if chat_count:
3579
+ summary["chat_messages"] = chat_count
3580
+
3581
+ return summary
3582
+
3583
+
3584
+ def verify_output(output_path: str) -> dict:
3585
+ """Verify the downloaded VTT file. Returns a summary dict."""
3586
+ if not os.path.exists(output_path):
3587
+ log("step8", f"ERROR: Output file does not exist: {output_path}")
3588
+ return {"valid": False, "error": "file not found"}
3589
+
3590
+ with open(output_path) as f:
3591
+ content = f.read()
3592
+
3593
+ if not content.startswith("WEBVTT"):
3594
+ log("step8", "ERROR: File does not start with WEBVTT header.")
3595
+ return {"valid": False, "error": "missing WEBVTT header"}
3596
+
3597
+ # Count cues
3598
+ cue_count = len(re.findall(r"\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3}", content))
3599
+
3600
+ # Extract speakers
3601
+ speaker_pattern = re.compile(r"^<v ([^>]+)>", re.MULTILINE)
3602
+ speakers = speaker_pattern.findall(content)
3603
+ speaker_counts = Counter(speakers)
3604
+
3605
+ file_size = os.path.getsize(output_path)
3606
+
3607
+ log("step8", f"File: {output_path}")
3608
+ log("step8", f"Size: {file_size} bytes")
3609
+ log("step8", f"Cues: {cue_count}")
3610
+ log("step8", f"Speakers ({len(speaker_counts)}):")
3611
+ for sp, cnt in speaker_counts.most_common():
3612
+ log("step8", f" {sp}: {cnt}")
3613
+
3614
+ return {
3615
+ "valid": True,
3616
+ "file_size": file_size,
3617
+ "cue_count": cue_count,
3618
+ "speakers": dict(speaker_counts),
3619
+ }
3620
+
3621
+
3622
+ # ---------------------------------------------------------------------------
3623
+ # Main orchestration
3624
+ # ---------------------------------------------------------------------------
3625
+
3626
+ MAX_SEARCH_RETRIES = 4
3627
+
3628
+
3629
+ async def main() -> None:
3630
+ _install_signal_handlers()
3631
+ args = parse_args()
3632
+ config = resolve_config(args)
3633
+
3634
+ log("main", f"Meeting: {config['meeting']}")
3635
+ datetime_label = config["date"]
3636
+ if config["time"]:
3637
+ datetime_label += f"T{config['time']}"
3638
+ log("main", f"Date: {datetime_label}")
3639
+ log("main", f"Output: {config['output']}")
3640
+ log("main", f"CDP port: {config['port']}")
3641
+ log("main", f"Model: {config['model']}")
3642
+ log("main", f"GCP project: {config['gcp_project']}")
3643
+ log("main", f"GCP region: {config['gcp_region']}")
3644
+
3645
+ # Step 1: Ensure Teams is running
3646
+ ensure_teams_running(config["port"], force=config["force_restart"])
3647
+
3648
+ # Step 2: Verify CDP
3649
+ verify_cdp(config["port"])
3650
+
3651
+ # Step 3: Find main Teams targets
3652
+ targets = find_teams_targets(config["port"])
3653
+ if not targets:
3654
+ _exit_error(
3655
+ EXIT_CDP_UNAVAILABLE,
3656
+ "NO_TARGETS",
3657
+ "No Teams page targets found via CDP.",
3658
+ step="step3",
3659
+ )
3660
+ log("step3", f"Found {len(targets)} Teams page target(s)")
3661
+ for t in targets:
3662
+ log("step3", f" {t['id'][:20]}... {t.get('title', '')[:60]}")
3663
+
3664
+ # Create agent client (used in Steps 4 and 6)
3665
+ agent_client = create_agent_client(config)
3666
+
3667
+ # Steps 4+5: Navigate to meeting and Recap > Transcript.
3668
+ # For recurring meetings the first search result may be the wrong chat
3669
+ # group (occurrence dropdown doesn't contain the target date). In that
3670
+ # case we retry with the next search result index, up to MAX_RETRIES.
3671
+ search_target_id = None
3672
+ result_index_override: int | None = None
3673
+
3674
+ for attempt in range(MAX_SEARCH_RETRIES):
3675
+ _check_shutdown()
3676
+ # Record existing iframe targets BEFORE each navigation so Step 6
3677
+ # can distinguish new targets from stale ones
3678
+ pre_nav_iframe_ids = {t["id"] for t in find_xplatplugins_targets(config["port"])}
3679
+ if pre_nav_iframe_ids:
3680
+ log("main", f"Pre-existing iframe targets: {len(pre_nav_iframe_ids)}")
3681
+
3682
+ # Step 4: Navigate to meeting
3683
+ search_target_id = await navigate_to_meeting(
3684
+ config["port"],
3685
+ targets,
3686
+ config,
3687
+ agent_client,
3688
+ result_index_override=result_index_override,
3689
+ )
3690
+
3691
+ if search_target_id is None:
3692
+ # Navigation failed (index out of range, or clicked a non-chat
3693
+ # result). Try the next search result index.
3694
+ if result_index_override is None:
3695
+ result_index_override = 1
3696
+ else:
3697
+ result_index_override += 1
3698
+ log(
3699
+ "main",
3700
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed "
3701
+ f"(navigation). Retrying with index {result_index_override}...",
3702
+ )
3703
+ continue
3704
+
3705
+ # Step 5: Navigate to Recap > Transcript
3706
+ transcript_ok = await navigate_to_transcript(
3707
+ config["port"], search_target_id, config, agent_client
3708
+ )
3709
+
3710
+ if transcript_ok:
3711
+ log("main", "Correct occurrence found and transcript tab loaded.")
3712
+ break
3713
+
3714
+ # Occurrence not found -- try the next search result
3715
+ if result_index_override is None:
3716
+ # First attempt used the agent's pick (typically index 0).
3717
+ # Start overriding from index 1.
3718
+ result_index_override = 1
3719
+ else:
3720
+ result_index_override += 1
3721
+
3722
+ log(
3723
+ "main",
3724
+ f"Attempt {attempt + 1}/{MAX_SEARCH_RETRIES} failed. "
3725
+ f"Retrying with search result index {result_index_override}...",
3726
+ )
3727
+ else:
3728
+ _exit_error(
3729
+ EXIT_MEETING_NOT_FOUND,
3730
+ "MEETING_NOT_FOUND",
3731
+ f"Could not find the target {datetime_label} in any "
3732
+ f"of the first {MAX_SEARCH_RETRIES} search results.",
3733
+ step="step5",
3734
+ )
3735
+
3736
+ # Extract the full chat member list from the roster popover.
3737
+ # This runs after step 5 so that we have the speaker_map for
3738
+ # name resolution.
3739
+ if search_target_id:
3740
+ roster_members = await _extract_roster_members(
3741
+ config["port"],
3742
+ search_target_id,
3743
+ config.get("speaker_map"),
3744
+ )
3745
+ config["roster_members"] = roster_members
3746
+
3747
+ # Build a combined GUID -> clean name map from both the roster
3748
+ # and the speaker_map. The roster covers all chat members
3749
+ # (including non-speakers), which lets us resolve reaction
3750
+ # authors and chat authors that the speaker_map alone cannot.
3751
+ member_map: dict[str, str] = {}
3752
+ for rm in roster_members:
3753
+ oid = rm.get("objectId", "")
3754
+ if oid and rm.get("name"):
3755
+ member_map[oid] = rm["name"]
3756
+ # Speaker map entries take priority (Recap canonical names).
3757
+ if config.get("speaker_map"):
3758
+ member_map.update(config["speaker_map"])
3759
+ config["member_map"] = member_map
3760
+
3761
+ # Detect multi-part meetings (Part 1, Part 2, etc.)
3762
+ num_parts = await _detect_part_tabs(config["port"], search_target_id)
3763
+ log("main", f"Meeting parts detected: {num_parts}")
3764
+
3765
+ # --dry-run: emit meeting metadata and exit without downloading.
3766
+ if config.get("dry_run"):
3767
+ summary = _build_result_summary(config, num_parts=num_parts)
3768
+ summary["dry_run"] = True
3769
+ if _format_json:
3770
+ print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
3771
+ else:
3772
+ log("main", f"Dry-run summary: {json.dumps(summary, ensure_ascii=False)}")
3773
+ sys.exit(EXIT_OK)
3774
+
3775
+ # Download each part's transcript.
3776
+ # Part 1 is already loaded (navigate_to_transcript clicked Transcript).
3777
+ # For subsequent parts we click the Part N tab, then Transcript.
3778
+ part_vtt_contents: list[str] = []
3779
+
3780
+ for part_num in range(1, num_parts + 1):
3781
+ _check_shutdown()
3782
+ part_label = f"Part {part_num}/{num_parts}"
3783
+
3784
+ if part_num > 1:
3785
+ # Navigate to Part N and its Transcript sub-tab
3786
+ log("main", f"Navigating to {part_label}...")
3787
+
3788
+ # Record iframe targets BEFORE clicking the new Part tab so
3789
+ # we can distinguish the new iframe from existing ones.
3790
+ pre_part_iframe_ids = {t["id"] for t in find_xplatplugins_targets(config["port"])}
3791
+
3792
+ ok = await _click_part_and_transcript(config["port"], search_target_id, part_num)
3793
+ if not ok:
3794
+ log("main", f"WARNING: Could not navigate to {part_label}. Skipping.")
3795
+ continue
3796
+
3797
+ # Use updated pre-nav set for iframe disambiguation
3798
+ pre_nav_iframe_ids = pre_part_iframe_ids
3799
+
3800
+ log("main", f"Downloading transcript for {part_label}...")
3801
+
3802
+ # Determine output path: use temp files for multi-part, direct for single
3803
+ if num_parts == 1:
3804
+ part_output = config["output"]
3805
+ else:
3806
+ part_output = f"{config['output']}.part{part_num}.tmp"
3807
+
3808
+ # Step 6: Find iframe target (pass pre-existing IDs for disambiguation)
3809
+ iframe_target_id = find_iframe_target(
3810
+ config["port"], config, agent_client, pre_nav_iframe_ids
3811
+ )
3812
+
3813
+ # Step 7: Check permission
3814
+ permission = await check_permission(config["port"], iframe_target_id)
3815
+
3816
+ if permission == "no_button":
3817
+ if num_parts == 1:
3818
+ _exit_error(
3819
+ EXIT_DOWNLOAD_FAILED,
3820
+ "DOWNLOAD_FAILED",
3821
+ "Download button not found. The transcript may not have loaded.",
3822
+ step="step7",
3823
+ )
3824
+ log("main", f"WARNING: No download button for {part_label}. Skipping.")
3825
+ continue
3826
+
3827
+ # Step 7A or 7B based on permission
3828
+ success = False
3829
+ if permission == "has_permission":
3830
+ log("main", f"{part_label}: Using Approach A (native blob interception)")
3831
+ success = await download_native_vtt(config["port"], iframe_target_id, part_output)
3832
+ else:
3833
+ log("main", f"{part_label}: Using Approach B (DOM extraction)")
3834
+ success = await _extract_via_react_props(
3835
+ config["port"],
3836
+ iframe_target_id,
3837
+ part_output,
3838
+ speaker_map=config.get("speaker_map"),
3839
+ )
3840
+ if success is None:
3841
+ log("main", f"{part_label}: React props unavailable, falling back to scroll")
3842
+ success = await extract_dom_vtt(config["port"], iframe_target_id, part_output)
3843
+
3844
+ if not success:
3845
+ if num_parts == 1:
3846
+ _exit_error(
3847
+ EXIT_DOWNLOAD_FAILED,
3848
+ "DOWNLOAD_FAILED",
3849
+ "Transcript download failed.",
3850
+ step="step7",
3851
+ )
3852
+ log("main", f"WARNING: Download failed for {part_label}. Skipping.")
3853
+ continue
3854
+
3855
+ # Apply speaker mapping to this part before merging
3856
+ speaker_map = config.get("speaker_map")
3857
+ if speaker_map:
3858
+ _apply_speaker_mapping_to_vtt(part_output, speaker_map)
3859
+
3860
+ with open(part_output) as f:
3861
+ part_vtt_contents.append(f.read())
3862
+
3863
+ log("main", f"{part_label}: downloaded successfully")
3864
+
3865
+ if not part_vtt_contents:
3866
+ _exit_error(
3867
+ EXIT_DOWNLOAD_FAILED,
3868
+ "DOWNLOAD_FAILED",
3869
+ "No transcript parts were downloaded.",
3870
+ step="step7",
3871
+ )
3872
+
3873
+ # Merge parts if multi-part meeting
3874
+ if num_parts > 1:
3875
+ log("main", f"Merging {len(part_vtt_contents)} parts...")
3876
+ merged = _merge_vtt_parts(part_vtt_contents)
3877
+ with open(config["output"], "w") as f:
3878
+ f.write(merged)
3879
+ log("main", f"Merged VTT written to {config['output']}")
3880
+
3881
+ # Clean up temp files
3882
+ for part_num in range(1, num_parts + 1):
3883
+ tmp_path = f"{config['output']}.part{part_num}.tmp"
3884
+ if os.path.exists(tmp_path):
3885
+ os.remove(tmp_path)
3886
+
3887
+ # Insert VTT metadata comment block (meeting title, attendees, times)
3888
+ _prepend_vtt_metadata(config["output"], config)
3889
+
3890
+ _check_shutdown()
3891
+
3892
+ # Extract meeting chat messages and append to VTT
3893
+ if search_target_id:
3894
+ # Compute meeting start datetime for offset calculation
3895
+ time_text = config.get("meeting_time_text", "")
3896
+ if time_text:
3897
+ meeting_start, _ = _parse_meeting_times(time_text, config["date"])
3898
+ elif config.get("time"):
3899
+ meeting_start = f"{config['date']}T{config['time']}"
3900
+ else:
3901
+ meeting_start = config["date"]
3902
+
3903
+ chat_messages = await _extract_chat_messages(config["port"], search_target_id, config)
3904
+ if chat_messages:
3905
+ _append_chat_to_vtt(config["output"], chat_messages, meeting_start)
3906
+ chat_count = len(chat_messages)
3907
+ else:
3908
+ log("main", "No chat messages found for this meeting occurrence.")
3909
+ chat_count = 0
3910
+ else:
3911
+ chat_count = 0
3912
+
3913
+ # Ensure file ends with a trailing newline (WebVTT spec recommendation).
3914
+ _ensure_trailing_newline(config["output"])
3915
+
3916
+ # Step 8: Verify
3917
+ result = verify_output(config["output"])
3918
+ if result["valid"]:
3919
+ log("main", "Transcript download complete.")
3920
+ else:
3921
+ _exit_error(
3922
+ EXIT_VERIFICATION_FAILED,
3923
+ "VERIFICATION_FAILED",
3924
+ f"Output file verification failed: {result.get('error')}",
3925
+ step="step8",
3926
+ )
3927
+
3928
+ # --- Emit final output ---------------------------------------------------
3929
+ # Behaviour matrix:
3930
+ # --output PATH --format text → file written, nothing on stdout
3931
+ # --output PATH --format json → file written, JSON summary on stdout
3932
+ # (no --output) --format text → VTT content on stdout
3933
+ # (no --output) --format json → JSON summary with embedded VTT on stdout
3934
+
3935
+ output_to_stdout = config.get("output_to_stdout", False)
3936
+
3937
+ if _format_json:
3938
+ summary = _build_result_summary(
3939
+ config,
3940
+ num_parts=num_parts,
3941
+ verification=result,
3942
+ chat_count=chat_count,
3943
+ )
3944
+ if output_to_stdout:
3945
+ # Embed full VTT content in JSON
3946
+ with open(config["output"]) as f:
3947
+ summary["vtt"] = f.read()
3948
+ else:
3949
+ summary["file"] = config["output"]
3950
+ print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
3951
+ elif output_to_stdout:
3952
+ # Raw VTT to stdout
3953
+ with open(config["output"]) as f:
3954
+ sys.stdout.write(f.read())
3955
+ sys.stdout.flush()
3956
+
3957
+ # Clean up temp file when output was piped to stdout
3958
+ if output_to_stdout:
3959
+ with contextlib.suppress(OSError):
3960
+ os.remove(config["output"])
3961
+
3962
+
3963
+ def main_sync() -> None:
3964
+ """Synchronous entry point for console_scripts."""
3965
+ asyncio.run(main())
3966
+
3967
+
3968
+ if __name__ == "__main__":
3969
+ main_sync()