teams-transcripts 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,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)
|