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,463 @@
1
+ """Safe Microsoft Teams process management for CDP-based automation.
2
+
3
+ Every process kill is guarded by a tri-state live-call probe. The probe
4
+ combines CoreAudio process activity with the macOS Accessibility tree so that
5
+ video-only calls are protected as well as calls using Teams audio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import os
12
+ import subprocess
13
+ import time
14
+ import typing
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+
18
+ from teams_transcripts.adapters.cdp.helpers import cdp_http_get
19
+
20
+ if typing.TYPE_CHECKING:
21
+ from collections.abc import Sequence
22
+
23
+
24
+ class LoggerLike(typing.Protocol):
25
+ def log(self, step: str, msg: str) -> None: ...
26
+
27
+
28
+ class ShutdownLike(typing.Protocol):
29
+ def interruptible_sleep_sync(self, seconds: float) -> None: ...
30
+
31
+
32
+ CDP_FLAGS: list[str] = [
33
+ "--disable-backgrounding-occluded-windows",
34
+ "--disable-renderer-backgrounding",
35
+ "--disable-hang-monitor",
36
+ "--disable-ipc-flooding-protection",
37
+ "--disable-smooth-scrolling",
38
+ "--run-all-compositor-stages-before-draw",
39
+ "--hide-scrollbars",
40
+ "--mute-audio",
41
+ "--silent-debugger-extension-api",
42
+ "--force-device-scale-factor=2",
43
+ ]
44
+
45
+ _ENV_VAR = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"
46
+ _TEAMS_PROCESS_PATTERN = r"Microsoft Teams\.app/Contents/|(^|/)MSTeams( |$)"
47
+ _AUDIO_SAMPLE_COUNT = 3
48
+ _AUDIO_SAMPLE_INTERVAL_SECONDS = 0.5
49
+
50
+
51
+ class CallState(str, Enum):
52
+ """Whether Teams can safely be restarted."""
53
+
54
+ ACTIVE = "active"
55
+ INACTIVE = "inactive"
56
+ UNKNOWN = "unknown"
57
+
58
+
59
+ class RestartBlockedError(RuntimeError):
60
+ """Raised when a restart is denied by the live-call safeguard."""
61
+
62
+ def __init__(self, call_state: CallState) -> None:
63
+ self.call_state = call_state
64
+ self.reason = "active_call" if call_state is CallState.ACTIVE else "call_state_unknown"
65
+ super().__init__(self.reason)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class RestartResult:
70
+ """Outcome of a guarded Teams restart."""
71
+
72
+ restarted: bool
73
+ cdp_ready: bool
74
+ port: int
75
+ call_state: CallState
76
+ forced: bool
77
+
78
+ def to_dict(self) -> dict[str, object]:
79
+ """Return the MCP-facing representation."""
80
+ return {
81
+ "status": "ok" if self.cdp_ready else "error",
82
+ "restarted": self.restarted,
83
+ "cdp_ready": self.cdp_ready,
84
+ "port": self.port,
85
+ "call_state": self.call_state.value,
86
+ "forced": self.forced,
87
+ }
88
+
89
+
90
+ class _AudioObjectPropertyAddress(ctypes.Structure):
91
+ _fields_ = [
92
+ ("mSelector", ctypes.c_uint32),
93
+ ("mScope", ctypes.c_uint32),
94
+ ("mElement", ctypes.c_uint32),
95
+ ]
96
+
97
+
98
+ def _fourcc(value: str) -> int:
99
+ return int.from_bytes(value.encode("ascii"), byteorder="big")
100
+
101
+
102
+ _AUDIO_SYSTEM_OBJECT = 1
103
+ _AUDIO_OBJECT_UNKNOWN = 0
104
+ _AUDIO_SCOPE_GLOBAL = _fourcc("glob")
105
+ _AUDIO_ELEMENT_MAIN = 0
106
+ _AUDIO_TRANSLATE_PID = _fourcc("id2p")
107
+ _AUDIO_RUNNING_INPUT = _fourcc("piri")
108
+ _AUDIO_RUNNING_OUTPUT = _fourcc("piro")
109
+
110
+
111
+ def _build_cdp_flags(port: int) -> str:
112
+ """Build the WebView2 Chromium flags for a debugging port."""
113
+ return " ".join([f"--remote-debugging-port={port}", *CDP_FLAGS])
114
+
115
+
116
+ def _set_launchctl_env(flags_value: str, *, logger: LoggerLike) -> None:
117
+ """Make WebView2 flags visible to apps launched by LaunchServices."""
118
+ subprocess.run(
119
+ ["launchctl", "setenv", _ENV_VAR, flags_value],
120
+ capture_output=True,
121
+ check=False,
122
+ )
123
+ logger.log("step1", f"launchctl setenv {_ENV_VAR} (port extracted from flags)")
124
+
125
+
126
+ def _find_teams_processes() -> list[int] | None:
127
+ """Return Teams and Teams helper PIDs, or ``None`` if inspection failed."""
128
+ try:
129
+ result = subprocess.run(
130
+ ["pgrep", "-f", _TEAMS_PROCESS_PATTERN],
131
+ capture_output=True,
132
+ text=True,
133
+ check=False,
134
+ )
135
+ except OSError:
136
+ return None
137
+
138
+ if result.returncode == 1:
139
+ return []
140
+ if result.returncode != 0:
141
+ return None
142
+
143
+ try:
144
+ return sorted({int(line) for line in result.stdout.splitlines() if line.strip()})
145
+ except (AttributeError, ValueError):
146
+ return None
147
+
148
+
149
+ def _load_coreaudio() -> typing.Any:
150
+ coreaudio = ctypes.CDLL("/System/Library/Frameworks/CoreAudio.framework/CoreAudio")
151
+ get_data = coreaudio.AudioObjectGetPropertyData
152
+ get_data.argtypes = [
153
+ ctypes.c_uint32,
154
+ ctypes.POINTER(_AudioObjectPropertyAddress),
155
+ ctypes.c_uint32,
156
+ ctypes.c_void_p,
157
+ ctypes.POINTER(ctypes.c_uint32),
158
+ ctypes.c_void_p,
159
+ ]
160
+ get_data.restype = ctypes.c_int32
161
+ return coreaudio
162
+
163
+
164
+ def _audio_property(coreaudio: typing.Any, object_id: int, selector: int) -> bool:
165
+ address = _AudioObjectPropertyAddress(
166
+ selector,
167
+ _AUDIO_SCOPE_GLOBAL,
168
+ _AUDIO_ELEMENT_MAIN,
169
+ )
170
+ value = ctypes.c_uint32(0)
171
+ size = ctypes.c_uint32(ctypes.sizeof(value))
172
+ status = coreaudio.AudioObjectGetPropertyData(
173
+ object_id,
174
+ ctypes.byref(address),
175
+ 0,
176
+ None,
177
+ ctypes.byref(size),
178
+ ctypes.byref(value),
179
+ )
180
+ if status != 0:
181
+ raise OSError(f"CoreAudio property query failed with OSStatus {status}")
182
+ return bool(value.value)
183
+
184
+
185
+ def _audio_process_object(coreaudio: typing.Any, pid: int) -> int:
186
+ address = _AudioObjectPropertyAddress(
187
+ _AUDIO_TRANSLATE_PID,
188
+ _AUDIO_SCOPE_GLOBAL,
189
+ _AUDIO_ELEMENT_MAIN,
190
+ )
191
+ qualifier = ctypes.c_int32(pid)
192
+ object_id = ctypes.c_uint32(_AUDIO_OBJECT_UNKNOWN)
193
+ size = ctypes.c_uint32(ctypes.sizeof(object_id))
194
+ status = coreaudio.AudioObjectGetPropertyData(
195
+ _AUDIO_SYSTEM_OBJECT,
196
+ ctypes.byref(address),
197
+ ctypes.sizeof(qualifier),
198
+ ctypes.byref(qualifier),
199
+ ctypes.byref(size),
200
+ ctypes.byref(object_id),
201
+ )
202
+ if status != 0:
203
+ raise OSError(f"CoreAudio PID translation failed with OSStatus {status}")
204
+ return object_id.value
205
+
206
+
207
+ def _sample_teams_audio(process_ids: Sequence[int]) -> bool:
208
+ """Return whether any Teams process currently has active audio I/O."""
209
+ coreaudio = _load_coreaudio()
210
+ for pid in process_ids:
211
+ object_id = _audio_process_object(coreaudio, pid)
212
+ if object_id == _AUDIO_OBJECT_UNKNOWN:
213
+ continue
214
+ if _audio_property(coreaudio, object_id, _AUDIO_RUNNING_INPUT):
215
+ return True
216
+ if _audio_property(coreaudio, object_id, _AUDIO_RUNNING_OUTPUT):
217
+ return True
218
+ return False
219
+
220
+
221
+ _ACCESSIBILITY_CALL_SCRIPT = r"""
222
+ tell application "System Events"
223
+ set teamsProcesses to every application process whose name is "Microsoft Teams" ¬
224
+ or name is "MSTeams"
225
+ if (count of teamsProcesses) is 0 then return "inactive"
226
+ repeat with teamsProcess in teamsProcesses
227
+ set uiItems to entire contents of teamsProcess
228
+ repeat with uiItem in uiItems
229
+ try
230
+ set itemVisible to true
231
+ try
232
+ set itemVisible to value of attribute "AXVisible" of uiItem
233
+ end try
234
+ try
235
+ if value of attribute "AXHidden" of uiItem then set itemVisible to false
236
+ end try
237
+ if itemVisible and role of uiItem is "AXButton" then
238
+ set itemText to ""
239
+ try
240
+ set itemText to itemText & " " & (name of uiItem as text)
241
+ end try
242
+ try
243
+ set itemText to itemText & " " & (description of uiItem as text)
244
+ end try
245
+ try
246
+ set itemText to itemText & " " & ¬
247
+ (value of attribute "AXTitle" of uiItem as text)
248
+ end try
249
+ ignoring case
250
+ if itemText contains "leave" or itemText contains "hang up" ¬
251
+ or itemText contains "hang-up" then
252
+ return "active"
253
+ end if
254
+ end ignoring
255
+ end if
256
+ end try
257
+ end repeat
258
+ end repeat
259
+ return "inactive"
260
+ end tell
261
+ """
262
+
263
+
264
+ def _probe_accessibility_call_ui() -> bool | None:
265
+ """Inspect Teams' accessibility tree for a Leave/Hang-up button."""
266
+ try:
267
+ result = subprocess.run(
268
+ ["osascript", "-e", _ACCESSIBILITY_CALL_SCRIPT],
269
+ capture_output=True,
270
+ text=True,
271
+ timeout=5,
272
+ check=False,
273
+ )
274
+ except (OSError, subprocess.TimeoutExpired):
275
+ return None
276
+ if result.returncode != 0:
277
+ return None
278
+ output = result.stdout.strip().lower()
279
+ if output == "active":
280
+ return True
281
+ if output == "inactive":
282
+ return False
283
+ return None
284
+
285
+
286
+ def detect_call_state() -> CallState:
287
+ """Detect live Teams calls using persistent audio activity and visible UI."""
288
+ process_ids = _find_teams_processes()
289
+ if process_ids is None:
290
+ return CallState.UNKNOWN
291
+ if not process_ids:
292
+ return CallState.INACTIVE
293
+
294
+ audio_samples: list[bool] = []
295
+ try:
296
+ for sample_index in range(_AUDIO_SAMPLE_COUNT):
297
+ audio_samples.append(_sample_teams_audio(process_ids))
298
+ if sample_index + 1 < _AUDIO_SAMPLE_COUNT:
299
+ time.sleep(_AUDIO_SAMPLE_INTERVAL_SECONDS)
300
+ except (AttributeError, OSError):
301
+ return CallState.UNKNOWN
302
+
303
+ if all(audio_samples):
304
+ return CallState.ACTIVE
305
+
306
+ ui_active = _probe_accessibility_call_ui()
307
+ if ui_active is True:
308
+ return CallState.ACTIVE
309
+ if ui_active is False:
310
+ return CallState.INACTIVE
311
+ return CallState.UNKNOWN
312
+
313
+
314
+ def _kill_teams(
315
+ *,
316
+ logger: LoggerLike,
317
+ label: str = "step1",
318
+ force: bool = False,
319
+ ) -> CallState:
320
+ """Kill Teams only after checking for a possible live call."""
321
+ call_state = detect_call_state()
322
+ logger.log(label, f"Teams call state before restart: {call_state.value}")
323
+ if not force and call_state is not CallState.INACTIVE:
324
+ raise RestartBlockedError(call_state)
325
+ subprocess.run(
326
+ ["pkill", "-f", "Microsoft Teams"],
327
+ capture_output=True,
328
+ check=False,
329
+ )
330
+ logger.log(label, "Killed existing Teams processes.")
331
+ return call_state
332
+
333
+
334
+ def _launch_teams(
335
+ port: int,
336
+ *,
337
+ logger: LoggerLike,
338
+ tenant_id: str | None = None,
339
+ ) -> None:
340
+ flags_value = _build_cdp_flags(port)
341
+ _set_launchctl_env(flags_value, logger=logger)
342
+ env = os.environ.copy()
343
+ env[_ENV_VAR] = flags_value
344
+ command = ["open", "-a", "Microsoft Teams"]
345
+ if tenant_id:
346
+ command.append(
347
+ "msteams://teams.microsoft.com/l/entity/"
348
+ f"com.microsoft.teamspace.tab.wiki/tab?tenantId={tenant_id}"
349
+ )
350
+ subprocess.Popen(command, env=env)
351
+
352
+
353
+ def restart_teams_process(
354
+ port: int,
355
+ *,
356
+ logger: LoggerLike,
357
+ shutdown: ShutdownLike,
358
+ force: bool = False,
359
+ tenant_id: str | None = None,
360
+ label: str = "step1",
361
+ ) -> RestartResult:
362
+ """Guard, kill, relaunch, and verify Teams on ``port``."""
363
+ call_state = _kill_teams(logger=logger, label=label, force=force)
364
+ shutdown.interruptible_sleep_sync(3 if tenant_id else 2)
365
+ _launch_teams(port, logger=logger, tenant_id=tenant_id)
366
+ logger.log(label, "Waiting for Teams to start...")
367
+ shutdown.interruptible_sleep_sync(20 if tenant_id else 12)
368
+ cdp_ready = _is_cdp_ready(port)
369
+ if cdp_ready:
370
+ logger.log(label, f"Teams restarted and CDP is ready on port {port}.")
371
+ else:
372
+ logger.log(label, f"Teams restarted but CDP did not become ready on port {port}.")
373
+ return RestartResult(
374
+ restarted=True,
375
+ cdp_ready=cdp_ready,
376
+ port=port,
377
+ call_state=call_state,
378
+ forced=force,
379
+ )
380
+
381
+
382
+ def _is_cdp_ready(port: int) -> bool:
383
+ version = cdp_http_get(port, "/json/version")
384
+ return isinstance(version, dict) and "Browser" in version
385
+
386
+
387
+ def _exit_restart_blocked(
388
+ error: RestartBlockedError,
389
+ *,
390
+ label: str,
391
+ format_json: bool,
392
+ ) -> typing.NoReturn:
393
+ from teams_transcripts.infrastructure.errors import (
394
+ ERROR_RESTART_BLOCKED,
395
+ EXIT_CDP_UNAVAILABLE,
396
+ exit_error,
397
+ )
398
+
399
+ detail = (
400
+ "an active Teams call was detected"
401
+ if error.reason == "active_call"
402
+ else ("the Teams call state could not be determined")
403
+ )
404
+ exit_error(
405
+ EXIT_CDP_UNAVAILABLE,
406
+ ERROR_RESTART_BLOCKED,
407
+ f"Teams restart blocked because {detail}. "
408
+ "Do not interrupt Teams automatically. Retry with the CLI flag "
409
+ "--force-restart, or call restart_teams(force=True), only after the user "
410
+ "explicitly approves interrupting a possible live call.",
411
+ step=label,
412
+ format_json=format_json,
413
+ )
414
+
415
+
416
+ def ensure_teams_running(
417
+ port: int,
418
+ *,
419
+ logger: LoggerLike,
420
+ shutdown: ShutdownLike,
421
+ format_json: bool = False,
422
+ force: bool = False,
423
+ ) -> RestartResult | None:
424
+ """Ensure CDP is available, safely restarting Teams when necessary."""
425
+ version = cdp_http_get(port, "/json/version")
426
+ if isinstance(version, dict) and "Browser" in version:
427
+ logger.log("step1", f"CDP already active: {version['Browser']}")
428
+ return None
429
+
430
+ logger.log("step1", "CDP not responding. Checking call state before restarting Teams...")
431
+ try:
432
+ return restart_teams_process(
433
+ port,
434
+ logger=logger,
435
+ shutdown=shutdown,
436
+ force=force,
437
+ )
438
+ except RestartBlockedError as error:
439
+ _exit_restart_blocked(error, label="step1", format_json=format_json)
440
+
441
+
442
+ def relaunch_teams_on_tenant(
443
+ port: int,
444
+ tenant_id: str,
445
+ *,
446
+ logger: LoggerLike,
447
+ shutdown: ShutdownLike,
448
+ format_json: bool = False,
449
+ force: bool = False,
450
+ ) -> RestartResult:
451
+ """Safely relaunch Teams on a specific Azure AD tenant."""
452
+ logger.log("tenant", "Switching tenant: checking call state before restarting Teams...")
453
+ try:
454
+ return restart_teams_process(
455
+ port,
456
+ logger=logger,
457
+ shutdown=shutdown,
458
+ force=force,
459
+ tenant_id=tenant_id,
460
+ label="tenant",
461
+ )
462
+ except RestartBlockedError as error:
463
+ _exit_restart_blocked(error, label="tenant", format_json=format_json)