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,292 @@
1
+ """Pure participant categorisation functions.
2
+
3
+ Parses attendance data from Teams Event/Call ``<partlist>`` XML,
4
+ Tracking/RSVP data from the Details tab, and combines it with roster
5
+ and speaker data to build categorised
6
+ :class:`~teams_transcripts.domain.models.MeetingParticipants`.
7
+
8
+ All functions in this module are pure: no I/O, no network, no logging,
9
+ no global state.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from teams_transcripts.domain.models import MeetingParticipants, RosterMember
17
+
18
+
19
+ def parse_partlist_xml(content: str) -> list[dict]:
20
+ """Parse ``<partlist>`` XML from an Event/Call message content string.
21
+
22
+ The ``<partlist>`` element is embedded in the ``content`` field of
23
+ Teams Event/Call messages (``messageType == "Event/Call"``). Each
24
+ ``<part>`` child represents a call participant with their MRI
25
+ identity, display name, and duration in seconds.
26
+
27
+ Note: Teams may declare a ``count`` attribute larger than the actual
28
+ number of ``<part>`` elements (the XML is truncated server-side).
29
+
30
+ Args:
31
+ content: Raw HTML/XML content string from an Event/Call message.
32
+
33
+ Returns:
34
+ List of dicts, each with keys:
35
+ - ``identity`` (str): Full MRI string (e.g. ``"8:orgid:abc-123"``).
36
+ - ``display_name`` (str): Participant display name.
37
+ - ``duration_seconds`` (int): Duration in the call in seconds.
38
+ """
39
+ # Find the <partlist>...</partlist> block
40
+ partlist_match = re.search(r"<partlist[^>]*>(.*?)</partlist>", content, re.DOTALL)
41
+ if not partlist_match:
42
+ return []
43
+
44
+ partlist_body = partlist_match.group(1)
45
+ if not partlist_body.strip():
46
+ return []
47
+
48
+ # Extract each <part> element
49
+ parts: list[dict] = []
50
+ for part_match in re.finditer(
51
+ r'<part\s+identity="([^"]*)">(.*?)</part>',
52
+ partlist_body,
53
+ re.DOTALL,
54
+ ):
55
+ identity = part_match.group(1)
56
+ part_body = part_match.group(2)
57
+
58
+ # Extract <displayName>
59
+ dn_match = re.search(r"<displayName>(.*?)</displayName>", part_body)
60
+ display_name = _decode_xml_entities(dn_match.group(1)) if dn_match else ""
61
+
62
+ # Extract <duration>
63
+ dur_match = re.search(r"<duration>(\d+)</duration>", part_body)
64
+ duration = int(dur_match.group(1)) if dur_match else 0
65
+
66
+ parts.append(
67
+ {
68
+ "identity": identity,
69
+ "display_name": display_name,
70
+ "duration_seconds": duration,
71
+ }
72
+ )
73
+
74
+ return parts
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Tracking/RSVP parsing
79
+ # ---------------------------------------------------------------------------
80
+
81
+ # Known RSVP status values in the Teams Tracking panel aria-labels.
82
+ # The regex captures the status word immediately before the comma+position
83
+ # suffix (e.g. ", 1 of 15, ...").
84
+ _TRACKING_STATUSES = {"Accepted", "Tentative", "Declined", "None", "Organizer"}
85
+
86
+ # Pattern: "<Name> <Status>, <N> of <M>, in a list of <M> items, Dialogue Pop-up Button"
87
+ # The status word is one of the known values. The name may contain
88
+ # parentheses, hyphens, etc.
89
+ _TRACKING_LABEL_RE = re.compile(
90
+ r"^(.+?)\s+(Accepted|Tentative|Declined|None|Organizer)"
91
+ r",\s+\d+\s+of\s+\d+,\s+in a list of \d+ items",
92
+ )
93
+
94
+
95
+ def parse_tracking_entries(aria_labels: list[str]) -> list[dict]:
96
+ """Parse aria-label strings from the Teams Tracking panel.
97
+
98
+ Each label has the format:
99
+ ``"Name Status, N of M, in a list of M items, Dialogue Pop-up Button"``
100
+
101
+ where *Status* is one of ``Accepted``, ``Tentative``, ``Declined``,
102
+ ``None`` (no response), or ``Organizer``.
103
+
104
+ Args:
105
+ aria_labels: Raw aria-label strings collected from the Tracking
106
+ panel list items.
107
+
108
+ Returns:
109
+ List of dicts, each with keys:
110
+ - ``name`` (str): Display name.
111
+ - ``rsvp_status`` (str): Normalised status (lowercase).
112
+ """
113
+ entries: list[dict] = []
114
+ for label in aria_labels:
115
+ m = _TRACKING_LABEL_RE.match(label)
116
+ if not m:
117
+ continue
118
+ name = m.group(1).strip()
119
+ status = m.group(2).lower()
120
+ entries.append({"name": name, "rsvp_status": status})
121
+ return entries
122
+
123
+
124
+ def enrich_with_rsvp(
125
+ members: list[RosterMember],
126
+ tracking: list[dict],
127
+ ) -> list[RosterMember]:
128
+ """Enrich roster members with RSVP status from tracking data.
129
+
130
+ Matches tracking entries to members by normalised display name
131
+ (case-insensitive, whitespace-collapsed). Unmatched tracking
132
+ entries (e.g. distribution lists) are silently ignored. Unmatched
133
+ members retain an empty ``rsvp_status``.
134
+
135
+ Since :class:`RosterMember` is frozen, new instances are created
136
+ for members whose status changes.
137
+
138
+ Args:
139
+ members: Existing roster members (may have empty ``rsvp_status``).
140
+ tracking: Parsed tracking entries from :func:`parse_tracking_entries`.
141
+
142
+ Returns:
143
+ New list of :class:`RosterMember` with ``rsvp_status`` populated
144
+ where a match was found.
145
+ """
146
+ if not tracking:
147
+ return list(members)
148
+
149
+ # Build lookup: normalised name -> rsvp_status
150
+ status_by_name: dict[str, str] = {}
151
+ for entry in tracking:
152
+ key = _normalise_name(entry["name"])
153
+ status_by_name[key] = entry["rsvp_status"]
154
+
155
+ result: list[RosterMember] = []
156
+ for m in members:
157
+ key = _normalise_name(m.name)
158
+ status = status_by_name.get(key, "")
159
+ if status and status != m.rsvp_status:
160
+ result.append(
161
+ RosterMember(
162
+ name=m.name,
163
+ email=m.email,
164
+ object_id=m.object_id,
165
+ role=m.role,
166
+ rsvp_status=status,
167
+ )
168
+ )
169
+ else:
170
+ result.append(m)
171
+ return result
172
+
173
+
174
+ def build_meeting_participants(
175
+ *,
176
+ all_members: list[RosterMember],
177
+ speaker_map: dict[str, str],
178
+ attendance: list[dict],
179
+ organizer_id: str,
180
+ tracking: list[dict] | None = None,
181
+ ) -> MeetingParticipants:
182
+ """Build categorised participant lists from all data sources.
183
+
184
+ Cross-references the full roster (from the participant popover),
185
+ the Recap speaker mapping, the Event/Call attendance list, and
186
+ optionally the Tracking/RSVP data to produce four categories:
187
+ organiser, invited, speakers, attendees.
188
+
189
+ When ``tracking`` is provided, all members are enriched with RSVP
190
+ status before categorisation, so organiser, speakers, and attendees
191
+ also carry the status.
192
+
193
+ Args:
194
+ all_members: Complete roster from the participant popover.
195
+ Each member has ``object_id`` (bare GUID), ``name``, ``email``.
196
+ speaker_map: GUID-to-displayName mapping from the Recap
197
+ Speakers tab.
198
+ attendance: List of attendance dicts from :func:`parse_partlist_xml`.
199
+ Each has ``identity`` (MRI), ``display_name``, ``duration_seconds``.
200
+ organizer_id: Bare GUID of the meeting organiser (from
201
+ ``meetingInformation.organizerId``), or empty string.
202
+ tracking: Optional list of tracking dicts from
203
+ :func:`parse_tracking_entries`. Each has ``name`` and
204
+ ``rsvp_status``.
205
+
206
+ Returns:
207
+ :class:`MeetingParticipants` with all four categories populated.
208
+ """
209
+ # Enrich members with RSVP status before categorisation
210
+ enriched = enrich_with_rsvp(all_members, tracking) if tracking else list(all_members)
211
+
212
+ # Index enriched members by objectId for fast lookup
213
+ members_by_id: dict[str, RosterMember] = {}
214
+ for m in enriched:
215
+ if m.object_id:
216
+ members_by_id[m.object_id] = m
217
+
218
+ # Organiser
219
+ organiser: list[RosterMember] = []
220
+ if organizer_id and organizer_id in members_by_id:
221
+ organiser = [members_by_id[organizer_id]]
222
+
223
+ # Invited = all enriched members, sorted by name
224
+ invited = sorted(enriched, key=lambda m: m.name.lower())
225
+
226
+ # Speakers = roster members whose objectId is in the speaker_map
227
+ speakers = [members_by_id[guid] for guid in speaker_map if guid in members_by_id]
228
+ speakers.sort(key=lambda m: m.name.lower())
229
+
230
+ # Attendees = roster members whose objectId matches an attendance
231
+ # identity MRI. Bots (identity starting with "28:") are excluded.
232
+ # Deduplicate by objectId (multiple partlists may list the same person).
233
+ seen_attendee_ids: set[str] = set()
234
+ attendees: list[RosterMember] = []
235
+ for att in attendance:
236
+ identity = att["identity"]
237
+ # Skip bots
238
+ if identity.startswith("28:"):
239
+ continue
240
+ # Extract bare GUID from MRI "8:orgid:{guid}"
241
+ guid = _extract_guid_from_mri(identity)
242
+ if not guid or guid in seen_attendee_ids:
243
+ continue
244
+ if guid in members_by_id:
245
+ attendees.append(members_by_id[guid])
246
+ seen_attendee_ids.add(guid)
247
+ attendees.sort(key=lambda m: m.name.lower())
248
+
249
+ return MeetingParticipants(
250
+ organiser=organiser,
251
+ invited=invited,
252
+ speakers=speakers,
253
+ attendees=attendees,
254
+ )
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Internal helpers
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ def _extract_guid_from_mri(mri: str) -> str:
263
+ """Extract the bare GUID from a Teams MRI string.
264
+
265
+ MRI format: ``"8:orgid:{guid}"`` or ``"28:{bot-id}"``.
266
+ Returns the last colon-separated segment, or the original string
267
+ if no colons are present.
268
+ """
269
+ if ":" in mri:
270
+ return mri.split(":")[-1]
271
+ return mri
272
+
273
+
274
+ def _normalise_name(name: str) -> str:
275
+ """Normalise a display name for case-insensitive, whitespace-tolerant matching.
276
+
277
+ Lowercases, strips leading/trailing whitespace, and collapses internal
278
+ runs of whitespace to a single space.
279
+ """
280
+ return " ".join(name.lower().split())
281
+
282
+
283
+ def _decode_xml_entities(text: str) -> str:
284
+ """Decode common XML entities in a string.
285
+
286
+ Handles ``&amp;``, ``&lt;``, ``&gt;``, ``&quot;``, ``&apos;``.
287
+ """
288
+ text = text.replace("&amp;", "&")
289
+ text = text.replace("&lt;", "<")
290
+ text = text.replace("&gt;", ">")
291
+ text = text.replace("&quot;", '"')
292
+ return text.replace("&apos;", "'")
@@ -0,0 +1,106 @@
1
+ """Pure search result scoring and occurrence matching functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from datetime import date
11
+
12
+
13
+ def score_search_result(meeting_name: str, result_text: str) -> float:
14
+ """Score a search result against the meeting name using word overlap.
15
+
16
+ The algorithm:
17
+ 1. Tokenise both strings into lowercase word sets.
18
+ 2. Count exact word overlaps.
19
+ 3. For each meeting-name word not in the exact overlap, check if it
20
+ appears as a substring of the result text (handles concatenated
21
+ words like ``"CallAdrian"``).
22
+ 4. Subtract 0.5 if the result text starts with a ``Name:`` pattern
23
+ (indicating a personal message rather than a meeting entry).
24
+
25
+ Args:
26
+ meeting_name: The meeting search string.
27
+ result_text: The text content of a search result.
28
+
29
+ Returns:
30
+ Numeric score (higher is better).
31
+ """
32
+ name_words = set(re.findall(r"\w+", meeting_name.lower()))
33
+ result_text_lower = result_text.lower()
34
+ result_words = set(re.findall(r"\w+", result_text_lower))
35
+
36
+ # Exact word overlap
37
+ overlap = len(name_words & result_words)
38
+
39
+ # Substring match for words not in exact overlap
40
+ for nw in name_words - result_words:
41
+ if nw in result_text_lower:
42
+ overlap += 1
43
+
44
+ # Tiebreaker: penalise personal message results
45
+ score = float(overlap)
46
+ if re.match(r"^[A-Z][a-z]+:", result_text):
47
+ score -= 0.5
48
+
49
+ return score
50
+
51
+
52
+ def best_result_index(meeting_name: str, results: list[str]) -> int:
53
+ """Return the index of the best-scoring search result.
54
+
55
+ Ties are broken by preferring the earlier (lower-index) result.
56
+
57
+ Args:
58
+ meeting_name: The meeting search string.
59
+ results: List of search result text strings.
60
+
61
+ Returns:
62
+ Zero-based index of the best result.
63
+ """
64
+ best_idx, best_score = 0, 0.0
65
+ for i, text in enumerate(results):
66
+ s = score_search_result(meeting_name, text)
67
+ if s > best_score:
68
+ best_score = s
69
+ best_idx = i
70
+ return best_idx
71
+
72
+
73
+ def occurrence_matches(
74
+ text: str,
75
+ target_date: str,
76
+ target_time: str | None,
77
+ ) -> bool:
78
+ """Check whether an occurrence dropdown text matches the target date/time.
79
+
80
+ The text has the form ``"22 April 2026 11:15 - 12:00"``. The match
81
+ requires that the day number, month name, and year all appear in the
82
+ text. If *target_time* is provided, it must appear before the dash
83
+ separator (i.e. it must be the start time, not the end time).
84
+
85
+ Args:
86
+ text: Raw occurrence dropdown text.
87
+ target_date: Target date as ``YYYY-MM-DD``.
88
+ target_time: Target time as ``HH:MM``, or ``None`` for date-only.
89
+
90
+ Returns:
91
+ ``True`` if the text matches the target.
92
+ """
93
+ dt = date.fromisoformat(target_date)
94
+ date_pattern = re.compile(
95
+ rf"(?<!\d){dt.day}\s+{re.escape(dt.strftime('%B'))}\s+{dt.year}(?!\d)",
96
+ re.IGNORECASE,
97
+ )
98
+
99
+ if not date_pattern.search(text):
100
+ return False
101
+ if target_time is None:
102
+ return True
103
+ sep_idx = text.find("-")
104
+ if sep_idx == -1:
105
+ return target_time in text
106
+ return target_time in text[:sep_idx]
@@ -0,0 +1,130 @@
1
+ """Pure speaker resolution and mapping functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+
12
+ def resolve_chat_author(
13
+ from_user_id: str,
14
+ im_display_name: str,
15
+ speaker_map: dict[str, str] | None,
16
+ ) -> str:
17
+ """Resolve a chat message author name using the speaker mapping.
18
+
19
+ Extracts the bare GUID from ``fromUserId`` (format ``8:orgid:{GUID}``)
20
+ and checks the speaker_map for a cleaner display name. Falls back to
21
+ ``imDisplayName`` from the message props, then to ``"Unknown"``.
22
+
23
+ Args:
24
+ from_user_id: Teams user ID string (e.g. ``"8:orgid:abc-123"``).
25
+ im_display_name: Fallback display name from the message.
26
+ speaker_map: Mapping of GUID to display name, or ``None``.
27
+
28
+ Returns:
29
+ Best available human-readable name.
30
+ """
31
+ if speaker_map and from_user_id and ":" in from_user_id:
32
+ guid = from_user_id.split(":")[-1]
33
+ mapped = speaker_map.get(guid)
34
+ if mapped:
35
+ return mapped
36
+
37
+ return im_display_name or "Unknown"
38
+
39
+
40
+ def apply_speaker_mapping(content: str, speaker_map: dict[str, str]) -> str:
41
+ """Replace speaker names in VTT content using the Recap speaker mapping.
42
+
43
+ Native VTT downloads (Approach A) contain speaker names in
44
+ ``<v SPEAKER_NAME>`` tags. This function matches each VTT speaker
45
+ name to a Recap display name and replaces it.
46
+
47
+ The mapping keys are GUIDs, but VTT files don't contain GUIDs --
48
+ they contain display names. Matching is done by:
49
+
50
+ 1. ``@N`` pattern: Recap shows ``Speaker N``.
51
+ 2. Substring match: VTT has ``v-Gabor Veres (Metosin)``, Recap has
52
+ ``Gabor Veres``. The Recap name is a substring of the VTT name.
53
+ The longest matching Recap name wins.
54
+ 3. Exact match: names are already identical -- no replacement needed.
55
+
56
+ Args:
57
+ content: Raw VTT file content.
58
+ speaker_map: Mapping of GUID to Recap display name.
59
+
60
+ Returns:
61
+ VTT content with speaker names replaced.
62
+ """
63
+ # Extract all unique speaker names from the VTT.
64
+ vtt_names = set(re.findall(r"<v ([^>]+)>", content))
65
+ recap_names = set(speaker_map.values())
66
+
67
+ replacements: dict[str, str] = {} # old_vtt_name -> recap_name
68
+
69
+ for vtt_name in vtt_names:
70
+ if vtt_name in recap_names:
71
+ # Already matches a Recap name exactly -- no change needed.
72
+ continue
73
+
74
+ # 1. @N pattern: look for "Speaker N" in Recap names.
75
+ at_match = re.match(r"^@(\d+)$", vtt_name)
76
+ if at_match:
77
+ speaker_label = f"Speaker {at_match.group(1)}"
78
+ if speaker_label in recap_names:
79
+ replacements[vtt_name] = speaker_label
80
+ continue
81
+
82
+ # 2. Substring match: a Recap name is contained in the VTT name.
83
+ # Pick the longest matching Recap name to avoid false positives.
84
+ best_match = None
85
+ best_len = 0
86
+ for recap_name in recap_names:
87
+ if recap_name in vtt_name and len(recap_name) > best_len:
88
+ best_match = recap_name
89
+ best_len = len(recap_name)
90
+ if best_match:
91
+ replacements[vtt_name] = best_match
92
+
93
+ if not replacements:
94
+ return content
95
+
96
+ for old_name, new_name in replacements.items():
97
+ old_tag = f"<v {old_name}>"
98
+ new_tag = f"<v {new_name}>"
99
+ content = content.replace(old_tag, new_tag)
100
+
101
+ return content
102
+
103
+
104
+ def build_speaker_map_from_roster(
105
+ roster_members: list[dict],
106
+ speaker_map: dict[str, str] | None,
107
+ ) -> dict[str, str]:
108
+ """Build a combined GUID-to-name map from the roster and speaker_map.
109
+
110
+ The roster covers all chat members (including non-speakers), which
111
+ lets us resolve reaction authors and chat authors that the speaker_map
112
+ alone cannot. Speaker map entries take priority (Recap canonical names).
113
+
114
+ Args:
115
+ roster_members: List of roster member dicts with ``"objectId"``
116
+ and ``"name"`` keys.
117
+ speaker_map: Existing GUID-to-name mapping from Recap, or ``None``.
118
+
119
+ Returns:
120
+ Combined GUID-to-name mapping.
121
+ """
122
+ member_map: dict[str, str] = {}
123
+ for rm in roster_members:
124
+ oid = rm.get("objectId", "")
125
+ if oid and rm.get("name"):
126
+ member_map[oid] = rm["name"]
127
+ # Speaker map entries take priority (Recap canonical names).
128
+ if speaker_map:
129
+ member_map.update(speaker_map)
130
+ return member_map