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