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,449 @@
1
+ """CDP HTTP helpers and target discovery.
2
+
3
+ Provides functions for interacting with CDP's HTTP endpoints and
4
+ discovering specific browser targets (Teams pages, SharePoint iframes).
5
+
6
+ Extracted from ``transcript_download.py`` lines 580-588 (cdp_http_get),
7
+ 793-809 (find_teams_targets), 817-829 (find_search_target),
8
+ 768-785 (verify_cdp), 1897-1908 (find_xplatplugins_targets),
9
+ 1911-1993 (find_iframe_target).
10
+
11
+ Functions that were both I/O and filtering have been split:
12
+ * Pure filtering: ``filter_teams_targets``, ``filter_xplatplugins_targets``
13
+ * I/O orchestrators: ``find_teams_targets``, ``find_xplatplugins_targets``,
14
+ ``find_search_target``, ``find_iframe_target``, ``verify_cdp``
15
+
16
+ Schema version: 1.0.0
17
+ Created: 2026-04-25
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import typing
24
+ import urllib.request
25
+
26
+ from teams_transcripts.adapters.cdp.connection import connect_to_target
27
+ from teams_transcripts.infrastructure.errors import (
28
+ EXIT_CDP_UNAVAILABLE,
29
+ EXIT_DOWNLOAD_FAILED,
30
+ exit_error,
31
+ )
32
+
33
+ if typing.TYPE_CHECKING:
34
+ from teams_transcripts.domain.models import MeetingConfig
35
+ from teams_transcripts.infrastructure.logging import Logger
36
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # HTTP helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def cdp_http_get(port: int, path: str) -> dict | list | None:
45
+ """GET a CDP HTTP endpoint, return parsed JSON or ``None`` on failure.
46
+
47
+ Only connects to 127.0.0.1 -- the URL is constructed internally and
48
+ never from external input.
49
+
50
+ Args:
51
+ port: CDP debugging port number.
52
+ path: URL path (e.g. ``"/json/version"``).
53
+
54
+ Returns:
55
+ Parsed JSON (dict or list), or ``None`` if the request failed.
56
+
57
+ Raises:
58
+ ValueError: If *port* is not in the valid range or *path* does
59
+ not start with ``/``.
60
+ """
61
+ if not (1 <= port <= 65535):
62
+ msg = f"Port out of range: {port}"
63
+ raise ValueError(msg)
64
+ if not path.startswith("/"):
65
+ msg = f"Path must start with '/': {path!r}"
66
+ raise ValueError(msg)
67
+ url = f"http://127.0.0.1:{port}{path}"
68
+ try:
69
+ with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 -- localhost only
70
+ return json.loads(resp.read())
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Target filtering (pure)
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def filter_teams_targets(targets: list[dict]) -> list[dict]:
81
+ """Filter a target list to main Teams v2 page targets.
82
+
83
+ Excludes service workers and other non-page targets.
84
+
85
+ Args:
86
+ targets: Raw CDP target list from ``/json``.
87
+
88
+ Returns:
89
+ Filtered list of Teams page targets.
90
+ """
91
+ results = []
92
+ for t in targets:
93
+ url = t.get("url", "")
94
+ is_teams_v2 = "teams.microsoft.com/v2" in url
95
+ is_teams_live = "teams.live.com/v2" in url
96
+ is_m365_chat = "microsoft365.com/chat" in url
97
+ is_page = "worker" not in url
98
+ if (is_teams_v2 or is_teams_live or is_m365_chat) and is_page:
99
+ results.append(t)
100
+ return results
101
+
102
+
103
+ def filter_xplatplugins_targets(targets: list[dict]) -> list[dict]:
104
+ """Filter a target list to SharePoint transcript iframe targets.
105
+
106
+ Matches targets whose URL contains both ``sharepoint`` and
107
+ ``xplatplugins``. This is the only pattern observed in production
108
+ for the Teams transcript viewer iframe (hosted at
109
+ ``/_layouts/15/xplatplugins.aspx``).
110
+
111
+ The filter is deliberately narrow to avoid false positives from
112
+ other SharePoint iframes (SPFx portals, OneDrive file browser,
113
+ Stream embeds, etc.) which would cause iframe disambiguation
114
+ failures.
115
+
116
+ Args:
117
+ targets: Raw CDP target list from ``/json``.
118
+
119
+ Returns:
120
+ Filtered list of SharePoint transcript iframe targets.
121
+ """
122
+ matches = []
123
+ for t in targets:
124
+ url = t.get("url", "")
125
+ if "sharepoint" in url and "xplatplugins" in url:
126
+ matches.append(t)
127
+ return matches
128
+
129
+
130
+ def filter_worker_targets(targets: list[dict], tenant_id: str | None = None) -> list[dict]:
131
+ """Filter a target list to Teams precompiled web worker targets.
132
+
133
+ Workers are the per-tenant background threads that host the GraphQL
134
+ handlers, MCPS workflow starters, and token acquisition services.
135
+ They have ``type: "worker"`` in the CDP target list and a URL
136
+ containing ``precompiled-web-worker``.
137
+
138
+ Args:
139
+ targets: Raw CDP target list from ``/json``.
140
+ tenant_id: Optional tenant ID to prefer. When provided,
141
+ workers whose URL contains this tenant ID are returned
142
+ first, followed by other matching workers.
143
+
144
+ Returns:
145
+ Filtered list of worker target dicts, ordered by tenant
146
+ match preference (tenant-matching first).
147
+ """
148
+ tenant_matches = []
149
+ other_matches = []
150
+ for t in targets:
151
+ url = t.get("url", "")
152
+ target_type = t.get("type", "")
153
+ is_worker = "precompiled-web-worker" in url or target_type == "other"
154
+ if not is_worker:
155
+ continue
156
+ if tenant_id and tenant_id in url:
157
+ tenant_matches.append(t)
158
+ else:
159
+ other_matches.append(t)
160
+ return tenant_matches + other_matches
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # I/O orchestrators
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def find_teams_targets(
169
+ port: int,
170
+ *,
171
+ logger: Logger,
172
+ format_json: bool,
173
+ ) -> list[dict]:
174
+ """Discover main Teams page targets via CDP HTTP.
175
+
176
+ Calls ``cdp_http_get`` and filters the result. Exits with
177
+ ``CDP_UNAVAILABLE`` if no targets can be listed.
178
+
179
+ Args:
180
+ port: CDP debugging port.
181
+ logger: Logger instance.
182
+ format_json: Whether to format errors as JSON.
183
+
184
+ Returns:
185
+ List of Teams page target dicts.
186
+ """
187
+ raw_targets = cdp_http_get(port, "/json")
188
+ if not raw_targets or not isinstance(raw_targets, list):
189
+ exit_error(
190
+ EXIT_CDP_UNAVAILABLE,
191
+ "CDP_UNAVAILABLE",
192
+ "Could not list CDP targets.",
193
+ step="step3",
194
+ format_json=format_json,
195
+ )
196
+
197
+ return filter_teams_targets(raw_targets)
198
+
199
+
200
+ def find_worker_targets(
201
+ port: int,
202
+ *,
203
+ logger: Logger,
204
+ tenant_id: str | None = None,
205
+ ) -> list[dict]:
206
+ """Discover Teams web worker targets via CDP HTTP.
207
+
208
+ Args:
209
+ port: CDP debugging port.
210
+ logger: Logger instance.
211
+ tenant_id: Optional tenant ID to filter workers by.
212
+
213
+ Returns:
214
+ List of worker target dicts (may be empty).
215
+ """
216
+ raw_targets = cdp_http_get(port, "/json")
217
+ if not raw_targets or not isinstance(raw_targets, list):
218
+ return []
219
+ workers = filter_worker_targets(raw_targets, tenant_id=tenant_id)
220
+ logger.log("list-transcripts", f"Found {len(workers)} worker target(s)")
221
+ return workers
222
+
223
+
224
+ def find_xplatplugins_targets_io(port: int) -> list[dict]:
225
+ """Discover xplatplugins iframe targets via CDP HTTP.
226
+
227
+ Args:
228
+ port: CDP debugging port.
229
+
230
+ Returns:
231
+ List of xplatplugins target dicts (may be empty).
232
+ """
233
+ raw_targets = cdp_http_get(port, "/json")
234
+ if not raw_targets or not isinstance(raw_targets, list):
235
+ return []
236
+ return filter_xplatplugins_targets(raw_targets)
237
+
238
+
239
+ def _search_target_sort_key(target: dict) -> tuple[int, str]:
240
+ """Sort key that prioritises the primary Teams page over tenant pages.
241
+
242
+ The primary Teams page (titled ``"Microsoft Teams"``) is the most
243
+ stable target for keyboard-driven navigation. Tenant-specific
244
+ pages (e.g. ``"Central Hub | insidemedia.net"``) can navigate away
245
+ when ``Ctrl+3`` is sent, destroying the WebSocket connection.
246
+
247
+ Returns a ``(priority, title)`` tuple so targets with the same
248
+ priority are ordered alphabetically by title for determinism.
249
+ """
250
+ title = target.get("title", "")
251
+ if title == "Microsoft Teams":
252
+ return (0, title)
253
+ return (1, title)
254
+
255
+
256
+ async def find_search_target(
257
+ port: int,
258
+ targets: list[dict],
259
+ *,
260
+ logger: Logger,
261
+ ) -> str | None:
262
+ """Find which CDP target has the Teams search input element.
263
+
264
+ Targets are checked in priority order: pages titled
265
+ ``"Microsoft Teams"`` are tried first because they are the most
266
+ stable target for subsequent keyboard navigation.
267
+
268
+ Args:
269
+ port: CDP debugging port.
270
+ targets: List of candidate target dicts.
271
+ logger: Logger instance.
272
+
273
+ Returns:
274
+ The target ID with the search input, or ``None``.
275
+ """
276
+ sorted_targets = sorted(targets, key=_search_target_sort_key)
277
+ for t in sorted_targets:
278
+ target_id = await _check_target_has_search(port, t, logger)
279
+ if target_id is not None:
280
+ logger.log("step4", f"Search input found on target: {target_id[:20]}...")
281
+ return target_id
282
+ return None
283
+
284
+
285
+ async def _check_target_has_search(port: int, target: dict, logger: Logger) -> str | None:
286
+ """Check whether a single CDP target contains the search input.
287
+
288
+ Returns the target ID if found, ``None`` otherwise. Connection
289
+ failures and timeouts are logged and treated as "not found".
290
+ """
291
+ try:
292
+ ws, cdp = await connect_to_target(port, target["id"])
293
+ async with ws:
294
+ result = await cdp.evaluate("!!document.querySelector('#ms-searchux-input')")
295
+ if result is True:
296
+ return target["id"]
297
+ except Exception as exc:
298
+ logger.log("step4", f"Target {target['id'][:20]}... check failed: {exc}")
299
+ return None
300
+
301
+
302
+ def verify_cdp(
303
+ port: int,
304
+ *,
305
+ logger: Logger,
306
+ shutdown: ShutdownCoordinator,
307
+ format_json: bool,
308
+ ) -> str:
309
+ """Verify CDP is responding and return the browser version string.
310
+
311
+ Retries up to 5 times with 3-second intervals. Exits with
312
+ ``CDP_UNAVAILABLE`` if all attempts fail.
313
+
314
+ Args:
315
+ port: CDP debugging port.
316
+ logger: Logger instance.
317
+ shutdown: Shutdown coordinator for interruptibility.
318
+ format_json: Whether to format errors as JSON.
319
+
320
+ Returns:
321
+ Browser version string (e.g. ``"Chrome/123.0.6312.86"``).
322
+ """
323
+ for attempt in range(5):
324
+ shutdown.check_shutdown()
325
+ version = cdp_http_get(port, "/json/version")
326
+ if isinstance(version, dict) and "Browser" in version:
327
+ browser = version["Browser"]
328
+ logger.log("step2", f"CDP verified: {browser}")
329
+ return browser
330
+ logger.log("step2", f"Attempt {attempt + 1}/5 -- waiting...")
331
+ shutdown.interruptible_sleep_sync(3)
332
+
333
+ # exit_error calls sys.exit (typed NoReturn) -- unreachable beyond this point
334
+ exit_error(
335
+ EXIT_CDP_UNAVAILABLE,
336
+ "CDP_UNAVAILABLE",
337
+ "CDP did not respond after 5 attempts. Check Teams is running.",
338
+ step="step2",
339
+ format_json=format_json,
340
+ )
341
+ raise SystemExit(EXIT_CDP_UNAVAILABLE)
342
+
343
+
344
+ def find_iframe_target(
345
+ port: int,
346
+ *,
347
+ config: MeetingConfig,
348
+ agent_client: typing.Any,
349
+ logger: Logger,
350
+ shutdown: ShutdownCoordinator,
351
+ pre_nav_iframe_ids: set[str] | None = None,
352
+ allow_missing: bool = False,
353
+ ) -> str | None:
354
+ """Find the correct xplatplugins iframe target.
355
+
356
+ Uses the LLM agent if multiple candidates are found and
357
+ disambiguation is needed. Falls back to the most recent target.
358
+
359
+ Args:
360
+ port: CDP debugging port.
361
+ config: Meeting configuration.
362
+ agent_client: Vertex AI agent client.
363
+ logger: Logger instance.
364
+ shutdown: Shutdown coordinator.
365
+ pre_nav_iframe_ids: Set of iframe target IDs that existed
366
+ before navigation (to prefer newly created targets).
367
+ allow_missing: When ``True``, return ``None`` instead of
368
+ calling :func:`exit_error` when no targets are found.
369
+ Used by the multi-part download loop to retry navigation
370
+ rather than hard-exiting.
371
+
372
+ Returns:
373
+ The selected target ID, or ``None`` when *allow_missing* is
374
+ set and no targets were found.
375
+ """
376
+ from teams_transcripts.adapters.vertex import agent_text_query
377
+
378
+ logger.log("step6", "Looking for transcript iframe target...")
379
+ pre_nav_iframe_ids = pre_nav_iframe_ids or set()
380
+
381
+ # Retry as the iframe may still be loading
382
+ matches: list[dict] = []
383
+ new_matches: list[dict] = []
384
+ for attempt in range(10):
385
+ shutdown.check_shutdown()
386
+ matches = find_xplatplugins_targets_io(port)
387
+ new_matches = [t for t in matches if t["id"] not in pre_nav_iframe_ids]
388
+ if new_matches:
389
+ break
390
+ if matches and attempt >= 5:
391
+ logger.log("step6", "No new iframe targets appeared. Using existing targets.")
392
+ new_matches = matches
393
+ break
394
+ label = "new iframe" if matches else "any iframe"
395
+ logger.log("step6", f"Waiting for {label} target (attempt {attempt + 1}/10)...")
396
+ shutdown.interruptible_sleep_sync(2)
397
+
398
+ candidates = new_matches or matches
399
+
400
+ if not candidates:
401
+ if allow_missing:
402
+ logger.log("step6", "No iframe targets found (will retry).")
403
+ return None
404
+ exit_error(
405
+ EXIT_DOWNLOAD_FAILED,
406
+ "IFRAME_NOT_FOUND",
407
+ "No xplatplugins iframe target found. "
408
+ "Check that Step 5 completed and the Transcript tab loaded.",
409
+ step="step6",
410
+ format_json=config.format_json,
411
+ )
412
+
413
+ if len(candidates) == 1:
414
+ tid = candidates[0]["id"]
415
+ is_new = tid not in pre_nav_iframe_ids
416
+ logger.log("step6", f"{'New' if is_new else 'Existing'} iframe target: {tid[:20]}...")
417
+ return tid
418
+
419
+ # Multiple targets -- ask the agent
420
+ logger.log("step6", f"{len(candidates)} iframe targets to choose from. Asking agent...")
421
+
422
+ target_list = "\n".join(
423
+ f" [{i}] ID: {t['id']}, URL: {t['url'][:200]}" for i, t in enumerate(candidates)
424
+ )
425
+ prompt = (
426
+ f"Multiple SharePoint iframe targets were found in a Teams CDP session. "
427
+ f"I am looking for the transcript of the meeting "
428
+ f"'{config.meeting}' on {config.date}.\n\n"
429
+ f"Targets:\n{target_list}\n\n"
430
+ f"Which target most likely corresponds to this meeting? "
431
+ f"Look at the URL paths for clues (e.g., organiser name, meeting context). "
432
+ f'Return JSON: {{"target_id": "<full target ID>"}} '
433
+ f"for the best match."
434
+ )
435
+
436
+ result = agent_text_query(agent_client, config.model, prompt, logger=logger)
437
+
438
+ if result and result.get("target_id"):
439
+ for t in candidates:
440
+ if t["id"] == result["target_id"]:
441
+ logger.log("step6", f"Agent selected target: {t['id'][:20]}...")
442
+ return t["id"]
443
+ for t in candidates:
444
+ if result["target_id"] in t["id"] or t["id"] in result["target_id"]:
445
+ logger.log("step6", f"Agent selected target (partial match): {t['id'][:20]}...")
446
+ return t["id"]
447
+
448
+ logger.log("step6", "Agent could not disambiguate. Using most recent target.")
449
+ return candidates[-1]["id"]