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,684 @@
1
+ """Tenant detection, matching, verification, switching, and listing service.
2
+
3
+ Implements the detect-verify-switch approach: check that the Teams desktop
4
+ app is signed into the correct Azure AD tenant before proceeding with
5
+ transcript download. If the tenant does not match, raises
6
+ ``TenantSwitchNeededError`` so the pipeline can kill and relaunch Teams
7
+ on the correct tenant. If the requested tenant is not in the available
8
+ list at all, exits with code 6 (``TENANT_NOT_FOUND``).
9
+
10
+ Also provides ``--list-tenants`` support: ``run_list_tenants()`` connects
11
+ to Teams via CDP, discovers available tenants, and prints a formatted
12
+ table or JSON array.
13
+
14
+ Public API:
15
+ detect_current_tenant(browser) -> dict | None
16
+ list_available_tenants(browser) -> list[dict]
17
+ match_tenant(domain, tenants) -> dict | None (pure function)
18
+ resolve_requested_tenant(browser, tenant, logger, format_json) -> TenantVerification
19
+ pick_detection_target(targets) -> str (pure function)
20
+ format_tenants_text(tenants, current) -> str (pure function)
21
+ format_tenants_json(tenants, current) -> str (pure function)
22
+ ensure_tenant(browser, config, logger) -> TenantVerification | None
23
+ run_list_tenants(port, ...) -> None (async orchestrator)
24
+ TenantSwitchNeededError (exception)
25
+
26
+ Schema version: 1.2.0
27
+ Created: 2026-04-26
28
+ Updated: 2026-04-26
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import sys
35
+ import typing
36
+
37
+ if typing.TYPE_CHECKING:
38
+ from teams_transcripts.domain.models import MeetingConfig
39
+ from teams_transcripts.infrastructure.logging import Logger
40
+ from teams_transcripts.infrastructure.signals import ShutdownCoordinator
41
+ from teams_transcripts.ports.browser import BrowserPort
42
+
43
+ from teams_transcripts.infrastructure.errors import (
44
+ ERROR_TENANT_NOT_FOUND,
45
+ EXIT_TENANT_NOT_FOUND,
46
+ exit_error,
47
+ )
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Exception for tenant switching
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ class TenantSwitchNeededError(Exception):
55
+ """Raised when the current tenant does not match the requested tenant.
56
+
57
+ The pipeline should catch this, kill Teams, relaunch with the
58
+ ``tenant_id`` in the URL, and re-verify.
59
+
60
+ Attributes:
61
+ tenant_id: Azure AD tenant ID for the target tenant.
62
+ domain: Display domain for the target tenant.
63
+ """
64
+
65
+ def __init__(self, tenant_id: str, domain: str) -> None:
66
+ self.tenant_id = tenant_id
67
+ self.domain = domain
68
+ super().__init__(f"Switch to tenant {domain} ({tenant_id})")
69
+
70
+
71
+ class TenantVerification(typing.NamedTuple):
72
+ """Canonical tenant details after verification."""
73
+
74
+ tenant_id: str
75
+ domain: str
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # JavaScript expressions
80
+ # ---------------------------------------------------------------------------
81
+
82
+ # Extract tenantId and tenantName from the clientStartupConfiguration
83
+ # element embedded in the Teams page DOM.
84
+ _JS_DETECT_TENANT = """\
85
+ (function() {
86
+ try {
87
+ var el = document.getElementById('clientStartupConfiguration');
88
+ if (!el) return null;
89
+ var cfg = JSON.parse(el.textContent);
90
+ if (!cfg.tenantId) return null;
91
+ return JSON.stringify({tenantId: cfg.tenantId, tenantName: cfg.tenantName || ''});
92
+ } catch(e) {
93
+ return null;
94
+ }
95
+ })()
96
+ """
97
+
98
+ # Fallback: extract tenant display name from the page title.
99
+ # Title format: "... | TenantName | UPN | Microsoft Teams"
100
+ # Returns just the tenant display name string, or null.
101
+ _JS_DETECT_TENANT_FROM_TITLE = """\
102
+ (function() {
103
+ try {
104
+ var title = document.title;
105
+ if (!title || title.indexOf('Microsoft Teams') < 0) return null;
106
+ var parts = title.split(' | ');
107
+ if (parts.length < 3) return null;
108
+ if (parts[parts.length - 1].trim() !== 'Microsoft Teams') return null;
109
+ var tenantName = parts[parts.length - 3].trim();
110
+ if (!tenantName) return null;
111
+ return tenantName;
112
+ } catch(e) {
113
+ return null;
114
+ }
115
+ })()
116
+ """
117
+
118
+ # Acquire a token for the Teams API, then fetch the tenants list.
119
+ # Returns a JSON array of tenant objects, or null on failure.
120
+ #
121
+ # This is a single async IIFE that acquires a token via the WAM bridge
122
+ # and fetches the /users/tenants endpoint in one expression. Must be
123
+ # evaluated with awaitPromise: true in CDP Runtime.evaluate.
124
+ _JS_LIST_TENANTS = """\
125
+ (async function() {
126
+ try {
127
+ var wamResult = await window.getWamAccessToken(
128
+ 'https://api.spaces.skype.com', '', ''
129
+ );
130
+ if (!wamResult || !wamResult.success || !wamResult.token) return null;
131
+ var resp = await fetch(
132
+ 'https://teams.microsoft.com/api/mt/emea/beta/users/tenants',
133
+ {headers: {'Authorization': 'Bearer ' + wamResult.token}}
134
+ );
135
+ if (!resp.ok) return null;
136
+ var data = await resp.json();
137
+ return JSON.stringify(data);
138
+ } catch(e) {
139
+ return null;
140
+ }
141
+ })()
142
+ """
143
+
144
+ # Fallback: read the tenants list from localStorage.
145
+ # Teams caches it under tmp.auth.v1.<userId>.Tenants.Tenants.
146
+ # Multiple keys may exist (one per account); we pick the one with the
147
+ # most tenant entries that have non-empty tenantName fields.
148
+ # Returns a JSON array of tenant objects, or null.
149
+ _JS_LIST_TENANTS_FROM_STORAGE = """\
150
+ (function() {
151
+ try {
152
+ var best = null;
153
+ var bestLen = 0;
154
+ for (var i = 0; i < localStorage.length; i++) {
155
+ var key = localStorage.key(i);
156
+ if (key && key.indexOf('.Tenants.Tenants') > 0 &&
157
+ key.indexOf('tmp.auth.v1.') === 0) {
158
+ var raw = localStorage.getItem(key);
159
+ if (!raw) continue;
160
+ var parsed = JSON.parse(raw);
161
+ var items = parsed.item || parsed;
162
+ if (!Array.isArray(items)) continue;
163
+ var named = items.filter(function(t) { return t.tenantName; });
164
+ if (named.length > bestLen) {
165
+ best = items;
166
+ bestLen = named.length;
167
+ }
168
+ }
169
+ }
170
+ return best && best.length > 0 ? JSON.stringify(best) : null;
171
+ } catch(e) {
172
+ return null;
173
+ }
174
+ })()
175
+ """
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # detect_current_tenant
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ async def detect_current_tenant(browser: BrowserPort) -> dict | None:
184
+ """Detect the current tenant from the Teams page DOM.
185
+
186
+ Tries two methods in order:
187
+
188
+ 1. Read ``clientStartupConfiguration`` for ``tenantId`` (M365 Copilot
189
+ wrapper mode).
190
+ 2. Parse the page title for the tenant display name (direct Teams mode).
191
+ Title format: ``"... | TenantName | UPN | Microsoft Teams"``.
192
+
193
+ Args:
194
+ browser: Connected browser adapter (already attached to a
195
+ Teams page target).
196
+
197
+ Returns:
198
+ A dict with at least ``tenantId`` or ``tenantName`` keys,
199
+ or ``None`` if detection fails by both methods.
200
+ """
201
+ # Method 1: clientStartupConfiguration element
202
+ raw = await browser.evaluate(_JS_DETECT_TENANT)
203
+ if isinstance(raw, str):
204
+ try:
205
+ data = json.loads(raw)
206
+ except (json.JSONDecodeError, TypeError):
207
+ data = None
208
+ if isinstance(data, dict) and data.get("tenantId"):
209
+ return data
210
+
211
+ # Method 2: page title parsing
212
+ title_name = await browser.evaluate(_JS_DETECT_TENANT_FROM_TITLE)
213
+ if isinstance(title_name, str) and title_name:
214
+ return {"tenantId": "", "tenantName": title_name}
215
+
216
+ return None
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # list_available_tenants
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ async def list_available_tenants(browser: BrowserPort) -> list[dict]:
225
+ """List all tenants the current user belongs to.
226
+
227
+ Tries two methods in order:
228
+
229
+ 1. Acquire a ``api.spaces.skype.com`` token via WAM and call the
230
+ Teams ``/users/tenants`` API (M365 Copilot wrapper mode).
231
+ 2. Read the cached tenants list from ``localStorage``
232
+ (``tmp.auth.v1.<userId>.Tenants.Tenants``) (direct Teams mode).
233
+
234
+ Args:
235
+ browser: Connected browser adapter.
236
+
237
+ Returns:
238
+ A list of tenant dicts (each with ``tenantId``, ``tenantName``,
239
+ ``tenantPrimaryDomainName``), or an empty list on failure.
240
+ """
241
+ # Method 1: WAM token + API call (async, needs awaitPromise)
242
+ resp = await browser.send_command(
243
+ "Runtime.evaluate",
244
+ {"expression": _JS_LIST_TENANTS, "awaitPromise": True},
245
+ )
246
+ raw = resp.get("result", {}).get("result", {}).get("value")
247
+ tenants = _parse_tenants_json(raw)
248
+ if tenants:
249
+ return tenants
250
+
251
+ # Method 2: localStorage cache (sync JS)
252
+ raw_storage = await browser.evaluate(_JS_LIST_TENANTS_FROM_STORAGE)
253
+ tenants = _parse_tenants_json(raw_storage)
254
+ if tenants:
255
+ return tenants
256
+
257
+ return []
258
+
259
+
260
+ def _parse_tenants_json(raw: object) -> list[dict] | None:
261
+ """Parse a JSON string into a list of tenant dicts, or return None."""
262
+ if raw is None or not isinstance(raw, str):
263
+ return None
264
+ try:
265
+ data = json.loads(raw)
266
+ except (json.JSONDecodeError, TypeError):
267
+ return None
268
+ if not isinstance(data, list):
269
+ return None
270
+ return data or None
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # pick_detection_target (pure function)
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ def pick_detection_target(targets: list[dict]) -> str:
279
+ """Choose the best CDP target for tenant detection.
280
+
281
+ Prefers a target with a descriptive title (e.g.
282
+ ``"Chat | ... | TenantName | ..."``) over a generic
283
+ ``"Microsoft Teams"`` shell page. Descriptive titles are
284
+ identified by containing at least two ``" | "`` separators.
285
+
286
+ Args:
287
+ targets: Non-empty list of Teams page target dicts.
288
+
289
+ Returns:
290
+ The ``id`` of the best target for tenant detection.
291
+ """
292
+ for t in targets:
293
+ title = t.get("title", "")
294
+ if title.count(" | ") >= 2:
295
+ return t["id"]
296
+ return targets[0]["id"]
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # match_tenant (pure function)
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ def match_tenant(domain: str, tenants: list[dict]) -> dict | None:
305
+ """Find a tenant matching the given domain name.
306
+
307
+ Performs case-insensitive matching against both ``tenantName`` and
308
+ ``tenantPrimaryDomainName`` fields.
309
+
310
+ Args:
311
+ domain: Domain name to match (e.g. ``"abccorp.com"``,
312
+ ``"contoso.onmicrosoft.com"``).
313
+ tenants: List of tenant dicts from :func:`list_available_tenants`.
314
+
315
+ Returns:
316
+ The matching tenant dict, or ``None`` if no match is found.
317
+ """
318
+ needle = domain.lower()
319
+ for t in tenants:
320
+ name = t.get("tenantName", "")
321
+ primary = t.get("tenantPrimaryDomainName", "")
322
+ if needle == name.lower() or needle == primary.lower():
323
+ return t
324
+ return None
325
+
326
+
327
+ def _tenant_domain(tenant: dict, fallback: str) -> str:
328
+ """Return the display domain/name for a tenant entry."""
329
+ return tenant.get("tenantPrimaryDomainName") or tenant.get("tenantName", fallback)
330
+
331
+
332
+ async def resolve_requested_tenant(
333
+ browser: BrowserPort,
334
+ tenant: str,
335
+ logger: Logger,
336
+ *,
337
+ format_json: bool = False,
338
+ ) -> TenantVerification:
339
+ """Resolve a requested tenant name/domain to canonical tenant details.
340
+
341
+ Args:
342
+ browser: Connected browser adapter.
343
+ tenant: Tenant name or primary domain supplied by the user.
344
+ logger: Logger instance for diagnostic output.
345
+ format_json: Whether errors should be emitted as JSON.
346
+
347
+ Returns:
348
+ Canonical tenant ID and display domain.
349
+
350
+ Raises:
351
+ SystemExit: If the tenant list cannot be read, no matching tenant is
352
+ available, or the matching entry lacks a tenant ID.
353
+ """
354
+ tenants = await list_available_tenants(browser)
355
+ matched = match_tenant(tenant, tenants)
356
+ if matched is None:
357
+ available = [_tenant_domain(t, "?") for t in tenants]
358
+ available_str = ", ".join(available) if available else "(none discovered)"
359
+ exit_error(
360
+ EXIT_TENANT_NOT_FOUND,
361
+ ERROR_TENANT_NOT_FOUND,
362
+ f"Tenant '{tenant}' not found. Available tenants: {available_str}",
363
+ format_json=format_json,
364
+ )
365
+ raise AssertionError("unreachable: exit_error returned")
366
+
367
+ tenant_id = matched.get("tenantId", "")
368
+ domain = _tenant_domain(matched, tenant)
369
+ if not tenant_id:
370
+ exit_error(
371
+ EXIT_TENANT_NOT_FOUND,
372
+ ERROR_TENANT_NOT_FOUND,
373
+ f"Tenant '{tenant}' matched '{domain}' but did not include a tenant ID.",
374
+ format_json=format_json,
375
+ )
376
+ raise AssertionError("unreachable: exit_error returned")
377
+
378
+ logger.log("tenant", f"Tenant resolved: {domain} ({tenant_id[:8]}...)")
379
+ return TenantVerification(tenant_id=tenant_id, domain=domain)
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # ensure_tenant
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ async def ensure_tenant(
388
+ browser: BrowserPort,
389
+ config: MeetingConfig,
390
+ logger: Logger,
391
+ ) -> TenantVerification | None:
392
+ """Verify that Teams is on the correct tenant.
393
+
394
+ If ``config.tenant`` is ``None``, this function does nothing and
395
+ returns ``None``.
396
+
397
+ If the current tenant matches the requested tenant, returns canonical
398
+ tenant details containing the Azure AD tenant ID and primary domain.
399
+
400
+ If there is a mismatch and the requested tenant IS in the
401
+ available list, raises ``TenantSwitchNeededError`` with the
402
+ target tenant ID and domain. The pipeline catches this and
403
+ handles the kill+relaunch.
404
+
405
+ If the requested tenant is NOT in the available list, calls
406
+ ``exit_error()`` with exit code 6 (``TENANT_NOT_FOUND``).
407
+
408
+ Args:
409
+ browser: Connected browser adapter.
410
+ config: Pipeline configuration (with ``tenant`` and
411
+ ``format_json`` fields).
412
+ logger: Logger instance for diagnostic output.
413
+
414
+ Returns:
415
+ Tenant verification details, or ``None`` when ``config.tenant`` is
416
+ not set.
417
+
418
+ Raises:
419
+ TenantSwitchNeededError: When the current tenant does not
420
+ match and the requested tenant is in the available list.
421
+ """
422
+ if config.tenant is None:
423
+ return None
424
+
425
+ format_json = config.format_json
426
+
427
+ # Step 1: detect current tenant
428
+ logger.log("tenant", f"Verifying tenant: {config.tenant}")
429
+ current = await detect_current_tenant(browser)
430
+ if current is None:
431
+ exit_error(
432
+ EXIT_TENANT_NOT_FOUND,
433
+ ERROR_TENANT_NOT_FOUND,
434
+ "Could not detect current tenant. The Teams page DOM may have changed.",
435
+ format_json=format_json,
436
+ )
437
+
438
+ # Step 2: list available tenants
439
+ tenants = await list_available_tenants(browser)
440
+
441
+ # Step 3: resolve the current tenant's identity.
442
+ # Detection may return a tenantId (from clientStartupConfiguration)
443
+ # or just a tenantName (from page title parsing). If we only have
444
+ # a display name, look it up in the tenants list to get the ID.
445
+ current_id = current.get("tenantId", "")
446
+ current_name = current.get("tenantName", "")
447
+
448
+ if not current_id and current_name and tenants:
449
+ # Title-based detection: match display name against tenants list
450
+ name_lower = current_name.lower()
451
+ for t in tenants:
452
+ if t.get("tenantName", "").lower() == name_lower:
453
+ current_id = t.get("tenantId", "")
454
+ break
455
+
456
+ if current_id:
457
+ logger.log("tenant", f"Current tenant ID: {current_id[:8]}...")
458
+
459
+ # Look up the current tenant's display name from the list
460
+ current_tenant_entry = next((t for t in tenants if t.get("tenantId") == current_id), None)
461
+ current_display = (
462
+ current_tenant_entry.get("tenantPrimaryDomainName")
463
+ or current_tenant_entry.get("tenantName", current_id or current_name)
464
+ if current_tenant_entry
465
+ else current_name or current_id
466
+ )
467
+ logger.log("tenant", f"Current tenant: {current_display}")
468
+
469
+ # Step 3: match the requested tenant
470
+ matched = match_tenant(config.tenant, tenants)
471
+
472
+ if matched is None:
473
+ # Requested tenant not found in available tenants
474
+ available = [_tenant_domain(t, "?") for t in tenants]
475
+ available_str = ", ".join(available) if available else "(none discovered)"
476
+ exit_error(
477
+ EXIT_TENANT_NOT_FOUND,
478
+ ERROR_TENANT_NOT_FOUND,
479
+ f"Tenant '{config.tenant}' not found. Available tenants: {available_str}",
480
+ format_json=format_json,
481
+ )
482
+
483
+ # Step 4: check if the matched tenant is the current tenant
484
+ matched_id = matched.get("tenantId", "")
485
+ if matched_id != current_id:
486
+ matched_domain = _tenant_domain(matched, config.tenant)
487
+ logger.log(
488
+ "tenant",
489
+ f"Tenant mismatch: on '{current_display}', need '{matched_domain}'. "
490
+ f"Will switch automatically.",
491
+ )
492
+ raise TenantSwitchNeededError(tenant_id=matched_id, domain=matched_domain)
493
+
494
+ # Tenant matches -- return the domain for inclusion in output
495
+ matched_domain = _tenant_domain(matched, config.tenant)
496
+ logger.log("tenant", f"Tenant verified: {matched_domain}")
497
+ return TenantVerification(tenant_id=matched_id, domain=matched_domain)
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # format_tenants_text (pure function)
502
+ # ---------------------------------------------------------------------------
503
+
504
+
505
+ def format_tenants_text(
506
+ tenants: list[dict],
507
+ current_tenant: dict | None,
508
+ ) -> str:
509
+ """Format a list of tenants as a human-readable text table.
510
+
511
+ Columns: NAME, DOMAIN, TENANT ID. The current tenant (matched by
512
+ ``tenantId``) is marked with ``*``. Column widths are computed
513
+ dynamically from the data.
514
+
515
+ Args:
516
+ tenants: List of tenant dicts (each with ``tenantId``,
517
+ ``tenantName``, ``tenantPrimaryDomainName``).
518
+ current_tenant: Dict with at least ``tenantId`` for the
519
+ currently active tenant, or ``None`` if unknown.
520
+
521
+ Returns:
522
+ A multi-line string containing the formatted table.
523
+ """
524
+ if not tenants:
525
+ return "No tenants found."
526
+
527
+ current_id = (current_tenant or {}).get("tenantId", "")
528
+
529
+ # Build row data: (is_current, name, domain, tenant_id)
530
+ rows: list[tuple[bool, str, str, str]] = []
531
+ for t in tenants:
532
+ tid = t.get("tenantId", "")
533
+ name = t.get("tenantName", "")
534
+ domain = t.get("tenantPrimaryDomainName", "")
535
+ is_current = bool(current_id and tid == current_id)
536
+ rows.append((is_current, name, domain, tid))
537
+
538
+ # Compute column widths (minimum: header lengths)
539
+ hdr_name, hdr_domain, hdr_id = "NAME", "DOMAIN", "TENANT ID"
540
+ w_name = max(len(hdr_name), *(len(r[1]) for r in rows))
541
+ w_domain = max(len(hdr_domain), *(len(r[2]) for r in rows))
542
+ w_id = max(len(hdr_id), *(len(r[3]) for r in rows))
543
+
544
+ lines: list[str] = []
545
+ lines.append("Available tenants (* = current):")
546
+ lines.append("")
547
+
548
+ # Header
549
+ marker_col = " " # 4 chars: " * " or " "
550
+ lines.append(f"{marker_col}{hdr_name:<{w_name}} {hdr_domain:<{w_domain}} {hdr_id:<{w_id}}")
551
+
552
+ # Data rows
553
+ for is_current, name, domain, tid in rows:
554
+ marker = " * " if is_current else " "
555
+ lines.append(f"{marker}{name:<{w_name}} {domain:<{w_domain}} {tid:<{w_id}}")
556
+
557
+ lines.append("")
558
+ lines.append("Use --tenant NAME_OR_DOMAIN to select a tenant for download.")
559
+ return "\n".join(lines)
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # format_tenants_json (pure function)
564
+ # ---------------------------------------------------------------------------
565
+
566
+
567
+ def format_tenants_json(
568
+ tenants: list[dict],
569
+ current_tenant: dict | None,
570
+ ) -> str:
571
+ """Format a list of tenants as a JSON array.
572
+
573
+ Each entry has ``tenantId``, ``tenantName``, ``domain``
574
+ (from ``tenantPrimaryDomainName``), and ``current`` (boolean).
575
+
576
+ Args:
577
+ tenants: List of tenant dicts.
578
+ current_tenant: Dict with ``tenantId`` for the current tenant,
579
+ or ``None``.
580
+
581
+ Returns:
582
+ A pretty-printed JSON string.
583
+ """
584
+ current_id = (current_tenant or {}).get("tenantId", "")
585
+
586
+ entries = []
587
+ for t in tenants:
588
+ tid = t.get("tenantId", "")
589
+ entries.append(
590
+ {
591
+ "tenantId": tid,
592
+ "tenantName": t.get("tenantName", ""),
593
+ "domain": t.get("tenantPrimaryDomainName", ""),
594
+ "current": bool(current_id and tid == current_id),
595
+ }
596
+ )
597
+
598
+ return json.dumps(entries, indent=2)
599
+
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # run_list_tenants (async orchestrator for --list-tenants)
603
+ # ---------------------------------------------------------------------------
604
+
605
+
606
+ async def run_list_tenants(
607
+ port: int,
608
+ *,
609
+ logger: Logger,
610
+ shutdown: ShutdownCoordinator,
611
+ format_json: bool,
612
+ output_path: str | None = None,
613
+ force_restart: bool = False,
614
+ ) -> None:
615
+ """List all available tenants and print to stdout or file.
616
+
617
+ Connects to Teams via CDP, discovers page targets, detects the
618
+ current tenant, lists all available tenants, formats the output,
619
+ and writes it to ``output_path`` (or stdout if ``None``).
620
+
621
+ Args:
622
+ port: CDP debugging port number.
623
+ logger: Logger instance.
624
+ shutdown: Shutdown coordinator for interruptible sleeps.
625
+ format_json: If ``True``, output JSON; otherwise text table.
626
+ output_path: File path to write to, or ``None`` for stdout.
627
+ """
628
+ from teams_transcripts.adapters.cdp.browser import CdpBrowserAdapter
629
+ from teams_transcripts.adapters.cdp.helpers import find_teams_targets, verify_cdp
630
+ from teams_transcripts.adapters.cdp.teams import ensure_teams_running
631
+ from teams_transcripts.infrastructure.errors import EXIT_CDP_UNAVAILABLE, exit_error
632
+
633
+ # Step 1: Ensure Teams is running with CDP
634
+ ensure_teams_running(
635
+ port,
636
+ logger=logger,
637
+ shutdown=shutdown,
638
+ format_json=format_json,
639
+ force=force_restart,
640
+ )
641
+
642
+ # Step 2: Verify CDP
643
+ verify_cdp(port, logger=logger, shutdown=shutdown, format_json=format_json)
644
+
645
+ # Step 3: Discover Teams targets
646
+ targets = find_teams_targets(port, logger=logger, format_json=format_json)
647
+ if not targets:
648
+ exit_error(
649
+ EXIT_CDP_UNAVAILABLE,
650
+ "NO_TARGETS",
651
+ "No Teams page targets found via CDP.",
652
+ step="list-tenants",
653
+ format_json=format_json,
654
+ )
655
+ logger.log("list-tenants", f"Found {len(targets)} Teams page target(s)")
656
+
657
+ # Pick the best target for tenant detection
658
+ target_id = pick_detection_target(targets)
659
+
660
+ # Connect and detect/list tenants
661
+ browser = CdpBrowserAdapter(port=port)
662
+ await browser.connect(target_id)
663
+ try:
664
+ current = await detect_current_tenant(browser)
665
+ tenants = await list_available_tenants(browser)
666
+ finally:
667
+ await browser.disconnect()
668
+
669
+ # Format output
670
+ if format_json:
671
+ output = format_tenants_json(tenants, current)
672
+ else:
673
+ output = format_tenants_text(tenants, current)
674
+
675
+ # Write to file or stdout
676
+ if output_path:
677
+ with open(output_path, "w", encoding="utf-8") as f:
678
+ f.write(output)
679
+ f.write("\n")
680
+ logger.log("list-tenants", f"Tenant list written to {output_path}")
681
+ else:
682
+ print(output)
683
+
684
+ sys.exit(0)