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,826 @@
1
+ """Meeting transcript listing service via MCPS worker injection.
2
+
3
+ Connects to a Teams web worker via CDP, injects JavaScript to call the
4
+ MCPS ``startListArtifactsByUserWorkflow`` API, and returns structured
5
+ meeting artifact data. Day queries are dispatched concurrently (up to 5
6
+ in parallel) for performance.
7
+
8
+ Public API:
9
+ run_list_transcripts(port, days, format_json, output_path, logger, shutdown)
10
+ resolve_meeting_id(port, meeting_id, logger, shutdown, tenant_id) -> (str, str, str)
11
+ resolve_call_id(port, call_id, logger, shutdown, tenant_id) -> (str, str, str)
12
+
13
+ Schema version: 1.1.0
14
+ Created: 2026-04-28
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import datetime as dt
22
+ import typing
23
+ import uuid
24
+
25
+ import websockets
26
+
27
+ from teams_transcripts.domain.mcps import (
28
+ build_thread_id,
29
+ extract_meeting_id,
30
+ format_artifacts_json,
31
+ format_artifacts_table,
32
+ parse_mcps_response,
33
+ )
34
+
35
+ if typing.TYPE_CHECKING:
36
+ from teams_transcripts.adapters.cdp.connection import WebSocketLike
37
+ from teams_transcripts.domain.models import MeetingArtifact
38
+ from teams_transcripts.infrastructure.logging import Logger
39
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Constants
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _MAX_CONCURRENT_DAYS = 5
47
+ """Maximum number of day queries running in parallel."""
48
+
49
+ _MCPS_PAGE_SIZE = 20
50
+ """Number of results per MCPS page."""
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Concurrent CDP connection (multiplexed)
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ class _MuxCDPConnection:
59
+ """A concurrency-safe CDP connection that multiplexes requests.
60
+
61
+ Unlike :class:`~adapters.cdp.connection.CDPConnection` (which uses a
62
+ simple recv loop per request and is not safe for concurrent use), this
63
+ class runs a single background recv loop that dispatches responses to
64
+ the correct waiting future by message ID.
65
+
66
+ This allows multiple coroutines to call :meth:`send` concurrently on
67
+ the same WebSocket connection.
68
+ """
69
+
70
+ def __init__(self, ws: WebSocketLike) -> None:
71
+ self._ws = ws
72
+ self._msg_id = 0
73
+ self._pending: dict[int, asyncio.Future[dict]] = {}
74
+ self._recv_task: asyncio.Task[None] | None = None
75
+
76
+ def start(self) -> None:
77
+ """Start the background receive loop."""
78
+ self._recv_task = asyncio.get_event_loop().create_task(self._recv_loop())
79
+
80
+ async def stop(self) -> None:
81
+ """Stop the background receive loop."""
82
+ if self._recv_task:
83
+ self._recv_task.cancel()
84
+ with contextlib.suppress(asyncio.CancelledError):
85
+ await self._recv_task
86
+
87
+ async def _recv_loop(self) -> None:
88
+ """Background loop that reads messages and dispatches to futures."""
89
+ import json
90
+
91
+ try:
92
+ while True:
93
+ raw = await self._ws.recv()
94
+ data = json.loads(raw)
95
+ msg_id = data.get("id")
96
+ if msg_id is not None and msg_id in self._pending:
97
+ self._pending[msg_id].set_result(data)
98
+ del self._pending[msg_id]
99
+ except asyncio.CancelledError:
100
+ raise
101
+ except Exception:
102
+ # Connection closed or error — cancel all pending futures
103
+ for fut in self._pending.values():
104
+ if not fut.done():
105
+ fut.cancel()
106
+ self._pending.clear()
107
+
108
+ async def send(
109
+ self,
110
+ method: str,
111
+ params: dict | None = None,
112
+ timeout: float = 30,
113
+ ) -> dict:
114
+ """Send a CDP command and wait for the matching response.
115
+
116
+ Safe to call concurrently from multiple coroutines.
117
+ """
118
+ import json
119
+
120
+ self._msg_id += 1
121
+ mid = self._msg_id
122
+ future: asyncio.Future[dict] = asyncio.get_event_loop().create_future()
123
+ self._pending[mid] = future
124
+
125
+ await self._ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
126
+ try:
127
+ return await asyncio.wait_for(future, timeout=timeout)
128
+ except (asyncio.TimeoutError, asyncio.CancelledError):
129
+ self._pending.pop(mid, None)
130
+ future.cancel()
131
+ raise
132
+
133
+
134
+ _JS_RESOLVE_CONTEXT = """\
135
+ (async () => {
136
+ const ctx = workerServer._stateAndRequestHandlers
137
+ .get('graphql').requestHandler.contextValue;
138
+ const ews = ctx.managers.artifactsPlatformManager
139
+ .managerContext.workflowStarters.existingWorkflowStarter;
140
+
141
+ // Resolve UPN and tenant from Graph token JWT payload
142
+ let upn = null;
143
+ let tid = null;
144
+ try {
145
+ const token = await ctx.discoverService.aad.acquireToken(
146
+ 'https://graph.microsoft.com'
147
+ );
148
+ if (token) {
149
+ const tokenStr = typeof token === 'string' ? token : token.token;
150
+ if (tokenStr) {
151
+ const parts = tokenStr.split('.');
152
+ const payload = JSON.parse(atob(parts[1]));
153
+ upn = payload.upn || payload.preferred_username
154
+ || payload.unique_name || null;
155
+ tid = payload.tid || null;
156
+ }
157
+ }
158
+ } catch(e) {}
159
+
160
+ return { hasEws: !!ews, upn: upn, tid: tid };
161
+ })()
162
+ """
163
+
164
+ _JS_QUERY_DAY_TEMPLATE = """\
165
+ (async () => {{
166
+ const ctx = workerServer._stateAndRequestHandlers
167
+ .get('graphql').requestHandler.contextValue;
168
+ const ews = ctx.managers.artifactsPlatformManager
169
+ .managerContext.workflowStarters.existingWorkflowStarter;
170
+
171
+ const results = [];
172
+ let skiptoken = undefined;
173
+
174
+ while (true) {{
175
+ const params = {{
176
+ correlationId: '{correlation_id}',
177
+ top: {page_size},
178
+ userUpn: '{upn}',
179
+ startDateTime: '{start_dt}',
180
+ endDateTime: '{end_dt}',
181
+ includeTypes: 'TranscriptV2',
182
+ callerApp: 'TeamsRecapPodcastDesktop',
183
+ }};
184
+ if (skiptoken) params.skiptoken = skiptoken;
185
+
186
+ const resp = await ews.startListArtifactsByUserWorkflow(params);
187
+ if (!resp.value || resp.value.length === 0) break;
188
+ results.push(...resp.value);
189
+
190
+ if (!resp.paginationInfo || !resp.paginationInfo.hasNextPage) break;
191
+ skiptoken = resp.paginationInfo.skipToken;
192
+ }}
193
+
194
+ return {{ value: results }};
195
+ }})()
196
+ """
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Internal helpers
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def _build_day_query_js(upn: str, query_date: dt.date) -> str:
205
+ """Build the JavaScript expression to query a single day.
206
+
207
+ Args:
208
+ upn: User principal name (email).
209
+ query_date: The date to query.
210
+
211
+ Returns:
212
+ JavaScript expression string for Runtime.evaluate.
213
+ """
214
+ start_dt = f"{query_date.isoformat()}T00:00:00Z"
215
+ end_dt = f"{(query_date + dt.timedelta(days=1)).isoformat()}T00:00:00Z"
216
+ correlation_id = str(uuid.uuid4())
217
+
218
+ return _JS_QUERY_DAY_TEMPLATE.format(
219
+ correlation_id=correlation_id,
220
+ page_size=_MCPS_PAGE_SIZE,
221
+ upn=upn,
222
+ start_dt=start_dt,
223
+ end_dt=end_dt,
224
+ )
225
+
226
+
227
+ async def _query_day(
228
+ cdp: _MuxCDPConnection,
229
+ upn: str,
230
+ query_date: dt.date,
231
+ semaphore: asyncio.Semaphore,
232
+ logger: Logger,
233
+ ) -> list[MeetingArtifact]:
234
+ """Query a single day with pagination, gated by semaphore.
235
+
236
+ Args:
237
+ cdp: Active CDP connection to the worker target.
238
+ upn: User principal name.
239
+ query_date: The date to query.
240
+ semaphore: Concurrency limiter.
241
+ logger: Logger instance.
242
+
243
+ Returns:
244
+ List of meeting artifacts for the queried day.
245
+ """
246
+ async with semaphore:
247
+ js = _build_day_query_js(upn, query_date)
248
+ resp = await cdp.send(
249
+ "Runtime.evaluate",
250
+ {
251
+ "expression": js,
252
+ "awaitPromise": True,
253
+ "returnByValue": True,
254
+ },
255
+ timeout=30,
256
+ )
257
+
258
+ # Extract the result value
259
+ result = resp.get("result", {}).get("result", {}).get("value")
260
+ if not result or not isinstance(result, dict):
261
+ # Check for exceptions
262
+ exception_details = resp.get("result", {}).get("exceptionDetails")
263
+ if exception_details:
264
+ text = exception_details.get("text", "unknown error")
265
+ logger.log(
266
+ "list-transcripts",
267
+ f" {query_date}: query failed ({text})",
268
+ )
269
+ return []
270
+
271
+ artifacts = parse_mcps_response(result, query_date)
272
+ if artifacts:
273
+ logger.log(
274
+ "list-transcripts",
275
+ f" {query_date}: {len(artifacts)} meeting(s)",
276
+ )
277
+ return artifacts
278
+
279
+
280
+ async def _resolve_upn(
281
+ cdp: _MuxCDPConnection,
282
+ tenant_id: str | None = None,
283
+ ) -> str | None:
284
+ """Resolve the user principal name from the worker context.
285
+
286
+ Args:
287
+ cdp: Active CDP connection to the worker target.
288
+ tenant_id: If provided, only return UPN when the worker's
289
+ JWT tenant claim matches this prefix.
290
+
291
+ Returns:
292
+ UPN string, or None if resolution failed or tenant mismatch.
293
+ """
294
+ resp = await cdp.send(
295
+ "Runtime.evaluate",
296
+ {
297
+ "expression": _JS_RESOLVE_CONTEXT,
298
+ "awaitPromise": True,
299
+ "returnByValue": True,
300
+ },
301
+ timeout=15,
302
+ )
303
+ result = resp.get("result", {}).get("result", {}).get("value")
304
+ if not result or not isinstance(result, dict):
305
+ return None
306
+ if not result.get("hasEws"):
307
+ return None
308
+ # Tenant check: if requested, verify this worker belongs to the right tenant
309
+ if tenant_id:
310
+ tid = result.get("tid") or ""
311
+ if not tid.startswith(tenant_id):
312
+ return None
313
+ return result.get("upn") or None
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Public API
318
+ # ---------------------------------------------------------------------------
319
+
320
+
321
+ async def resolve_meeting_id(
322
+ port: int,
323
+ meeting_id: str,
324
+ *,
325
+ logger: Logger,
326
+ shutdown: ShutdownCoordinator,
327
+ format_json: bool = False,
328
+ tenant_id: str | None = None,
329
+ force_restart: bool = False,
330
+ ) -> tuple[str, str, str]:
331
+ """Resolve a meeting UUID to its subject, date, and thread_id via MCPS.
332
+
333
+ Connects to the Teams worker, queries a date range (up to 90 days back)
334
+ to find the meeting entry whose thread_id matches the given UUID, and
335
+ returns the subject string, date, and full thread_id.
336
+
337
+ The returned ``thread_id`` enables direct navigation to the meeting
338
+ chat without the search-click-verify cycle.
339
+
340
+ Args:
341
+ port: CDP debugging port number.
342
+ meeting_id: Meeting UUID (e.g. ``2b4c1f5c-...``).
343
+ logger: Logger instance.
344
+ shutdown: Shutdown coordinator.
345
+ format_json: Whether to emit JSON error output.
346
+ tenant_id: Optional tenant ID prefix to filter workers.
347
+
348
+ Returns:
349
+ A tuple of ``(subject, date_str, thread_id)`` where ``date_str``
350
+ is ISO format (``YYYY-MM-DD``) and ``thread_id`` is the full
351
+ Teams thread identifier (e.g. ``19:meeting_...@thread.v2``).
352
+
353
+ Raises:
354
+ SystemExit: If the meeting cannot be found or CDP is unavailable.
355
+ """
356
+ from teams_transcripts.adapters.cdp.helpers import (
357
+ cdp_http_get,
358
+ filter_worker_targets,
359
+ verify_cdp,
360
+ )
361
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
362
+ from teams_transcripts.infrastructure.errors import (
363
+ EXIT_CDP_UNAVAILABLE,
364
+ EXIT_MEETING_NOT_FOUND,
365
+ exit_error,
366
+ )
367
+
368
+ logger.log("resolve-id", f"Resolving meeting ID {meeting_id[:12]}...")
369
+
370
+ # Ensure Teams is running and CDP is available
371
+ ensure_teams_running(
372
+ port,
373
+ logger=logger,
374
+ shutdown=shutdown,
375
+ format_json=format_json,
376
+ force=force_restart,
377
+ )
378
+ verify_cdp(port, logger=logger, shutdown=shutdown, format_json=format_json)
379
+
380
+ # Discover workers
381
+ raw_targets = cdp_http_get(port, "/json")
382
+ if not raw_targets or not isinstance(raw_targets, list):
383
+ exit_error(
384
+ EXIT_CDP_UNAVAILABLE,
385
+ "CDP_UNAVAILABLE",
386
+ "Could not list CDP targets.",
387
+ step="resolve-id",
388
+ format_json=format_json,
389
+ )
390
+
391
+ workers = filter_worker_targets(raw_targets, tenant_id=tenant_id)
392
+ if not workers:
393
+ exit_error(
394
+ EXIT_CDP_UNAVAILABLE,
395
+ "CDP_UNAVAILABLE",
396
+ "No Teams web worker targets found.",
397
+ step="resolve-id",
398
+ format_json=format_json,
399
+ )
400
+
401
+ # Query workers to find the meeting
402
+ today = dt.datetime.now(tz=dt.timezone.utc).date()
403
+ # Search 90 days back (covers the typical retention period)
404
+ search_days = 90
405
+ date_range = [today - dt.timedelta(days=i) for i in range(search_days)]
406
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_DAYS)
407
+
408
+ # Pre-compute both matching strategies: exact thread_id and extracted UUID.
409
+ # The exact match handles the common case; the UUID comparison handles
410
+ # format variations (URL-safe base64, padding differences, etc.).
411
+ target_thread_id = build_thread_id(meeting_id)
412
+ target_id_lower = meeting_id.lower()
413
+
414
+ for worker in workers:
415
+ worker_id = worker.get("id", "")
416
+ ws_url = worker.get("webSocketDebuggerUrl", "")
417
+ if not ws_url:
418
+ ws_url = f"ws://127.0.0.1:{port}/devtools/page/{worker_id}"
419
+
420
+ cdp: _MuxCDPConnection | None = None
421
+ ws_conn = None
422
+ try:
423
+ ws_conn = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
424
+ cdp = _MuxCDPConnection(ws_conn)
425
+ cdp.start()
426
+ upn = await _resolve_upn(cdp, tenant_id=tenant_id)
427
+ if not upn:
428
+ await cdp.stop()
429
+ await ws_conn.close()
430
+ continue
431
+
432
+ logger.log("resolve-id", f"Searching MCPS for meeting (user: {upn})...")
433
+
434
+ # Query days until we find the matching meeting.
435
+ # Process in batches; use return_exceptions=True so that a single
436
+ # day-query timeout or error does not discard the entire batch.
437
+ for batch_start in range(0, search_days, _MAX_CONCURRENT_DAYS):
438
+ shutdown.check_shutdown()
439
+ batch = date_range[batch_start : batch_start + _MAX_CONCURRENT_DAYS]
440
+ tasks = [_query_day(cdp, upn, d, semaphore, logger) for d in batch]
441
+ results = await asyncio.gather(*tasks, return_exceptions=True)
442
+ for day_results in results:
443
+ # Skip failed day queries (returned as exceptions)
444
+ if isinstance(day_results, BaseException):
445
+ continue
446
+ for artifact in day_results:
447
+ # Primary: exact thread_id match
448
+ if artifact.thread_id == target_thread_id:
449
+ logger.log(
450
+ "resolve-id",
451
+ f"Found: '{artifact.subject}' on {artifact.date}",
452
+ )
453
+ await cdp.stop()
454
+ await ws_conn.close()
455
+ return (
456
+ artifact.subject,
457
+ artifact.date.isoformat(),
458
+ artifact.thread_id,
459
+ )
460
+ # Fallback: compare extracted UUIDs (handles encoding
461
+ # differences between standard and URL-safe base64,
462
+ # padding variations, and case mismatches).
463
+ extracted = extract_meeting_id(artifact.thread_id)
464
+ if extracted.lower() == target_id_lower:
465
+ logger.log(
466
+ "resolve-id",
467
+ f"Found (UUID match): '{artifact.subject}' on {artifact.date}",
468
+ )
469
+ await cdp.stop()
470
+ await ws_conn.close()
471
+ return (
472
+ artifact.subject,
473
+ artifact.date.isoformat(),
474
+ artifact.thread_id,
475
+ )
476
+
477
+ await cdp.stop()
478
+ await ws_conn.close()
479
+
480
+ # If a specific tenant was requested, don't try other workers
481
+ if tenant_id:
482
+ break
483
+
484
+ except SystemExit:
485
+ raise
486
+ except Exception:
487
+ if cdp:
488
+ with contextlib.suppress(Exception):
489
+ await cdp.stop()
490
+ if ws_conn:
491
+ with contextlib.suppress(Exception):
492
+ await ws_conn.close()
493
+ continue
494
+
495
+ exit_error(
496
+ EXIT_MEETING_NOT_FOUND,
497
+ "MEETING_NOT_FOUND",
498
+ f"Meeting ID {meeting_id} not found in the last {search_days} days of transcripts.",
499
+ step="resolve-id",
500
+ format_json=format_json,
501
+ )
502
+ return ("", "", "") # unreachable — exit_error calls sys.exit
503
+
504
+
505
+ async def resolve_call_id(
506
+ port: int,
507
+ call_id: str,
508
+ *,
509
+ logger: Logger,
510
+ shutdown: ShutdownCoordinator,
511
+ format_json: bool = False,
512
+ tenant_id: str | None = None,
513
+ force_restart: bool = False,
514
+ ) -> tuple[str, str, str]:
515
+ """Resolve a call ID to its subject, date, and thread_id via MCPS.
516
+
517
+ Connects to the Teams worker, queries a date range (up to 90 days back)
518
+ to find the meeting entry whose call_id matches the given value, and
519
+ returns the subject string, date, and full thread_id.
520
+
521
+ Call IDs uniquely identify a specific occurrence, making this the
522
+ preferred resolution method for recurring meetings.
523
+
524
+ Args:
525
+ port: CDP debugging port number.
526
+ call_id: Call identifier (from --list-transcripts Call ID column).
527
+ logger: Logger instance.
528
+ shutdown: Shutdown coordinator.
529
+ format_json: Whether to emit JSON error output.
530
+ tenant_id: Optional tenant ID prefix to filter workers.
531
+
532
+ Returns:
533
+ A tuple of ``(subject, date_str, thread_id)`` where ``date_str``
534
+ is ISO format (``YYYY-MM-DD``) and ``thread_id`` is the full
535
+ Teams thread identifier (e.g. ``19:meeting_...@thread.v2``).
536
+
537
+ Raises:
538
+ SystemExit: If the call cannot be found or CDP is unavailable.
539
+ """
540
+ from teams_transcripts.adapters.cdp.helpers import (
541
+ cdp_http_get,
542
+ filter_worker_targets,
543
+ verify_cdp,
544
+ )
545
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
546
+ from teams_transcripts.infrastructure.errors import (
547
+ EXIT_CDP_UNAVAILABLE,
548
+ EXIT_MEETING_NOT_FOUND,
549
+ exit_error,
550
+ )
551
+
552
+ logger.log("resolve-call-id", f"Resolving call ID '{call_id}'...")
553
+
554
+ # Ensure Teams is running and CDP is available
555
+ ensure_teams_running(
556
+ port,
557
+ logger=logger,
558
+ shutdown=shutdown,
559
+ format_json=format_json,
560
+ force=force_restart,
561
+ )
562
+ verify_cdp(port, logger=logger, shutdown=shutdown, format_json=format_json)
563
+
564
+ # Discover workers
565
+ raw_targets = cdp_http_get(port, "/json")
566
+ if not raw_targets or not isinstance(raw_targets, list):
567
+ exit_error(
568
+ EXIT_CDP_UNAVAILABLE,
569
+ "CDP_UNAVAILABLE",
570
+ "Could not list CDP targets.",
571
+ step="resolve-call-id",
572
+ format_json=format_json,
573
+ )
574
+
575
+ workers = filter_worker_targets(raw_targets, tenant_id=tenant_id)
576
+ if not workers:
577
+ exit_error(
578
+ EXIT_CDP_UNAVAILABLE,
579
+ "CDP_UNAVAILABLE",
580
+ "No Teams web worker targets found.",
581
+ step="resolve-call-id",
582
+ format_json=format_json,
583
+ )
584
+
585
+ # Query workers to find the meeting by call_id
586
+ today = dt.datetime.now(tz=dt.timezone.utc).date()
587
+ search_days = 90
588
+ date_range = [today - dt.timedelta(days=i) for i in range(search_days)]
589
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_DAYS)
590
+
591
+ for worker in workers:
592
+ worker_id = worker.get("id", "")
593
+ ws_url = worker.get("webSocketDebuggerUrl", "")
594
+ if not ws_url:
595
+ ws_url = f"ws://127.0.0.1:{port}/devtools/page/{worker_id}"
596
+
597
+ cdp: _MuxCDPConnection | None = None
598
+ ws_conn = None
599
+ try:
600
+ ws_conn = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
601
+ cdp = _MuxCDPConnection(ws_conn)
602
+ cdp.start()
603
+ upn = await _resolve_upn(cdp, tenant_id=tenant_id)
604
+ if not upn:
605
+ await cdp.stop()
606
+ await ws_conn.close()
607
+ continue
608
+
609
+ logger.log("resolve-call-id", f"Searching MCPS for call (user: {upn})...")
610
+
611
+ # Query days until we find the matching call_id.
612
+ for batch_start in range(0, search_days, _MAX_CONCURRENT_DAYS):
613
+ shutdown.check_shutdown()
614
+ batch = date_range[batch_start : batch_start + _MAX_CONCURRENT_DAYS]
615
+ tasks = [_query_day(cdp, upn, d, semaphore, logger) for d in batch]
616
+ results = await asyncio.gather(*tasks, return_exceptions=True)
617
+ for day_results in results:
618
+ if isinstance(day_results, BaseException):
619
+ continue
620
+ for artifact in day_results:
621
+ if artifact.call_id == call_id:
622
+ logger.log(
623
+ "resolve-call-id",
624
+ f"Found: '{artifact.subject}' on {artifact.date}",
625
+ )
626
+ await cdp.stop()
627
+ await ws_conn.close()
628
+ return (
629
+ artifact.subject,
630
+ artifact.date.isoformat(),
631
+ artifact.thread_id,
632
+ )
633
+
634
+ await cdp.stop()
635
+ await ws_conn.close()
636
+
637
+ # If a specific tenant was requested, don't try other workers
638
+ if tenant_id:
639
+ break
640
+
641
+ except SystemExit:
642
+ raise
643
+ except Exception:
644
+ if cdp:
645
+ with contextlib.suppress(Exception):
646
+ await cdp.stop()
647
+ if ws_conn:
648
+ with contextlib.suppress(Exception):
649
+ await ws_conn.close()
650
+ continue
651
+
652
+ exit_error(
653
+ EXIT_MEETING_NOT_FOUND,
654
+ "MEETING_NOT_FOUND",
655
+ f"Call ID '{call_id}' not found in the last {search_days} days of transcripts.",
656
+ step="resolve-call-id",
657
+ format_json=format_json,
658
+ )
659
+ return ("", "", "") # unreachable — exit_error calls sys.exit
660
+
661
+
662
+ async def run_list_transcripts(
663
+ port: int,
664
+ *,
665
+ days: int,
666
+ logger: Logger,
667
+ shutdown: ShutdownCoordinator,
668
+ format_json: bool,
669
+ output_path: str | None = None,
670
+ tenant_id: str | None = None,
671
+ force_restart: bool = False,
672
+ ) -> None:
673
+ """List meetings with transcripts for the given number of days.
674
+
675
+ Connects to the Teams web worker via CDP, queries each day in
676
+ parallel (up to 5 concurrent), and outputs results as a table
677
+ or JSON.
678
+
679
+ Args:
680
+ port: CDP debugging port number.
681
+ days: Number of days back to query (1 = today only).
682
+ logger: Logger instance.
683
+ shutdown: Shutdown coordinator.
684
+ format_json: If ``True``, output JSON; otherwise text table.
685
+ output_path: File path to write to, or ``None`` for stdout.
686
+ tenant_id: Optional tenant ID to filter worker targets.
687
+ """
688
+ import sys
689
+
690
+ from teams_transcripts.adapters.cdp.helpers import (
691
+ cdp_http_get,
692
+ filter_worker_targets,
693
+ verify_cdp,
694
+ )
695
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
696
+ from teams_transcripts.infrastructure.errors import (
697
+ EXIT_CDP_UNAVAILABLE,
698
+ EXIT_DOWNLOAD_FAILED,
699
+ exit_error,
700
+ )
701
+
702
+ # Step 1: Ensure Teams is running with CDP
703
+ ensure_teams_running(
704
+ port,
705
+ logger=logger,
706
+ shutdown=shutdown,
707
+ format_json=format_json,
708
+ force=force_restart,
709
+ )
710
+
711
+ # Step 2: Verify CDP
712
+ verify_cdp(port, logger=logger, shutdown=shutdown, format_json=format_json)
713
+
714
+ # Step 3: Discover worker targets
715
+ raw_targets = cdp_http_get(port, "/json")
716
+ if not raw_targets or not isinstance(raw_targets, list):
717
+ exit_error(
718
+ EXIT_CDP_UNAVAILABLE,
719
+ "CDP_UNAVAILABLE",
720
+ "Could not list CDP targets.",
721
+ step="list-transcripts",
722
+ format_json=format_json,
723
+ )
724
+
725
+ workers = filter_worker_targets(raw_targets, tenant_id=tenant_id)
726
+ if not workers:
727
+ exit_error(
728
+ EXIT_CDP_UNAVAILABLE,
729
+ "CDP_UNAVAILABLE",
730
+ "No Teams web worker targets found. Is Teams running and signed in?",
731
+ step="list-transcripts",
732
+ format_json=format_json,
733
+ )
734
+
735
+ # Step 3: Connect to workers and query
736
+ # When --tenant is specified, use the first matching worker.
737
+ # When no tenant is specified, try all workers and aggregate results
738
+ # across tenants.
739
+ today = dt.datetime.now(tz=dt.timezone.utc).date()
740
+ date_range = [today - dt.timedelta(days=i) for i in range(days)]
741
+ semaphore = asyncio.Semaphore(_MAX_CONCURRENT_DAYS)
742
+
743
+ all_artifacts: list[MeetingArtifact] = []
744
+ seen_upns: set[str] = set()
745
+
746
+ for worker in workers:
747
+ worker_id = worker.get("id", "")
748
+ ws_url = worker.get("webSocketDebuggerUrl", "")
749
+ if not ws_url:
750
+ ws_url = f"ws://127.0.0.1:{port}/devtools/page/{worker_id}"
751
+
752
+ logger.log("list-transcripts", f"Trying worker {worker_id[:12]}...")
753
+
754
+ cdp: _MuxCDPConnection | None = None
755
+ ws_conn = None
756
+ try:
757
+ ws_conn = await websockets.connect(ws_url, max_size=16 * 1024 * 1024)
758
+ cdp = _MuxCDPConnection(ws_conn)
759
+ cdp.start()
760
+ upn = await _resolve_upn(cdp, tenant_id=tenant_id)
761
+ if not upn:
762
+ await cdp.stop()
763
+ await ws_conn.close()
764
+ continue
765
+
766
+ # Skip if we already queried this UPN (same tenant, different worker)
767
+ if upn in seen_upns:
768
+ await cdp.stop()
769
+ await ws_conn.close()
770
+ continue
771
+ seen_upns.add(upn)
772
+
773
+ logger.log("list-transcripts", f"User: {upn}")
774
+ logger.log("list-transcripts", f"Querying {days} day(s)...")
775
+
776
+ tasks = [
777
+ _query_day(cdp, upn, query_date, semaphore, logger) for query_date in date_range
778
+ ]
779
+ results = await asyncio.gather(*tasks, return_exceptions=True)
780
+ for day_results in results:
781
+ if isinstance(day_results, BaseException):
782
+ continue
783
+ all_artifacts.extend(day_results)
784
+
785
+ await cdp.stop()
786
+ await ws_conn.close()
787
+
788
+ # If a specific tenant was requested, stop after the first match
789
+ if tenant_id:
790
+ break
791
+
792
+ except Exception:
793
+ if cdp:
794
+ await cdp.stop()
795
+ if ws_conn:
796
+ await ws_conn.close()
797
+ continue
798
+
799
+ if not seen_upns:
800
+ exit_error(
801
+ EXIT_DOWNLOAD_FAILED,
802
+ "DOWNLOAD_FAILED",
803
+ "Could not resolve user principal name from any worker target.",
804
+ step="list-transcripts",
805
+ format_json=format_json,
806
+ )
807
+
808
+ # Sort by date descending
809
+ all_artifacts.sort(key=lambda a: a.date, reverse=True)
810
+
811
+ # Step 4: Format output
812
+ if format_json:
813
+ output = format_artifacts_json(all_artifacts)
814
+ else:
815
+ output = format_artifacts_table(all_artifacts)
816
+
817
+ # Step 5: Write to file or stdout
818
+ if output_path:
819
+ with open(output_path, "w", encoding="utf-8") as f:
820
+ f.write(output)
821
+ f.write("\n")
822
+ logger.log("list-transcripts", f"Output written to {output_path}")
823
+ else:
824
+ print(output)
825
+
826
+ sys.exit(0)