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