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,987 @@
|
|
|
1
|
+
"""CLI argument parsing and configuration resolution.
|
|
2
|
+
|
|
3
|
+
Replaces ``parse_args()`` and ``resolve_config()`` from the original
|
|
4
|
+
``transcript_download.py``.
|
|
5
|
+
|
|
6
|
+
Key design changes:
|
|
7
|
+
|
|
8
|
+
* ``resolve_config()`` returns a frozen :class:`~domain.models.MeetingConfig`
|
|
9
|
+
Pydantic model instead of a plain dict.
|
|
10
|
+
* No global state is mutated. The caller constructs ``Logger`` and
|
|
11
|
+
``ShutdownCoordinator`` from the returned config's ``quiet``, ``verbose``,
|
|
12
|
+
and ``format_json`` properties.
|
|
13
|
+
* ``exit_error()`` is called with an explicit ``format_json`` parameter
|
|
14
|
+
derived from the parsed args.
|
|
15
|
+
* An optional *env_override* parameter allows tests to inject environment
|
|
16
|
+
variable overrides without patching ``os.environ`` externally.
|
|
17
|
+
* ``_GcpSettings`` (pydantic-settings) centralises env var names and defaults
|
|
18
|
+
for GCP and tenant configuration.
|
|
19
|
+
|
|
20
|
+
Schema version: 1.1.0
|
|
21
|
+
Created: 2026-04-25
|
|
22
|
+
Updated: 2026-05-01 -- added _GcpSettings pydantic-settings model
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import tempfile
|
|
31
|
+
from datetime import date, datetime, timezone
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
from uuid import UUID
|
|
34
|
+
|
|
35
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
36
|
+
|
|
37
|
+
from teams_transcripts.domain.models import MeetingConfig
|
|
38
|
+
from teams_transcripts.infrastructure.errors import EXIT_BAD_ARGS, exit_error
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Argument parsing
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_args() -> argparse.Namespace:
|
|
49
|
+
"""Define and parse command-line arguments.
|
|
50
|
+
|
|
51
|
+
Returns the parsed namespace. This function is a direct extraction
|
|
52
|
+
of the argument definitions from the original script, preserving the
|
|
53
|
+
identical CLI contract (flags, help text, defaults, mutual exclusivity).
|
|
54
|
+
"""
|
|
55
|
+
p = argparse.ArgumentParser(
|
|
56
|
+
description=(
|
|
57
|
+
"Download Microsoft Teams meeting transcripts, chat, and participant "
|
|
58
|
+
"metadata as WebVTT or structured JSON.\n"
|
|
59
|
+
"\n"
|
|
60
|
+
"Connects to the Teams desktop app via the Chrome DevTools Protocol (CDP),\n"
|
|
61
|
+
"navigates to the specified meeting, and extracts the requested\n"
|
|
62
|
+
"components. Transcript extraction uses Recap > Transcript; chat-only\n"
|
|
63
|
+
"and metadata-only modes skip transcript download. An LLM agent\n"
|
|
64
|
+
"(Claude via Vertex AI) assists with non-deterministic UI steps such as\n"
|
|
65
|
+
"search result selection, occurrence selection, and iframe disambiguation.\n"
|
|
66
|
+
"\n"
|
|
67
|
+
"Two transcript download approaches are supported and auto-detected:\n"
|
|
68
|
+
" A) Native blob interception -- used when you have download permission.\n"
|
|
69
|
+
" The script clicks the Download button and intercepts the resulting\n"
|
|
70
|
+
" blob URL to capture the VTT content byte-for-byte.\n"
|
|
71
|
+
" B) React fibre tree extraction -- used when download is blocked.\n"
|
|
72
|
+
" The script walks the React component tree to extract all transcript\n"
|
|
73
|
+
" entries directly from the in-memory data structure.\n"
|
|
74
|
+
"\n"
|
|
75
|
+
"Speaker names are resolved using the Recap Speakers tab. Vendor-prefixed\n"
|
|
76
|
+
"names (e.g. 'v-Jane Smith (Acme)') and synthetic placeholders (e.g. '@1')\n"
|
|
77
|
+
"are replaced with clean display names (e.g. 'Jane Smith', 'Speaker 1').\n"
|
|
78
|
+
"\n"
|
|
79
|
+
"Use --list-tenants to discover available Azure AD tenants before\n"
|
|
80
|
+
"downloading. Use --tenant to verify (and auto-switch) the active tenant."
|
|
81
|
+
),
|
|
82
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
83
|
+
epilog=(
|
|
84
|
+
"prerequisites:\n"
|
|
85
|
+
" 1. Microsoft Teams desktop app (macOS) must be running with CDP enabled.\n"
|
|
86
|
+
" The script will set the required flags and relaunch Teams automatically\n"
|
|
87
|
+
" if CDP is not responding on the configured port. To launch manually:\n"
|
|
88
|
+
" export WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="
|
|
89
|
+
"'--remote-debugging-port=9333'\n"
|
|
90
|
+
" open -a 'Microsoft Teams'\n"
|
|
91
|
+
"\n"
|
|
92
|
+
" 2. Google Cloud Application Default Credentials (ADC) must be configured\n"
|
|
93
|
+
" for the Vertex AI project. The simplest way:\n"
|
|
94
|
+
" gcloud auth application-default login\n"
|
|
95
|
+
" Or point --adc / GOOGLE_ADC at a service account key file.\n"
|
|
96
|
+
"\n"
|
|
97
|
+
" 3. Python dependencies (websockets, anthropic[vertex], mcp) are managed\n"
|
|
98
|
+
" by uv. Run 'uv sync' to install from the lockfile. After setup,\n"
|
|
99
|
+
" use ./bin/teams-transcripts (or 'uv run teams-transcripts') to run.\n"
|
|
100
|
+
"\n"
|
|
101
|
+
"environment variables (used as fallbacks when CLI flags are omitted):\n"
|
|
102
|
+
" GOOGLE_CLOUD_PROJECT GCP project ID for Vertex AI (required if\n"
|
|
103
|
+
" --gcp-project is not provided)\n"
|
|
104
|
+
" CLOUD_ML_REGION Vertex AI region (default: 'global')\n"
|
|
105
|
+
" MODEL_ID Anthropic model ID (default: 'claude-haiku-4-5')\n"
|
|
106
|
+
" GOOGLE_ADC Path to ADC JSON credentials file\n"
|
|
107
|
+
" TEAMS_TENANT Azure AD tenant domain (same as --tenant)\n"
|
|
108
|
+
"\n"
|
|
109
|
+
"examples:\n"
|
|
110
|
+
" # Basic usage -- download a single-occurrence meeting transcript\n"
|
|
111
|
+
" %(prog)s \\\n"
|
|
112
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
113
|
+
" --datetime 2026-04-20 \\\n"
|
|
114
|
+
" --output ./transcripts/kickoff.vtt \\\n"
|
|
115
|
+
" --gcp-project my-vertex-project\n"
|
|
116
|
+
"\n"
|
|
117
|
+
" # Recurring meeting -- the script finds the correct occurrence by date\n"
|
|
118
|
+
" %(prog)s \\\n"
|
|
119
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
120
|
+
" --datetime 2026-04-14 \\\n"
|
|
121
|
+
" --output ./transcripts/standup-2026-04-14.vtt\n"
|
|
122
|
+
"\n"
|
|
123
|
+
" # Disambiguate two meetings with the same name on the same day\n"
|
|
124
|
+
" %(prog)s \\\n"
|
|
125
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
126
|
+
" --datetime 2026-04-14T14:00 \\\n"
|
|
127
|
+
" --output ./transcripts/standup-afternoon.vtt\n"
|
|
128
|
+
"\n"
|
|
129
|
+
" # Pipe VTT to stdout (no file written)\n"
|
|
130
|
+
" %(prog)s \\\n"
|
|
131
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
132
|
+
" --datetime 2026-04-20 | less\n"
|
|
133
|
+
"\n"
|
|
134
|
+
" # JSON output to file (nothing on stdout)\n"
|
|
135
|
+
" %(prog)s \\\n"
|
|
136
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
137
|
+
" --datetime 2026-04-20 \\\n"
|
|
138
|
+
" --output ./kickoff.json --format json\n"
|
|
139
|
+
"\n"
|
|
140
|
+
" # Structured JSON on stdout (agent-friendly)\n"
|
|
141
|
+
" %(prog)s \\\n"
|
|
142
|
+
" --meeting 'Project Kickoff' \\\n"
|
|
143
|
+
" --datetime 2026-04-20 --format json\n"
|
|
144
|
+
"\n"
|
|
145
|
+
" # Dry-run -- check meeting exists without downloading\n"
|
|
146
|
+
" %(prog)s \\\n"
|
|
147
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
148
|
+
" --datetime 2026-04-14 --dry-run --format json\n"
|
|
149
|
+
"\n"
|
|
150
|
+
" # Quiet mode for cron or agent use\n"
|
|
151
|
+
" %(prog)s \\\n"
|
|
152
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
153
|
+
" --datetime 2026-04-14 \\\n"
|
|
154
|
+
" --output ./standup.vtt -q\n"
|
|
155
|
+
"\n"
|
|
156
|
+
" # Verify tenant before downloading (auto-switch if needed)\n"
|
|
157
|
+
" %(prog)s \\\n"
|
|
158
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
159
|
+
" --datetime 2026-04-14 \\\n"
|
|
160
|
+
" --output ./standup.vtt \\\n"
|
|
161
|
+
" --tenant abccorp.com\n"
|
|
162
|
+
"\n"
|
|
163
|
+
" # List available tenants (no download)\n"
|
|
164
|
+
" %(prog)s --list-tenants\n"
|
|
165
|
+
" %(prog)s --list-tenants --format json\n"
|
|
166
|
+
" %(prog)s --list-tenants --output tenants.json --format json\n"
|
|
167
|
+
"\n"
|
|
168
|
+
" # List meetings with transcripts (today only)\n"
|
|
169
|
+
" %(prog)s --list-transcripts\n"
|
|
170
|
+
"\n"
|
|
171
|
+
" # List meetings with transcripts (last 7 days)\n"
|
|
172
|
+
" %(prog)s --list-transcripts 7\n"
|
|
173
|
+
"\n"
|
|
174
|
+
" # List meetings as JSON, write to file\n"
|
|
175
|
+
" %(prog)s --list-transcripts 14 --format json --output meetings.json\n"
|
|
176
|
+
"\n"
|
|
177
|
+
" # Download by meeting ID (UUID from --list-transcripts output)\n"
|
|
178
|
+
" %(prog)s \\\n"
|
|
179
|
+
" --meeting-id 2b4c1f5c-636d-4d78-a087-c2cbf3887fd8 \\\n"
|
|
180
|
+
" --output ./transcripts/meeting.vtt\n"
|
|
181
|
+
"\n"
|
|
182
|
+
" # Use a specific model and region\n"
|
|
183
|
+
" %(prog)s \\\n"
|
|
184
|
+
" --meeting 'Quarterly Review' \\\n"
|
|
185
|
+
" --datetime 2026-03-31 \\\n"
|
|
186
|
+
" --output ./review.vtt \\\n"
|
|
187
|
+
" --model claude-sonnet-4-20250514 \\\n"
|
|
188
|
+
" --gcp-region us-east5\n"
|
|
189
|
+
"\n"
|
|
190
|
+
" # Extract only chat messages (faster, no transcript download)\n"
|
|
191
|
+
" %(prog)s \\\n"
|
|
192
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
193
|
+
" --datetime 2026-04-14 \\\n"
|
|
194
|
+
" --transcript no --metadata no --format json\n"
|
|
195
|
+
"\n"
|
|
196
|
+
" # Extract only participant metadata\n"
|
|
197
|
+
" %(prog)s \\\n"
|
|
198
|
+
" --meeting 'Weekly Standup' \\\n"
|
|
199
|
+
" --datetime 2026-04-14 \\\n"
|
|
200
|
+
" --transcript no --chat no --format json\n"
|
|
201
|
+
"\n"
|
|
202
|
+
"argument constraints:\n"
|
|
203
|
+
" --meeting and --meeting-id are mutually exclusive.\n"
|
|
204
|
+
" --datetime is ONLY valid with --meeting. It is incompatible with\n"
|
|
205
|
+
" --meeting-id (the meeting date is resolved automatically from the UUID).\n"
|
|
206
|
+
" --list-tenants and --list-transcripts are incompatible with --meeting,\n"
|
|
207
|
+
" --meeting-id, --datetime, --dry-run, --transcript, --chat, --metadata.\n"
|
|
208
|
+
" --dry-run and --output are incompatible.\n"
|
|
209
|
+
" --format vtt and --list-transcripts are incompatible.\n"
|
|
210
|
+
" --format vtt and --transcript no are incompatible (when explicitly set).\n"
|
|
211
|
+
" At least one of --transcript, --chat, or --metadata must be yes,\n"
|
|
212
|
+
" except with --dry-run for a pure meeting-exists check.\n"
|
|
213
|
+
" --quiet and --verbose are mutually exclusive.\n"
|
|
214
|
+
"\n"
|
|
215
|
+
"how it works:\n"
|
|
216
|
+
" Step 1 Verify Teams is running with CDP enabled (relaunch if needed)\n"
|
|
217
|
+
" Step 2 Confirm CDP is responding on the configured port\n"
|
|
218
|
+
" Step 3 Discover Teams page targets via CDP\n"
|
|
219
|
+
" Step 3b Verify tenant (when --tenant specified; switch if needed)\n"
|
|
220
|
+
" Step 3c Resolve meeting UUID to subject and date (when --meeting-id\n"
|
|
221
|
+
" is used instead of --meeting)\n"
|
|
222
|
+
" Step 4 Search for the meeting by name and navigate to its chat\n"
|
|
223
|
+
" Step 5 Open Recap when needed, select the correct date occurrence,\n"
|
|
224
|
+
" extract speaker names, then switch to the Transcript tab\n"
|
|
225
|
+
" only when transcript output is requested\n"
|
|
226
|
+
" Step 6 Locate the transcript iframe target when transcript output\n"
|
|
227
|
+
" is requested\n"
|
|
228
|
+
" Step 7 Download the transcript (Approach A or B, auto-detected)\n"
|
|
229
|
+
" when requested; extract roster members and output chat\n"
|
|
230
|
+
" messages when requested; read Event/Call messages and RSVP\n"
|
|
231
|
+
" tracking when needed for metadata; then build VTT or\n"
|
|
232
|
+
" structured JSON\n"
|
|
233
|
+
" Step 8 Validate generated transcript content when transcript output\n"
|
|
234
|
+
" is requested\n"
|
|
235
|
+
"\n"
|
|
236
|
+
"notes:\n"
|
|
237
|
+
" - The meeting name must match what appears in Teams search results.\n"
|
|
238
|
+
" Partial matches work as long as the name is unambiguous.\n"
|
|
239
|
+
" - For recurring meetings, the occurrence dropdown must contain the\n"
|
|
240
|
+
" target date. Teams typically retains the most recent ~5 occurrences.\n"
|
|
241
|
+
" Older occurrences may no longer be available.\n"
|
|
242
|
+
" - The script retries up to 4 times with different search results if\n"
|
|
243
|
+
" the first result does not contain the target date.\n"
|
|
244
|
+
" - Output file is overwritten if it already exists.\n"
|
|
245
|
+
" - All diagnostic output goes to stderr. stdout carries only data\n"
|
|
246
|
+
" (VTT content, or JSON when --format json is set).\n"
|
|
247
|
+
" - In JSON mode with --output, the structured JSON is written to\n"
|
|
248
|
+
" the file and nothing is emitted on stdout. Without --output,\n"
|
|
249
|
+
" the full structured JSON (including the 'transcript' array) is\n"
|
|
250
|
+
" written to stdout.\n"
|
|
251
|
+
"\n"
|
|
252
|
+
"meeting participant data:\n"
|
|
253
|
+
" The VTT metadata NOTE block and JSON output include categorised\n"
|
|
254
|
+
" participant information extracted from four sources:\n"
|
|
255
|
+
"\n"
|
|
256
|
+
" Organiser Meeting creator (from roster popover fibre tree)\n"
|
|
257
|
+
" Invited All chat thread members with names and emails\n"
|
|
258
|
+
" Speakers Members who spoke (from Recap Speakers tab)\n"
|
|
259
|
+
" Attendees Members who joined the call (from Event/Call partlist)\n"
|
|
260
|
+
"\n"
|
|
261
|
+
" RSVP tracking data (accepted, tentative, declined, no response) is\n"
|
|
262
|
+
" extracted from the meeting Details tab when available and applied to\n"
|
|
263
|
+
" the Invited list. RSVP extraction is soft-failure: if the Details\n"
|
|
264
|
+
" tab or Tracking panel is unavailable, the pipeline continues without\n"
|
|
265
|
+
" RSVP data.\n"
|
|
266
|
+
"\n"
|
|
267
|
+
" In the VTT NOTE block, lists with more than 10 members show a count\n"
|
|
268
|
+
" instead of names. When RSVP data is available, the Invited line\n"
|
|
269
|
+
" includes a breakdown (e.g. '90 members (50 accepted, 5 tentative,\n"
|
|
270
|
+
" 2 declined, 33 no response)').\n"
|
|
271
|
+
"\n"
|
|
272
|
+
" In JSON mode, each participant is a dict with 'name', 'email', and\n"
|
|
273
|
+
" optionally 'rsvp_status' fields. The JSON keys are:\n"
|
|
274
|
+
" organiser dict with name and email\n"
|
|
275
|
+
" invited list of dicts (with rsvp_status when available)\n"
|
|
276
|
+
" speakers_list list of dicts\n"
|
|
277
|
+
" attendees list of dicts\n"
|
|
278
|
+
"\n"
|
|
279
|
+
" Note: the 'attendees' list may be incomplete for large meetings\n"
|
|
280
|
+
" due to server-side truncation of the Event/Call partlist XML.\n"
|
|
281
|
+
"\n"
|
|
282
|
+
"exit codes:\n"
|
|
283
|
+
" 0 Success\n"
|
|
284
|
+
" 1 Invalid arguments or missing configuration (BAD_ARGS)\n"
|
|
285
|
+
" 2 Teams/CDP not running or not responding (CDP_UNAVAILABLE)\n"
|
|
286
|
+
" 3 Meeting not found after all retries (MEETING_NOT_FOUND)\n"
|
|
287
|
+
" 4 Transcript download failed (DOWNLOAD_FAILED)\n"
|
|
288
|
+
" 5 Output file verification failed (VERIFICATION_FAILED)\n"
|
|
289
|
+
" 6 Tenant not found in available tenants (TENANT_NOT_FOUND)\n"
|
|
290
|
+
" 130 Interrupted by SIGINT (Ctrl+C)\n"
|
|
291
|
+
" 143 Terminated by SIGTERM\n"
|
|
292
|
+
"\n"
|
|
293
|
+
"output modes:\n"
|
|
294
|
+
" --output PATH --format vtt File written, nothing on stdout\n"
|
|
295
|
+
" --output PATH --format json File written, nothing on stdout\n"
|
|
296
|
+
" (no --output) --format vtt VTT content on stdout\n"
|
|
297
|
+
" (no --output) --format json Structured JSON on stdout\n"
|
|
298
|
+
" --dry-run --format vtt Summary logged to stderr\n"
|
|
299
|
+
" --dry-run --format json JSON metadata on stdout (no VTT)\n"
|
|
300
|
+
" --dry-run --output PATH Error (incompatible)\n"
|
|
301
|
+
" --list-tenants --format vtt Tenant table on stdout\n"
|
|
302
|
+
" --list-tenants --format json Tenant JSON array on stdout\n"
|
|
303
|
+
" --list-tenants --output PATH Tenant list written to file\n"
|
|
304
|
+
" --list-transcripts Meeting table on stdout (today)\n"
|
|
305
|
+
" --list-transcripts N Meeting table on stdout (N days)\n"
|
|
306
|
+
" --list-transcripts --format json Meeting JSON array on stdout\n"
|
|
307
|
+
" --list-transcripts --format vtt ERROR (incompatible)\n"
|
|
308
|
+
" --list-transcripts -o PATH Meeting list written to file\n"
|
|
309
|
+
"\n"
|
|
310
|
+
"json output schema (--format json, all components):\n"
|
|
311
|
+
" {\n"
|
|
312
|
+
' "status": "ok",\n'
|
|
313
|
+
' "meeting": "Weekly Standup",\n'
|
|
314
|
+
' "start": "2026-04-14T10:00+01:00",\n'
|
|
315
|
+
' "end": "2026-04-14T10:30+01:00",\n'
|
|
316
|
+
' "organiser": {"name": "Alice", "email": "alice@example.com"},\n'
|
|
317
|
+
' "invited": [\n'
|
|
318
|
+
' {"name": "Bob", "email": "bob@x.com", "rsvp_status": "accepted"}\n'
|
|
319
|
+
" ],\n"
|
|
320
|
+
' "speakers_list": [{"name": "Alice", "email": "alice@x.com"}],\n'
|
|
321
|
+
' "attendees": [{"name": "Alice", "email": "alice@x.com"}],\n'
|
|
322
|
+
' "chat": [\n'
|
|
323
|
+
" {\n"
|
|
324
|
+
' "author": "Bob", "text": "Hello",\n'
|
|
325
|
+
' "reactions": [{"type": "like", "count": 1, "users": ["Alice"]}],\n'
|
|
326
|
+
' "offset": "00:05:12.000"\n'
|
|
327
|
+
" }\n"
|
|
328
|
+
" ],\n"
|
|
329
|
+
' "transcript": [\n'
|
|
330
|
+
" {\n"
|
|
331
|
+
' "id": 1, "start": "00:00:03.366", "end": "00:00:09.117",\n'
|
|
332
|
+
' "start_sec": 3.366, "end_sec": 9.117,\n'
|
|
333
|
+
' "speaker": "Alice", "text": "Good morning everyone."\n'
|
|
334
|
+
" }\n"
|
|
335
|
+
" ],\n"
|
|
336
|
+
' "parts": 1, "cues": 342,\n'
|
|
337
|
+
' "speakers": {"Alice": 180, "Bob": 162},\n'
|
|
338
|
+
' "file_size": 48210, "chat_messages": 1\n'
|
|
339
|
+
" }\n"
|
|
340
|
+
"\n"
|
|
341
|
+
"json output schema (--transcript no --chat yes --metadata no):\n"
|
|
342
|
+
" {\n"
|
|
343
|
+
' "status": "ok",\n'
|
|
344
|
+
' "meeting": "Weekly Standup",\n'
|
|
345
|
+
' "start": "2026-04-14T10:00+01:00",\n'
|
|
346
|
+
' "end": "2026-04-14T10:30+01:00",\n'
|
|
347
|
+
' "chat": [\n'
|
|
348
|
+
' {"author": "Bob", "text": "Hello", "reactions": null,\n'
|
|
349
|
+
' "offset": "00:05:12.000"}\n'
|
|
350
|
+
" ]\n"
|
|
351
|
+
" }\n"
|
|
352
|
+
"\n"
|
|
353
|
+
"json output schema (--transcript no --chat no --metadata yes):\n"
|
|
354
|
+
" {\n"
|
|
355
|
+
' "status": "ok",\n'
|
|
356
|
+
' "meeting": "Weekly Standup",\n'
|
|
357
|
+
' "start": "2026-04-14T10:00+01:00",\n'
|
|
358
|
+
' "end": "2026-04-14T10:30+01:00",\n'
|
|
359
|
+
' "organiser": {"name": "Alice", "email": "alice@example.com"},\n'
|
|
360
|
+
' "invited": [\n'
|
|
361
|
+
' {"name": "Bob", "email": "bob@x.com", "rsvp_status": "accepted"}\n'
|
|
362
|
+
" ],\n"
|
|
363
|
+
' "speakers_list": [{"name": "Alice", "email": "alice@x.com"}],\n'
|
|
364
|
+
' "attendees": [{"name": "Alice", "email": "alice@x.com"}]\n'
|
|
365
|
+
" }\n"
|
|
366
|
+
"\n"
|
|
367
|
+
"json output schema (--transcript yes --chat no --metadata no):\n"
|
|
368
|
+
" {\n"
|
|
369
|
+
' "status": "ok",\n'
|
|
370
|
+
' "meeting": "Weekly Standup",\n'
|
|
371
|
+
' "start": "2026-04-14T10:00+01:00",\n'
|
|
372
|
+
' "end": "2026-04-14T10:30+01:00",\n'
|
|
373
|
+
' "parts": 1, "cues": 342,\n'
|
|
374
|
+
' "speakers": {"Alice": 180, "Bob": 162},\n'
|
|
375
|
+
' "file_size": 48210,\n'
|
|
376
|
+
' "transcript": [{...cues...}]\n'
|
|
377
|
+
" }\n"
|
|
378
|
+
"\n"
|
|
379
|
+
"json output schema (error):\n"
|
|
380
|
+
" {\n"
|
|
381
|
+
' "status": "error",\n'
|
|
382
|
+
' "code": "MEETING_NOT_FOUND",\n'
|
|
383
|
+
' "message": "Could not find target 2026-04-14 in search results.",\n'
|
|
384
|
+
' "step": "step5"\n'
|
|
385
|
+
" }\n"
|
|
386
|
+
"\n"
|
|
387
|
+
"json field reference:\n"
|
|
388
|
+
" status string always present ('ok' or 'error')\n"
|
|
389
|
+
" meeting string meeting title\n"
|
|
390
|
+
" start string ISO 8601 meeting start time\n"
|
|
391
|
+
" end string ISO 8601 meeting end time (when parseable)\n"
|
|
392
|
+
" dry_run boolean present only in --dry-run mode\n"
|
|
393
|
+
" organiser object {name, email} (--metadata yes)\n"
|
|
394
|
+
" invited array [{name, email, rsvp_status?}] (--metadata yes)\n"
|
|
395
|
+
" speakers_list array [{name, email}] (--metadata yes)\n"
|
|
396
|
+
" attendees array [{name, email}] (--metadata yes + attendance data)\n"
|
|
397
|
+
" chat array [{author, text, reactions, offset}] (--chat yes;\n"
|
|
398
|
+
" offset may be null when not computable)\n"
|
|
399
|
+
" chat_messages integer count (all-components mode only)\n"
|
|
400
|
+
" parts integer transcript part count (--transcript yes)\n"
|
|
401
|
+
" cues integer total cue count (--transcript yes)\n"
|
|
402
|
+
" speakers object {name: cue_count} (--transcript yes)\n"
|
|
403
|
+
" file_size integer VTT content size in bytes (--transcript yes)\n"
|
|
404
|
+
" transcript array [{id, start, end, start_sec, end_sec, speaker, text}]\n"
|
|
405
|
+
" (--transcript yes)\n"
|
|
406
|
+
" code string machine-stable error code (errors only)\n"
|
|
407
|
+
" message string human-readable error description (errors only)\n"
|
|
408
|
+
" step string pipeline step that failed (runtime errors)\n"
|
|
409
|
+
"\n"
|
|
410
|
+
"error codes (machine-stable, used in text prefix and JSON 'code' field):\n"
|
|
411
|
+
" BAD_ARGS Invalid CLI arguments or missing config\n"
|
|
412
|
+
" CDP_UNAVAILABLE Teams/CDP not running or not responding\n"
|
|
413
|
+
" RESTART_BLOCKED Teams restart blocked by active/unknown call state\n"
|
|
414
|
+
" NO_TARGETS No Teams page targets found via CDP\n"
|
|
415
|
+
" SEARCH_FAILED Search results did not appear\n"
|
|
416
|
+
" MEETING_NOT_FOUND Meeting not found in search results\n"
|
|
417
|
+
" RECAP_NOT_FOUND Recap tab not found after navigation\n"
|
|
418
|
+
" TRANSCRIPT_NOT_FOUND Transcript tab not found\n"
|
|
419
|
+
" IFRAME_NOT_FOUND No transcript iframe target\n"
|
|
420
|
+
" DOWNLOAD_FAILED VTT download failed\n"
|
|
421
|
+
" VERIFICATION_FAILED Output file is invalid\n"
|
|
422
|
+
" TENANT_NOT_FOUND Tenant not in available tenants list\n"
|
|
423
|
+
" INTERRUPTED Process interrupted by SIGINT\n"
|
|
424
|
+
" TERMINATED Process terminated by SIGTERM\n"
|
|
425
|
+
"\n"
|
|
426
|
+
"mcp server:\n"
|
|
427
|
+
" An MCP (Model Context Protocol) server is available for tool-use by\n"
|
|
428
|
+
" AI agents. Start with: teams-transcripts-mcp\n"
|
|
429
|
+
" Or on a network port: teams-transcripts-mcp --port 8080\n"
|
|
430
|
+
" Recommended tool: download(meeting_id=UUID) -- the unified entry point.\n"
|
|
431
|
+
" Also available: restart_teams, list_tenants, list_transcripts,\n"
|
|
432
|
+
" download_by_meeting_id, download_all, download_transcript,\n"
|
|
433
|
+
" download_chat, download_meeting_metadata, and UUID-based variants.\n"
|
|
434
|
+
" AI agents should prefer MCP tools over direct CLI invocation.\n"
|
|
435
|
+
" STDIO transport (default) for subprocess spawning by MCP clients.\n"
|
|
436
|
+
" SSE transport (--port) for network access.\n"
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
p.add_argument(
|
|
440
|
+
"--list-tenants",
|
|
441
|
+
action="store_true",
|
|
442
|
+
default=False,
|
|
443
|
+
help=(
|
|
444
|
+
"List all Azure AD tenants the signed-in user can access, "
|
|
445
|
+
"then exit. Output includes tenant name, primary domain, "
|
|
446
|
+
"and tenant ID. The current tenant is marked with *. "
|
|
447
|
+
"Use the name or domain value with --tenant in a subsequent "
|
|
448
|
+
"invocation. Incompatible with --meeting, --meeting-id, and --datetime."
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
p.add_argument(
|
|
452
|
+
"--list-transcripts",
|
|
453
|
+
nargs="?",
|
|
454
|
+
const=1,
|
|
455
|
+
type=int,
|
|
456
|
+
default=None,
|
|
457
|
+
metavar="DAYS",
|
|
458
|
+
help=(
|
|
459
|
+
"List meetings with transcripts, then exit. Accepts an optional "
|
|
460
|
+
"number of days to look back (default: 1 = today only; must be >= 1). Output is "
|
|
461
|
+
"a plain-text table by default; use --format json for structured "
|
|
462
|
+
"output. --format vtt is not valid with this flag. Use --output "
|
|
463
|
+
"to write the listing to a file instead of stdout. When --tenant "
|
|
464
|
+
"is specified, only meetings from that tenant are listed. Without "
|
|
465
|
+
"--tenant, all signed-in tenants are queried and results aggregated."
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
meeting_group = p.add_mutually_exclusive_group()
|
|
469
|
+
meeting_group.add_argument(
|
|
470
|
+
"--meeting",
|
|
471
|
+
default=None,
|
|
472
|
+
metavar="NAME",
|
|
473
|
+
help=(
|
|
474
|
+
"Meeting name to search for in Teams. This is typed into the Teams "
|
|
475
|
+
"search bar, so it should match the meeting title as it appears in the "
|
|
476
|
+
"Teams UI. For best results use the full meeting title. "
|
|
477
|
+
"Mutually exclusive with --meeting-id and --call-id."
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
meeting_group.add_argument(
|
|
481
|
+
"--meeting-id",
|
|
482
|
+
default=None,
|
|
483
|
+
metavar="UUID",
|
|
484
|
+
dest="meeting_id",
|
|
485
|
+
help=(
|
|
486
|
+
"Meeting UUID to download (from --list-transcripts ID column). "
|
|
487
|
+
"Identifies a meeting thread. For non-recurring meetings this is "
|
|
488
|
+
"unique; for recurring meetings all occurrences share the same UUID. "
|
|
489
|
+
"Use --call-id instead for unambiguous occurrence targeting. "
|
|
490
|
+
"The tool resolves the meeting subject and date via the MCPS API "
|
|
491
|
+
"and then navigates as if --meeting were used. "
|
|
492
|
+
"Mutually exclusive with --meeting and --call-id. Do not pass "
|
|
493
|
+
"--datetime with this flag (the date is resolved automatically)."
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
meeting_group.add_argument(
|
|
497
|
+
"--call-id",
|
|
498
|
+
default=None,
|
|
499
|
+
metavar="CALL_ID",
|
|
500
|
+
dest="call_id",
|
|
501
|
+
help=(
|
|
502
|
+
"Call identifier to download (from --list-transcripts Call ID column). "
|
|
503
|
+
"Uniquely identifies a specific occurrence of a meeting, including "
|
|
504
|
+
"recurring meetings. This is the preferred identifier when targeting "
|
|
505
|
+
"a specific occurrence. The tool resolves the meeting subject, date, "
|
|
506
|
+
"and thread via the MCPS API. "
|
|
507
|
+
"Mutually exclusive with --meeting and --meeting-id. Do not pass "
|
|
508
|
+
"--datetime with this flag (the date is resolved automatically)."
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
p.add_argument(
|
|
512
|
+
"--datetime",
|
|
513
|
+
default=None,
|
|
514
|
+
metavar="YYYY-MM-DD[THH:MM]",
|
|
515
|
+
dest="datetime",
|
|
516
|
+
help=(
|
|
517
|
+
"Target meeting date, optionally with time, in ISO 8601 format. "
|
|
518
|
+
"Only valid with --meeting; incompatible with --meeting-id "
|
|
519
|
+
"(the date is resolved automatically from the UUID). "
|
|
520
|
+
"Defaults to today's date when omitted. "
|
|
521
|
+
"Date only (e.g. 2026-04-22) selects the first occurrence on that "
|
|
522
|
+
"date. Date with time (e.g. 2026-04-22T14:00) disambiguates when "
|
|
523
|
+
"there are multiple meetings with the same name on the same day. "
|
|
524
|
+
"The time is matched against the start time shown in the Teams "
|
|
525
|
+
"occurrence dropdown (e.g. '22 April 2026 14:00 - 15:00')."
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
p.add_argument(
|
|
529
|
+
"--output",
|
|
530
|
+
default=None,
|
|
531
|
+
metavar="PATH",
|
|
532
|
+
help=(
|
|
533
|
+
"Output file path. The file is created (or overwritten) at "
|
|
534
|
+
"this path. Parent directories must already exist. When "
|
|
535
|
+
"omitted, content is written to stdout. The file format is "
|
|
536
|
+
"determined by --format (VTT or structured JSON)."
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
p.add_argument(
|
|
540
|
+
"--dry-run",
|
|
541
|
+
action="store_true",
|
|
542
|
+
default=False,
|
|
543
|
+
help=(
|
|
544
|
+
"Find the meeting, load Recap only when transcript or metadata is "
|
|
545
|
+
"requested, detect parts, extract safe roster metadata when metadata "
|
|
546
|
+
"or chat is requested, then exit without downloading transcripts, "
|
|
547
|
+
"extracting chat, or opening Details tracking. Incompatible with --output."
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
p.add_argument(
|
|
551
|
+
"--gcp-project",
|
|
552
|
+
default=None,
|
|
553
|
+
metavar="PROJECT_ID",
|
|
554
|
+
help=(
|
|
555
|
+
"Google Cloud project ID for Vertex AI API calls. Falls back to "
|
|
556
|
+
"the GOOGLE_CLOUD_PROJECT environment variable. A value must be "
|
|
557
|
+
"provided via either this flag or the environment variable."
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
p.add_argument(
|
|
561
|
+
"--port",
|
|
562
|
+
type=int,
|
|
563
|
+
default=9333,
|
|
564
|
+
metavar="PORT",
|
|
565
|
+
help=(
|
|
566
|
+
"CDP remote debugging port that Teams is listening on "
|
|
567
|
+
"(default: %(default)s). Must match the value passed to Teams via "
|
|
568
|
+
"WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. Must be between 1 and 65535."
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
p.add_argument(
|
|
572
|
+
"--force-restart",
|
|
573
|
+
action="store_true",
|
|
574
|
+
default=False,
|
|
575
|
+
help=(
|
|
576
|
+
"Allow Teams to be killed and relaunched even when an active call is "
|
|
577
|
+
"detected or call state cannot be determined. This may interrupt a live "
|
|
578
|
+
"call; use only when interruption is explicitly intended."
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
p.add_argument(
|
|
582
|
+
"--tenant",
|
|
583
|
+
default=None,
|
|
584
|
+
metavar="DOMAIN",
|
|
585
|
+
help=(
|
|
586
|
+
"Azure AD tenant domain to verify before downloading (e.g. "
|
|
587
|
+
"'abccorp.com', 'contoso.onmicrosoft.com'). When specified, the tool "
|
|
588
|
+
"checks that Teams is signed into the matching tenant and "
|
|
589
|
+
"automatically switches if needed (by killing and relaunching "
|
|
590
|
+
"Teams). Exits with code 6 if the tenant is not in the "
|
|
591
|
+
"available tenants list. Falls back to the TEAMS_TENANT "
|
|
592
|
+
"environment variable. Omit to skip tenant checking."
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
p.add_argument(
|
|
596
|
+
"--gcp-region",
|
|
597
|
+
default=None,
|
|
598
|
+
metavar="REGION",
|
|
599
|
+
help=(
|
|
600
|
+
"Vertex AI region for model inference (default: 'global'). Falls "
|
|
601
|
+
"back to the CLOUD_ML_REGION environment variable."
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
p.add_argument(
|
|
605
|
+
"--model",
|
|
606
|
+
default=None,
|
|
607
|
+
metavar="MODEL_ID",
|
|
608
|
+
help=(
|
|
609
|
+
"Anthropic model ID for the LLM agent used in non-deterministic "
|
|
610
|
+
"navigation steps (default: 'claude-haiku-4-5'). Falls back to "
|
|
611
|
+
"the MODEL_ID environment variable. Haiku is recommended for speed "
|
|
612
|
+
"and cost; the agent prompts are simple classification tasks."
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
p.add_argument(
|
|
616
|
+
"--adc",
|
|
617
|
+
default=None,
|
|
618
|
+
metavar="PATH",
|
|
619
|
+
help=(
|
|
620
|
+
"Path to a Google Application Default Credentials JSON file. "
|
|
621
|
+
"Falls back to the GOOGLE_ADC environment variable, then to "
|
|
622
|
+
"~/.config/gcloud/application_default_credentials.json."
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
verbosity = p.add_mutually_exclusive_group()
|
|
627
|
+
verbosity.add_argument(
|
|
628
|
+
"--quiet",
|
|
629
|
+
"-q",
|
|
630
|
+
action="store_true",
|
|
631
|
+
default=False,
|
|
632
|
+
help="Suppress all diagnostic output on stderr. Errors and "
|
|
633
|
+
"structured output (--format json) are still emitted.",
|
|
634
|
+
)
|
|
635
|
+
verbosity.add_argument(
|
|
636
|
+
"--verbose",
|
|
637
|
+
"-v",
|
|
638
|
+
action="store_true",
|
|
639
|
+
default=False,
|
|
640
|
+
help="Emit additional diagnostic detail on stderr (CDP payloads, "
|
|
641
|
+
"agent prompts, React fibre data sizes).",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
p.set_defaults(_format_explicit=False)
|
|
645
|
+
p.add_argument(
|
|
646
|
+
"--format",
|
|
647
|
+
dest="output_format",
|
|
648
|
+
choices=["vtt", "json"],
|
|
649
|
+
default="vtt",
|
|
650
|
+
action=_ExplicitFormatAction,
|
|
651
|
+
help="Output format. 'vtt' (default) writes WebVTT content. "
|
|
652
|
+
"'json' writes structured JSON with a 'transcript' array of cue "
|
|
653
|
+
"objects. Both formats write to --output (file) or stdout.",
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
p.add_argument(
|
|
657
|
+
"--transcript",
|
|
658
|
+
choices=["yes", "no"],
|
|
659
|
+
default="yes",
|
|
660
|
+
help="Include transcript cues in output (default: yes). When 'no', "
|
|
661
|
+
"the transcript download is skipped and output is forced to JSON. "
|
|
662
|
+
"Combine with --chat no and --metadata no only with --dry-run for a "
|
|
663
|
+
"pure meeting-exists check.",
|
|
664
|
+
)
|
|
665
|
+
p.add_argument(
|
|
666
|
+
"--chat",
|
|
667
|
+
choices=["yes", "no"],
|
|
668
|
+
default="yes",
|
|
669
|
+
help="Include chat messages in output (default: yes). When 'no', "
|
|
670
|
+
"chat messages are omitted from output. If --metadata yes, the tool may "
|
|
671
|
+
"still read Event/Call messages internally to build attendee metadata.",
|
|
672
|
+
)
|
|
673
|
+
p.add_argument(
|
|
674
|
+
"--metadata",
|
|
675
|
+
choices=["yes", "no"],
|
|
676
|
+
default="yes",
|
|
677
|
+
help="Include meeting participant metadata in output (default: yes). "
|
|
678
|
+
"When 'no', participant data (organiser, invited, speakers, "
|
|
679
|
+
"attendees, RSVP) is omitted from the output. Roster extraction "
|
|
680
|
+
"still runs if --chat yes (for author name resolution).",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
return p.parse_args()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class _ExplicitFormatAction(argparse.Action):
|
|
687
|
+
"""Record that ``--format`` was supplied explicitly."""
|
|
688
|
+
|
|
689
|
+
def __call__(
|
|
690
|
+
self,
|
|
691
|
+
parser: argparse.ArgumentParser,
|
|
692
|
+
namespace: argparse.Namespace,
|
|
693
|
+
values: str | Sequence[Any] | None,
|
|
694
|
+
option_string: str | None = None,
|
|
695
|
+
) -> None:
|
|
696
|
+
setattr(namespace, self.dest, values)
|
|
697
|
+
namespace._format_explicit = True
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
# Configuration resolution
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def resolve_config(
|
|
706
|
+
args: argparse.Namespace,
|
|
707
|
+
*,
|
|
708
|
+
env_override: dict[str, str | None] | None = None,
|
|
709
|
+
) -> MeetingConfig:
|
|
710
|
+
"""Resolve all configuration from CLI args, env vars, and defaults.
|
|
711
|
+
|
|
712
|
+
Returns a frozen :class:`~domain.models.MeetingConfig`. Does not
|
|
713
|
+
mutate any global state.
|
|
714
|
+
|
|
715
|
+
Calls ``exit_error()`` with the appropriate ``format_json`` setting
|
|
716
|
+
for validation failures, producing both human-readable stderr output
|
|
717
|
+
and (when ``--format json``) structured JSON on stdout.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
args: Parsed CLI arguments from :func:`parse_args`.
|
|
721
|
+
env_override: Optional mapping of environment variable names to
|
|
722
|
+
values (or ``None`` to unset). Used by tests to inject
|
|
723
|
+
environment without patching ``os.environ`` externally.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
A frozen ``MeetingConfig`` instance.
|
|
727
|
+
"""
|
|
728
|
+
# Apply env overrides for the duration of resolution
|
|
729
|
+
saved_env: dict[str, str | None] = {}
|
|
730
|
+
if env_override:
|
|
731
|
+
for k, v in env_override.items():
|
|
732
|
+
saved_env[k] = os.environ.get(k)
|
|
733
|
+
if v is None:
|
|
734
|
+
os.environ.pop(k, None)
|
|
735
|
+
else:
|
|
736
|
+
os.environ[k] = v
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
return _resolve_config_inner(args)
|
|
740
|
+
finally:
|
|
741
|
+
# Restore original environment
|
|
742
|
+
if env_override:
|
|
743
|
+
for k in env_override:
|
|
744
|
+
original = saved_env[k]
|
|
745
|
+
if original is None:
|
|
746
|
+
os.environ.pop(k, None)
|
|
747
|
+
else:
|
|
748
|
+
os.environ[k] = original
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _format_explicitly_set(args: argparse.Namespace) -> bool:
|
|
752
|
+
"""Check whether --format was explicitly provided on the command line.
|
|
753
|
+
|
|
754
|
+
``parse_args()`` records this via :class:`_ExplicitFormatAction`. The
|
|
755
|
+
fallback keeps direct tests that construct ``argparse.Namespace`` objects
|
|
756
|
+
compatible without consulting global ``sys.argv``.
|
|
757
|
+
"""
|
|
758
|
+
return bool(getattr(args, "_format_explicit", False))
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ---------------------------------------------------------------------------
|
|
762
|
+
# Environment variable resolution (pydantic-settings)
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class _GcpSettings(BaseSettings):
|
|
767
|
+
"""Resolve GCP and tenant configuration from environment variables.
|
|
768
|
+
|
|
769
|
+
Each field maps to one environment variable (case-insensitive match).
|
|
770
|
+
CLI arguments take precedence and are merged in
|
|
771
|
+
``_resolve_config_inner()``.
|
|
772
|
+
|
|
773
|
+
Fields:
|
|
774
|
+
google_cloud_project: GCP project ID (``GOOGLE_CLOUD_PROJECT``).
|
|
775
|
+
cloud_ml_region: Vertex AI region (``CLOUD_ML_REGION``).
|
|
776
|
+
model_id: LLM model identifier (``MODEL_ID``).
|
|
777
|
+
google_adc: Path to ADC JSON (``GOOGLE_ADC``).
|
|
778
|
+
teams_tenant: Tenant domain (``TEAMS_TENANT``).
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
782
|
+
|
|
783
|
+
google_cloud_project: str | None = None
|
|
784
|
+
cloud_ml_region: str = "global"
|
|
785
|
+
model_id: str = "claude-haiku-4-5"
|
|
786
|
+
google_adc: str = "~/.config/gcloud/application_default_credentials.json"
|
|
787
|
+
teams_tenant: str | None = None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _resolve_config_inner(args: argparse.Namespace) -> MeetingConfig:
|
|
791
|
+
"""Internal resolution logic (env overrides already applied)."""
|
|
792
|
+
format_json = args.output_format == "json"
|
|
793
|
+
|
|
794
|
+
# --meeting, --meeting-id, and --call-id mutual exclusivity is enforced by
|
|
795
|
+
# argparse. Check that at least one is provided (argparse group is not
|
|
796
|
+
# required because list modes don't need either).
|
|
797
|
+
if not args.meeting and not args.meeting_id and not args.call_id:
|
|
798
|
+
exit_error(
|
|
799
|
+
EXIT_BAD_ARGS,
|
|
800
|
+
"BAD_ARGS",
|
|
801
|
+
"One of --meeting, --meeting-id, or --call-id is required.",
|
|
802
|
+
format_json=format_json,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# --datetime is incompatible with --meeting-id (date is resolved from MCPS).
|
|
806
|
+
if args.meeting_id and args.datetime:
|
|
807
|
+
exit_error(
|
|
808
|
+
EXIT_BAD_ARGS,
|
|
809
|
+
"BAD_ARGS",
|
|
810
|
+
"--datetime is incompatible with --meeting-id. "
|
|
811
|
+
"The meeting date is resolved automatically from the UUID.",
|
|
812
|
+
format_json=format_json,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# --datetime is incompatible with --call-id (date is resolved from MCPS).
|
|
816
|
+
if args.call_id and args.datetime:
|
|
817
|
+
exit_error(
|
|
818
|
+
EXIT_BAD_ARGS,
|
|
819
|
+
"BAD_ARGS",
|
|
820
|
+
"--datetime is incompatible with --call-id. "
|
|
821
|
+
"The meeting date is resolved automatically from the call ID.",
|
|
822
|
+
format_json=format_json,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
if args.meeting_id:
|
|
826
|
+
try:
|
|
827
|
+
args.meeting_id = str(UUID(args.meeting_id))
|
|
828
|
+
except ValueError:
|
|
829
|
+
exit_error(
|
|
830
|
+
EXIT_BAD_ARGS,
|
|
831
|
+
"BAD_ARGS",
|
|
832
|
+
f"Invalid meeting ID '{args.meeting_id}'. Expected UUID.",
|
|
833
|
+
format_json=format_json,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
if not 1 <= args.port <= 65535:
|
|
837
|
+
exit_error(
|
|
838
|
+
EXIT_BAD_ARGS,
|
|
839
|
+
"BAD_ARGS",
|
|
840
|
+
f"Invalid port '{args.port}'. Expected a value from 1 to 65535.",
|
|
841
|
+
format_json=format_json,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
# --datetime defaults to today's date when omitted (only for --meeting mode).
|
|
845
|
+
if not args.datetime and not args.meeting_id and not args.call_id:
|
|
846
|
+
args.datetime = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
|
|
847
|
+
|
|
848
|
+
# Resolve GCP and tenant settings from env vars (CLI args take precedence)
|
|
849
|
+
settings = _GcpSettings()
|
|
850
|
+
|
|
851
|
+
project = args.gcp_project or settings.google_cloud_project
|
|
852
|
+
if not project:
|
|
853
|
+
exit_error(
|
|
854
|
+
EXIT_BAD_ARGS,
|
|
855
|
+
"BAD_ARGS",
|
|
856
|
+
"--gcp-project not provided and GOOGLE_CLOUD_PROJECT not set.",
|
|
857
|
+
format_json=format_json,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
model = args.model or settings.model_id
|
|
861
|
+
region = args.gcp_region or settings.cloud_ml_region
|
|
862
|
+
adc = args.adc or os.path.expanduser(settings.google_adc)
|
|
863
|
+
|
|
864
|
+
# Parse --datetime: accept YYYY-MM-DD or YYYY-MM-DDTHH:MM
|
|
865
|
+
# When --meeting-id or --call-id is used, date is populated later from MCPS resolution.
|
|
866
|
+
target_date = ""
|
|
867
|
+
target_time = None
|
|
868
|
+
dt_raw = args.datetime
|
|
869
|
+
if dt_raw:
|
|
870
|
+
if "T" in dt_raw:
|
|
871
|
+
date_part, time_part = dt_raw.split("T", 1)
|
|
872
|
+
# Validate date
|
|
873
|
+
try:
|
|
874
|
+
date.fromisoformat(date_part)
|
|
875
|
+
except ValueError:
|
|
876
|
+
exit_error(
|
|
877
|
+
EXIT_BAD_ARGS,
|
|
878
|
+
"BAD_ARGS",
|
|
879
|
+
f"Invalid date '{date_part}'. Expected YYYY-MM-DD.",
|
|
880
|
+
format_json=format_json,
|
|
881
|
+
)
|
|
882
|
+
# Validate time (HH:MM with valid ranges)
|
|
883
|
+
if not re.match(r"^\d{2}:\d{2}$", time_part):
|
|
884
|
+
exit_error(
|
|
885
|
+
EXIT_BAD_ARGS,
|
|
886
|
+
"BAD_ARGS",
|
|
887
|
+
f"Invalid time format '{time_part}'. Expected HH:MM.",
|
|
888
|
+
format_json=format_json,
|
|
889
|
+
)
|
|
890
|
+
hh, mm = time_part.split(":")
|
|
891
|
+
if not (0 <= int(hh) <= 23 and 0 <= int(mm) <= 59):
|
|
892
|
+
exit_error(
|
|
893
|
+
EXIT_BAD_ARGS,
|
|
894
|
+
"BAD_ARGS",
|
|
895
|
+
f"Invalid time '{time_part}'. Hours must be 00-23, minutes 00-59.",
|
|
896
|
+
format_json=format_json,
|
|
897
|
+
)
|
|
898
|
+
target_time = time_part
|
|
899
|
+
target_date = date_part
|
|
900
|
+
else:
|
|
901
|
+
try:
|
|
902
|
+
date.fromisoformat(dt_raw)
|
|
903
|
+
except ValueError:
|
|
904
|
+
exit_error(
|
|
905
|
+
EXIT_BAD_ARGS,
|
|
906
|
+
"BAD_ARGS",
|
|
907
|
+
f"Invalid date '{dt_raw}'. Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
|
908
|
+
format_json=format_json,
|
|
909
|
+
)
|
|
910
|
+
target_date = dt_raw
|
|
911
|
+
|
|
912
|
+
# Validate flag combinations
|
|
913
|
+
if args.dry_run and args.output:
|
|
914
|
+
exit_error(
|
|
915
|
+
EXIT_BAD_ARGS,
|
|
916
|
+
"BAD_ARGS",
|
|
917
|
+
"--dry-run and --output are incompatible. Dry-run does not produce a file.",
|
|
918
|
+
format_json=format_json,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
# Resolve component flags
|
|
922
|
+
include_transcript = args.transcript == "yes"
|
|
923
|
+
include_chat = args.chat == "yes"
|
|
924
|
+
include_metadata = args.metadata == "yes"
|
|
925
|
+
|
|
926
|
+
# At least one component must be included for real extraction. Dry-run may
|
|
927
|
+
# disable all components as a pure meeting-exists check.
|
|
928
|
+
if not include_transcript and not include_chat and not include_metadata and not args.dry_run:
|
|
929
|
+
exit_error(
|
|
930
|
+
EXIT_BAD_ARGS,
|
|
931
|
+
"BAD_ARGS",
|
|
932
|
+
"At least one of --transcript, --chat, or --metadata must be 'yes'.",
|
|
933
|
+
format_json=format_json,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# --transcript no requires JSON format (VTT without cues is meaningless)
|
|
937
|
+
if not include_transcript and args.output_format == "vtt":
|
|
938
|
+
# Check whether user explicitly passed --format vtt
|
|
939
|
+
# If so, error. If --format was left at default, override to json.
|
|
940
|
+
if _format_explicitly_set(args):
|
|
941
|
+
exit_error(
|
|
942
|
+
EXIT_BAD_ARGS,
|
|
943
|
+
"BAD_ARGS",
|
|
944
|
+
"--format vtt requires --transcript yes. "
|
|
945
|
+
"Use --format json when transcript is excluded.",
|
|
946
|
+
format_json=False,
|
|
947
|
+
)
|
|
948
|
+
args.output_format = "json"
|
|
949
|
+
format_json = True
|
|
950
|
+
|
|
951
|
+
# When --output is omitted (and not --dry-run), use a temp file
|
|
952
|
+
# internally so the file-based pipeline works unchanged. The temp
|
|
953
|
+
# file content is written to stdout (or embedded in JSON) at the end.
|
|
954
|
+
# When transcript is excluded, no temp file is needed (JSON-only output).
|
|
955
|
+
output_to_stdout = not args.dry_run and args.output is None
|
|
956
|
+
if output_to_stdout and include_transcript:
|
|
957
|
+
tmp_fd, output_path = tempfile.mkstemp(suffix=".vtt")
|
|
958
|
+
os.close(tmp_fd)
|
|
959
|
+
else:
|
|
960
|
+
output_path = args.output # None when --dry-run or --transcript no without --output
|
|
961
|
+
|
|
962
|
+
# Resolve tenant: CLI flag takes precedence over environment variable
|
|
963
|
+
tenant = args.tenant or settings.teams_tenant or None
|
|
964
|
+
|
|
965
|
+
return MeetingConfig(
|
|
966
|
+
meeting=args.meeting or "",
|
|
967
|
+
meeting_id=args.meeting_id,
|
|
968
|
+
call_id=args.call_id,
|
|
969
|
+
date=target_date,
|
|
970
|
+
time=target_time,
|
|
971
|
+
output=output_path,
|
|
972
|
+
output_to_stdout=output_to_stdout,
|
|
973
|
+
dry_run=args.dry_run,
|
|
974
|
+
port=args.port,
|
|
975
|
+
tenant=tenant,
|
|
976
|
+
gcp_project=project,
|
|
977
|
+
gcp_region=region,
|
|
978
|
+
model=model,
|
|
979
|
+
adc=adc,
|
|
980
|
+
output_format=args.output_format,
|
|
981
|
+
quiet=args.quiet,
|
|
982
|
+
verbose=args.verbose,
|
|
983
|
+
include_transcript=include_transcript,
|
|
984
|
+
include_chat=include_chat,
|
|
985
|
+
include_metadata=include_metadata,
|
|
986
|
+
force_restart=getattr(args, "force_restart", False),
|
|
987
|
+
)
|