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,343 @@
|
|
|
1
|
+
"""MCPS response parsing and artifact formatting.
|
|
2
|
+
|
|
3
|
+
Pure functions for converting raw MCPS API responses into domain objects
|
|
4
|
+
and formatting them for display (text table or JSON).
|
|
5
|
+
|
|
6
|
+
The MCPS (Meeting Content and Presence Service) API returns meeting
|
|
7
|
+
artifacts via ``startListArtifactsByUserWorkflow``. This module handles
|
|
8
|
+
the response structure:
|
|
9
|
+
|
|
10
|
+
.. code-block:: json
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"value": [
|
|
14
|
+
{
|
|
15
|
+
"threadInfo": {
|
|
16
|
+
"subject": "...",
|
|
17
|
+
"threadId": "19:meeting_...",
|
|
18
|
+
"callId": "...",
|
|
19
|
+
"iCalUid": "..."
|
|
20
|
+
},
|
|
21
|
+
"resources": [
|
|
22
|
+
{"type": "TranscriptV2", "location": "https://..."},
|
|
23
|
+
{"type": "Recording", "location": "https://..."}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"paginationInfo": {"hasNextPage": true, "skipToken": "..."}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Public API:
|
|
31
|
+
extract_meeting_id(thread_id) -> str
|
|
32
|
+
build_thread_id(meeting_id) -> str
|
|
33
|
+
parse_mcps_response(raw, query_date) -> list[MeetingArtifact]
|
|
34
|
+
format_artifacts_table(artifacts) -> str
|
|
35
|
+
format_artifacts_json(artifacts) -> str
|
|
36
|
+
|
|
37
|
+
Schema version: 2.0.0
|
|
38
|
+
Created: 2026-04-28
|
|
39
|
+
Updated: 2026-05-01
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import base64
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import typing
|
|
48
|
+
|
|
49
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
50
|
+
|
|
51
|
+
if typing.TYPE_CHECKING:
|
|
52
|
+
import datetime as dt
|
|
53
|
+
|
|
54
|
+
from teams_transcripts.domain.models import MeetingArtifact
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# MCPS response Pydantic models
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class McpsResourceMetadata(BaseModel):
|
|
65
|
+
"""Metadata block within an MCPS resource entry."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
68
|
+
|
|
69
|
+
web_url: str = Field(default="", alias="webUrl")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class McpsResource(BaseModel):
|
|
73
|
+
"""A single resource (transcript or recording) within an MCPS entry."""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(extra="allow")
|
|
76
|
+
|
|
77
|
+
type: str = ""
|
|
78
|
+
location: str = ""
|
|
79
|
+
metadata: McpsResourceMetadata | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class McpsThreadInfo(BaseModel):
|
|
83
|
+
"""Thread information for an MCPS meeting entry.
|
|
84
|
+
|
|
85
|
+
The MCPS API may return ``null`` for any string field. The
|
|
86
|
+
before-mode validator coerces nulls to empty strings so downstream
|
|
87
|
+
code can rely on ``str`` types without null checks.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
91
|
+
|
|
92
|
+
subject: str = ""
|
|
93
|
+
thread_id: str = Field(default="", alias="threadId")
|
|
94
|
+
call_id: str = Field(default="", alias="callId")
|
|
95
|
+
ical_uid: str = Field(default="", alias="iCalUid")
|
|
96
|
+
|
|
97
|
+
@field_validator("subject", "thread_id", "call_id", "ical_uid", mode="before")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _coerce_null_to_empty(cls, v: str | None) -> str:
|
|
100
|
+
"""Coerce ``None`` (JSON null) to empty string."""
|
|
101
|
+
return v if v is not None else ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class McpsEntry(BaseModel):
|
|
105
|
+
"""A single meeting entry in the MCPS response value array."""
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
108
|
+
|
|
109
|
+
thread_info: McpsThreadInfo = Field(default_factory=McpsThreadInfo, alias="threadInfo")
|
|
110
|
+
resources: list[McpsResource] = []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class McpsResponse(BaseModel):
|
|
114
|
+
"""Top-level MCPS API response from startListArtifactsByUserWorkflow."""
|
|
115
|
+
|
|
116
|
+
model_config = ConfigDict(extra="allow")
|
|
117
|
+
|
|
118
|
+
value: list[McpsEntry] = []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_meeting_id(thread_id: str) -> str:
|
|
122
|
+
"""Extract a short meeting ID from a Teams thread_id.
|
|
123
|
+
|
|
124
|
+
Thread IDs have the format ``19:meeting_<BASE64>@thread.v2`` where
|
|
125
|
+
the base64 segment decodes to a UUID. This function extracts and
|
|
126
|
+
returns that UUID. If the format is unexpected, returns a truncated
|
|
127
|
+
form of the thread_id.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
thread_id: The full thread ID string.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A UUID string (36 chars) or a fallback short identifier.
|
|
134
|
+
"""
|
|
135
|
+
if not thread_id:
|
|
136
|
+
return ""
|
|
137
|
+
try:
|
|
138
|
+
# Extract base64 segment: 19:meeting_<B64>@thread.v2
|
|
139
|
+
if "_" in thread_id and "@" in thread_id:
|
|
140
|
+
b64 = thread_id.split("_", 1)[1].rsplit("@", 1)[0]
|
|
141
|
+
# Pad for base64 decoding (add missing chars to reach multiple of 4)
|
|
142
|
+
remainder = len(b64) % 4
|
|
143
|
+
if remainder:
|
|
144
|
+
b64 += "=" * (4 - remainder)
|
|
145
|
+
# Use urlsafe_b64decode which accepts both standard base64
|
|
146
|
+
# (+ and /) and URL-safe base64 (- and _). Teams may use
|
|
147
|
+
# either variant depending on client version/region.
|
|
148
|
+
return base64.urlsafe_b64decode(b64).decode("utf-8")
|
|
149
|
+
except Exception: # noqa: S110
|
|
150
|
+
pass
|
|
151
|
+
# Fallback: return first 36 chars of thread_id
|
|
152
|
+
return thread_id[:36]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_thread_id(meeting_id: str) -> str:
|
|
156
|
+
"""Reconstruct a full Teams thread_id from a meeting UUID.
|
|
157
|
+
|
|
158
|
+
Inverse of :func:`extract_meeting_id`. Encodes the UUID as base64
|
|
159
|
+
and wraps it in the ``19:meeting_<BASE64>@thread.v2`` format.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
meeting_id: A UUID string (e.g. ``2b4c1f5c-636d-4d78-a087-c2cbf3887fd8``).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The full thread_id string.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If meeting_id is empty.
|
|
169
|
+
"""
|
|
170
|
+
if not meeting_id:
|
|
171
|
+
msg = "meeting_id must not be empty"
|
|
172
|
+
raise ValueError(msg)
|
|
173
|
+
# Encode UUID to base64, strip padding
|
|
174
|
+
encoded = base64.b64encode(meeting_id.encode("utf-8")).decode("utf-8").rstrip("=")
|
|
175
|
+
return f"19:meeting_{encoded}@thread.v2"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_mcps_response(raw: dict, query_date: dt.date) -> list[MeetingArtifact]:
|
|
179
|
+
"""Parse a raw MCPS response into a list of MeetingArtifact objects.
|
|
180
|
+
|
|
181
|
+
Validates the response structure via :class:`McpsResponse`, then
|
|
182
|
+
extracts transcript/recording information from typed resource objects.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
raw: The raw JSON response dict from
|
|
186
|
+
``startListArtifactsByUserWorkflow``.
|
|
187
|
+
query_date: The date that was queried (used as the artifact date
|
|
188
|
+
since the API does not return meeting start times reliably).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A list of :class:`MeetingArtifact` instances. Empty list if
|
|
192
|
+
``raw`` has no ``value`` key, it is empty, or validation fails.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
response = McpsResponse.model_validate(raw)
|
|
196
|
+
except ValidationError:
|
|
197
|
+
logger.debug("MCPS response failed validation", exc_info=True)
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
if not response.value:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
artifacts: list[MeetingArtifact] = []
|
|
204
|
+
for entry in response.value:
|
|
205
|
+
has_transcript = False
|
|
206
|
+
has_recording = False
|
|
207
|
+
transcript_url: str | None = None
|
|
208
|
+
recording_url: str | None = None
|
|
209
|
+
|
|
210
|
+
for resource in entry.resources:
|
|
211
|
+
meta = resource.metadata or McpsResourceMetadata()
|
|
212
|
+
if resource.type == "TranscriptV2":
|
|
213
|
+
has_transcript = True
|
|
214
|
+
transcript_url = resource.location or None
|
|
215
|
+
# When a recording exists, the TranscriptV2 resource
|
|
216
|
+
# includes the recording URL in metadata.webUrl
|
|
217
|
+
if meta.web_url:
|
|
218
|
+
has_recording = True
|
|
219
|
+
recording_url = meta.web_url
|
|
220
|
+
elif resource.type == "Recording":
|
|
221
|
+
has_recording = True
|
|
222
|
+
recording_url = resource.location or meta.web_url or None
|
|
223
|
+
|
|
224
|
+
artifact = MeetingArtifact(
|
|
225
|
+
subject=entry.thread_info.subject or "(unnamed)",
|
|
226
|
+
thread_id=entry.thread_info.thread_id,
|
|
227
|
+
call_id=entry.thread_info.call_id,
|
|
228
|
+
ical_uid=entry.thread_info.ical_uid,
|
|
229
|
+
date=query_date,
|
|
230
|
+
has_transcript=has_transcript,
|
|
231
|
+
has_recording=has_recording,
|
|
232
|
+
transcript_url=transcript_url,
|
|
233
|
+
recording_url=recording_url,
|
|
234
|
+
)
|
|
235
|
+
artifacts.append(artifact)
|
|
236
|
+
|
|
237
|
+
return artifacts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def format_artifacts_table(artifacts: list[MeetingArtifact]) -> str:
|
|
241
|
+
"""Format a list of meeting artifacts as a plain-text table.
|
|
242
|
+
|
|
243
|
+
Produces a fixed-width table with columns: Date, Subject, T (transcript
|
|
244
|
+
indicator), R (recording indicator), ID (meeting UUID), and optionally
|
|
245
|
+
Call ID (shown when at least one artifact has a non-empty call_id).
|
|
246
|
+
|
|
247
|
+
The Call ID column enables ``--call-id`` targeting for unambiguous
|
|
248
|
+
occurrence selection in recurring meeting series.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
artifacts: List of artifacts to format (should be pre-sorted).
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Formatted table string (no trailing newline). Returns a
|
|
255
|
+
"No meetings found" message if the list is empty.
|
|
256
|
+
"""
|
|
257
|
+
if not artifacts:
|
|
258
|
+
return "No meetings with transcripts found."
|
|
259
|
+
|
|
260
|
+
# Determine whether to show the Call ID column
|
|
261
|
+
has_call_ids = any(a.call_id for a in artifacts)
|
|
262
|
+
|
|
263
|
+
# Compute column widths
|
|
264
|
+
date_width = 10 # YYYY-MM-DD
|
|
265
|
+
id_width = 36 # UUID
|
|
266
|
+
# Subject column: minimum 7 ("Subject"), max 48 (reduced to fit ID)
|
|
267
|
+
max_subject = max(len(a.subject) for a in artifacts)
|
|
268
|
+
subject_width = min(max(max_subject, 7), 48)
|
|
269
|
+
|
|
270
|
+
# Call ID width: sized to longest value (minimum 7 for "Call ID" header)
|
|
271
|
+
if has_call_ids:
|
|
272
|
+
max_call_id = max((len(a.call_id) for a in artifacts if a.call_id), default=7)
|
|
273
|
+
call_id_width = max(max_call_id, 7)
|
|
274
|
+
else:
|
|
275
|
+
call_id_width = 0
|
|
276
|
+
|
|
277
|
+
# Header
|
|
278
|
+
header = (
|
|
279
|
+
f"{'Date':<{date_width}} "
|
|
280
|
+
f"{'Subject':<{subject_width}} "
|
|
281
|
+
f"{'T':>1} {'R':>1} "
|
|
282
|
+
f"{'ID':<{id_width}}"
|
|
283
|
+
)
|
|
284
|
+
separator = f"{'-' * date_width} {'-' * subject_width} {'-':>1} {'-':>1} {'-' * id_width}"
|
|
285
|
+
if has_call_ids:
|
|
286
|
+
header += f" {'Call ID':<{call_id_width}}"
|
|
287
|
+
separator += f" {'-' * call_id_width}"
|
|
288
|
+
|
|
289
|
+
lines = [header, separator]
|
|
290
|
+
for a in artifacts:
|
|
291
|
+
subject_display = a.subject[:subject_width]
|
|
292
|
+
t_indicator = "Y" if a.has_transcript else "-"
|
|
293
|
+
r_indicator = "Y" if a.has_recording else "-"
|
|
294
|
+
meeting_id = extract_meeting_id(a.thread_id)
|
|
295
|
+
line = (
|
|
296
|
+
f"{a.date.isoformat():<{date_width}} "
|
|
297
|
+
f"{subject_display:<{subject_width}} "
|
|
298
|
+
f"{t_indicator:>1} "
|
|
299
|
+
f"{r_indicator:>1} "
|
|
300
|
+
f"{meeting_id:<{id_width}}"
|
|
301
|
+
)
|
|
302
|
+
if has_call_ids:
|
|
303
|
+
call_id_display = a.call_id or "-"
|
|
304
|
+
line += f" {call_id_display:<{call_id_width}}"
|
|
305
|
+
lines.append(line)
|
|
306
|
+
|
|
307
|
+
# Footer with count
|
|
308
|
+
lines.append("")
|
|
309
|
+
lines.append(f"{len(artifacts)} meeting(s) found.")
|
|
310
|
+
|
|
311
|
+
return "\n".join(lines)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def format_artifacts_json(artifacts: list[MeetingArtifact]) -> str:
|
|
315
|
+
"""Format a list of meeting artifacts as a JSON array.
|
|
316
|
+
|
|
317
|
+
Each artifact is serialised as a dict with all fields. The ``date``
|
|
318
|
+
field is ISO-formatted.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
artifacts: List of artifacts to format.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Pretty-printed JSON string.
|
|
325
|
+
"""
|
|
326
|
+
entries = []
|
|
327
|
+
for a in artifacts:
|
|
328
|
+
entry = {
|
|
329
|
+
"subject": a.subject,
|
|
330
|
+
"date": a.date.isoformat(),
|
|
331
|
+
"thread_id": a.thread_id,
|
|
332
|
+
"call_id": a.call_id,
|
|
333
|
+
"ical_uid": a.ical_uid,
|
|
334
|
+
"has_transcript": a.has_transcript,
|
|
335
|
+
"has_recording": a.has_recording,
|
|
336
|
+
}
|
|
337
|
+
if a.transcript_url:
|
|
338
|
+
entry["transcript_url"] = a.transcript_url
|
|
339
|
+
if a.recording_url:
|
|
340
|
+
entry["recording_url"] = a.recording_url
|
|
341
|
+
entries.append(entry)
|
|
342
|
+
|
|
343
|
+
return json.dumps(entries, indent=2)
|