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,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)
|