@actagent/google-meet 2026.6.2

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 (41) hide show
  1. package/README.md +26 -0
  2. package/actagent.plugin.json +532 -0
  3. package/doctor-contract-api.ts +2 -0
  4. package/google-meet.live.test.ts +86 -0
  5. package/index.create.test.ts +672 -0
  6. package/index.test.ts +5130 -0
  7. package/index.ts +1225 -0
  8. package/node-host.test.ts +242 -0
  9. package/npm-shrinkwrap.json +39 -0
  10. package/package.json +46 -0
  11. package/src/agent-consult.ts +159 -0
  12. package/src/calendar.ts +253 -0
  13. package/src/cli.test.ts +1307 -0
  14. package/src/cli.ts +2382 -0
  15. package/src/config-compat.test.ts +99 -0
  16. package/src/config-compat.ts +79 -0
  17. package/src/config.test.ts +57 -0
  18. package/src/config.ts +598 -0
  19. package/src/create.ts +158 -0
  20. package/src/drive.ts +73 -0
  21. package/src/google-api-errors.ts +21 -0
  22. package/src/meet.ts +1027 -0
  23. package/src/node-host.ts +524 -0
  24. package/src/oauth.test.ts +164 -0
  25. package/src/oauth.ts +247 -0
  26. package/src/realtime-node.ts +771 -0
  27. package/src/realtime.ts +1355 -0
  28. package/src/runtime.ts +1009 -0
  29. package/src/setup.ts +277 -0
  30. package/src/test-support/plugin-harness.ts +233 -0
  31. package/src/transports/chrome-audio-device.ts +6 -0
  32. package/src/transports/chrome-browser-proxy.test.ts +67 -0
  33. package/src/transports/chrome-browser-proxy.ts +206 -0
  34. package/src/transports/chrome-create.ts +365 -0
  35. package/src/transports/chrome.test.ts +21 -0
  36. package/src/transports/chrome.ts +1073 -0
  37. package/src/transports/twilio.ts +58 -0
  38. package/src/transports/types.ts +148 -0
  39. package/src/voice-call-gateway.test.ts +153 -0
  40. package/src/voice-call-gateway.ts +242 -0
  41. package/tsconfig.json +16 -0
package/src/cli.ts ADDED
@@ -0,0 +1,2382 @@
1
+ // Google Meet plugin module implements cli behavior.
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { format } from "node:util";
6
+ import type { Command } from "commander";
7
+ import { formatErrorMessage } from "actagent/plugin-sdk/error-runtime";
8
+ import { callGatewayFromCli } from "actagent/plugin-sdk/gateway-runtime";
9
+ import {
10
+ clampTimerTimeoutMs,
11
+ parseStrictPositiveInteger,
12
+ } from "actagent/plugin-sdk/number-runtime";
13
+ import {
14
+ buildGoogleMeetCalendarDayWindow,
15
+ findGoogleMeetCalendarEvent,
16
+ listGoogleMeetCalendarEvents,
17
+ type GoogleMeetCalendarLookupResult,
18
+ } from "./calendar.js";
19
+ import {
20
+ resolveGoogleMeetGatewayOperationTimeoutMs,
21
+ type GoogleMeetConfig,
22
+ type GoogleMeetModeInput,
23
+ type GoogleMeetTransport,
24
+ } from "./config.js";
25
+ import { hasCreateSpaceConfigInput, resolveCreateSpaceConfig } from "./create.js";
26
+ import {
27
+ buildGoogleMeetPreflightReport,
28
+ createGoogleMeetSpace,
29
+ endGoogleMeetActiveConference,
30
+ fetchGoogleMeetArtifacts,
31
+ fetchGoogleMeetAttendance,
32
+ fetchLatestGoogleMeetConferenceRecord,
33
+ fetchGoogleMeetSpace,
34
+ type GoogleMeetArtifactsResult,
35
+ type GoogleMeetAttendanceResult,
36
+ type GoogleMeetLatestConferenceRecordResult,
37
+ } from "./meet.js";
38
+ import {
39
+ buildGoogleMeetAuthUrl,
40
+ createGoogleMeetOAuthState,
41
+ createGoogleMeetPkce,
42
+ exchangeGoogleMeetAuthCode,
43
+ resolveGoogleMeetAccessToken,
44
+ waitForGoogleMeetAuthCode,
45
+ } from "./oauth.js";
46
+ import type { GoogleMeetRuntime } from "./runtime.js";
47
+
48
+ type JoinOptions = {
49
+ transport?: GoogleMeetTransport;
50
+ mode?: GoogleMeetModeInput;
51
+ message?: string;
52
+ timeoutMs?: string;
53
+ dialInNumber?: string;
54
+ pin?: string;
55
+ dtmfSequence?: string;
56
+ };
57
+
58
+ type OAuthLoginOptions = {
59
+ clientId?: string;
60
+ clientSecret?: string;
61
+ manual?: boolean;
62
+ json?: boolean;
63
+ timeoutSec?: string;
64
+ };
65
+
66
+ export const testing = {
67
+ parsePositiveNumber,
68
+ resolveGoogleMeetGatewayOperationTimeoutMs,
69
+ resolveGoogleMeetGatewayTimeoutMs,
70
+ resolveGoogleMeetOAuthCallbackTimeoutMs,
71
+ };
72
+
73
+ type ResolveSpaceOptions = {
74
+ meeting?: string;
75
+ today?: boolean;
76
+ event?: string;
77
+ calendar?: string;
78
+ accessToken?: string;
79
+ refreshToken?: string;
80
+ clientId?: string;
81
+ clientSecret?: string;
82
+ expiresAt?: string;
83
+ json?: boolean;
84
+ };
85
+
86
+ type MeetArtifactOptions = ResolveSpaceOptions & {
87
+ conferenceRecord?: string;
88
+ pageSize?: string;
89
+ transcriptEntries?: boolean;
90
+ allConferenceRecords?: boolean;
91
+ includeDocBodies?: boolean;
92
+ mergeDuplicates?: boolean;
93
+ lateAfterMinutes?: string;
94
+ earlyBeforeMinutes?: string;
95
+ zip?: boolean;
96
+ dryRun?: boolean;
97
+ format?: "summary" | "markdown" | "csv";
98
+ output?: string;
99
+ };
100
+
101
+ export type GoogleMeetExportRequest = {
102
+ meeting?: string;
103
+ conferenceRecord?: string;
104
+ calendarEventId?: string;
105
+ calendarEventSummary?: string;
106
+ calendarId?: string;
107
+ pageSize?: number;
108
+ includeTranscriptEntries?: boolean;
109
+ includeDocumentBodies?: boolean;
110
+ allConferenceRecords?: boolean;
111
+ mergeDuplicateParticipants?: boolean;
112
+ lateAfterMinutes?: number;
113
+ earlyBeforeMinutes?: number;
114
+ };
115
+
116
+ export type GoogleMeetExportWarning = {
117
+ type:
118
+ | "smart_notes"
119
+ | "transcript_entries"
120
+ | "transcript_document_body"
121
+ | "smart_note_document_body";
122
+ conferenceRecord: string;
123
+ resource?: string;
124
+ message: string;
125
+ };
126
+
127
+ export type GoogleMeetExportManifest = {
128
+ generatedAt: string;
129
+ request?: GoogleMeetExportRequest;
130
+ tokenSource?: "cached-access-token" | "refresh-token";
131
+ calendarEvent?: GoogleMeetCalendarLookupResult;
132
+ inputs: {
133
+ artifacts?: string;
134
+ attendance?: string;
135
+ };
136
+ counts: {
137
+ conferenceRecords: number;
138
+ artifacts: number;
139
+ attendanceRows: number;
140
+ recordings: number;
141
+ transcripts: number;
142
+ transcriptEntries: number;
143
+ smartNotes: number;
144
+ warnings: number;
145
+ };
146
+ conferenceRecords: string[];
147
+ files: string[];
148
+ zipFile?: string;
149
+ warnings: GoogleMeetExportWarning[];
150
+ };
151
+
152
+ type SetupOptions = {
153
+ json?: boolean;
154
+ mode?: GoogleMeetModeInput;
155
+ transport?: GoogleMeetTransport;
156
+ };
157
+
158
+ type GoogleMeetGatewayMethod =
159
+ | "googlemeet.create"
160
+ | "googlemeet.join"
161
+ | "googlemeet.leave"
162
+ | "googlemeet.speak"
163
+ | "googlemeet.status"
164
+ | "googlemeet.testListen"
165
+ | "googlemeet.testSpeech";
166
+
167
+ type GoogleMeetGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown };
168
+
169
+ const GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS = 5000;
170
+ const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
171
+
172
+ type DoctorOptions = {
173
+ json?: boolean;
174
+ oauth?: boolean;
175
+ meeting?: string;
176
+ createSpace?: boolean;
177
+ accessToken?: string;
178
+ refreshToken?: string;
179
+ clientId?: string;
180
+ clientSecret?: string;
181
+ expiresAt?: string;
182
+ };
183
+
184
+ type JsonOptions = {
185
+ json?: boolean;
186
+ };
187
+
188
+ type RecoverTabOptions = JsonOptions & {
189
+ transport?: GoogleMeetTransport;
190
+ };
191
+
192
+ type CreateOptions = {
193
+ accessToken?: string;
194
+ refreshToken?: string;
195
+ clientId?: string;
196
+ clientSecret?: string;
197
+ expiresAt?: string;
198
+ accessType?: string;
199
+ entryPointAccess?: string;
200
+ join?: boolean;
201
+ transport?: GoogleMeetTransport;
202
+ mode?: GoogleMeetModeInput;
203
+ message?: string;
204
+ dialInNumber?: string;
205
+ pin?: string;
206
+ dtmfSequence?: string;
207
+ json?: boolean;
208
+ };
209
+
210
+ function writeStdoutJson(value: unknown): void {
211
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
212
+ }
213
+
214
+ function isGatewayUnavailableForLocalFallback(
215
+ err: unknown,
216
+ method: GoogleMeetGatewayMethod,
217
+ ): boolean {
218
+ const message = formatErrorMessage(err);
219
+ return (
220
+ message.includes("ECONNREFUSED") ||
221
+ message.includes("ECONNRESET") ||
222
+ message.includes("EHOSTUNREACH") ||
223
+ message.includes("ENOTFOUND") ||
224
+ message.includes("gateway not connected") ||
225
+ message.includes(`unknown method: ${method}`)
226
+ );
227
+ }
228
+
229
+ function writeStdoutLine(...values: unknown[]): void {
230
+ process.stdout.write(`${format(...values)}\n`);
231
+ }
232
+
233
+ async function writeCliOutput(options: { output?: string }, text: string): Promise<void> {
234
+ if (options.output?.trim()) {
235
+ await writeFile(options.output, text.endsWith("\n") ? text : `${text}\n`, "utf8");
236
+ writeStdoutLine("wrote: %s", options.output);
237
+ return;
238
+ }
239
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
240
+ }
241
+
242
+ async function promptInput(message: string): Promise<string> {
243
+ const rl = createInterface({
244
+ input: process.stdin,
245
+ output: process.stderr,
246
+ });
247
+ try {
248
+ return await rl.question(message);
249
+ } finally {
250
+ rl.close();
251
+ }
252
+ }
253
+
254
+ function parseOptionalNumber(value: string | undefined): number | undefined {
255
+ if (!value?.trim()) {
256
+ return undefined;
257
+ }
258
+ const trimmed = value.trim();
259
+ const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN;
260
+ if (!Number.isFinite(parsed)) {
261
+ throw new Error(`Expected a numeric value, received ${value}`);
262
+ }
263
+ return parsed;
264
+ }
265
+
266
+ function writeSetupStatus(status: Awaited<ReturnType<GoogleMeetRuntime["setupStatus"]>>): void {
267
+ writeStdoutLine("Google Meet setup: %s", status.ok ? "OK" : "needs attention");
268
+ for (const check of status.checks) {
269
+ writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message);
270
+ }
271
+ }
272
+
273
+ function formatBoolean(value: boolean | undefined): string {
274
+ return typeof value === "boolean" ? (value ? "yes" : "no") : "unknown";
275
+ }
276
+
277
+ function formatOptional(value: unknown): string {
278
+ return typeof value === "string" && value.trim() ? value : "n/a";
279
+ }
280
+
281
+ function parsePositiveNumber(value: string | undefined, label: string): number | undefined {
282
+ if (value === undefined) {
283
+ return undefined;
284
+ }
285
+ const trimmed = value.trim();
286
+ const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN;
287
+ if (!Number.isFinite(parsed) || parsed <= 0) {
288
+ throw new Error(`${label} must be a positive number`);
289
+ }
290
+ return parsed;
291
+ }
292
+
293
+ function resolveGoogleMeetGatewayTimeoutMs(timeoutMs: unknown): number {
294
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
295
+ ? (clampTimerTimeoutMs(Math.ceil(timeoutMs)) ?? 1)
296
+ : GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS;
297
+ }
298
+
299
+ function resolveGoogleMeetOAuthCallbackTimeoutMs(timeoutSec: string | undefined): number {
300
+ return (
301
+ clampTimerTimeoutMs((parsePositiveNumber(timeoutSec, "timeout-sec") ?? 300) * 1000) ?? 300_000
302
+ );
303
+ }
304
+
305
+ function parsePositiveIntegerOption(value: string | undefined, label: string): number | undefined {
306
+ if (value === undefined) {
307
+ return undefined;
308
+ }
309
+ const parsed = parseStrictPositiveInteger(value);
310
+ if (parsed === undefined) {
311
+ throw new Error(`${label} must be a positive integer`);
312
+ }
313
+ return parsed;
314
+ }
315
+
316
+ async function callGoogleMeetGateway(params: {
317
+ callGateway: typeof callGatewayFromCli;
318
+ method: GoogleMeetGatewayMethod;
319
+ payload?: Record<string, unknown>;
320
+ timeoutMs?: number;
321
+ }): Promise<GoogleMeetGatewayCallResult> {
322
+ try {
323
+ const timeoutMs = resolveGoogleMeetGatewayTimeoutMs(params.timeoutMs);
324
+ return {
325
+ ok: true,
326
+ payload: await params.callGateway(
327
+ params.method,
328
+ { json: true, timeout: String(timeoutMs) },
329
+ params.payload,
330
+ { progress: false },
331
+ ),
332
+ };
333
+ } catch (err) {
334
+ if (isGatewayUnavailableForLocalFallback(err, params.method)) {
335
+ return { ok: false, error: err };
336
+ }
337
+ throw err;
338
+ }
339
+ }
340
+
341
+ function formatDuration(value: number | undefined): string {
342
+ if (value === undefined) {
343
+ return "n/a";
344
+ }
345
+ const totalSeconds = Math.round(value / 1000);
346
+ const hours = Math.floor(totalSeconds / 3600);
347
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
348
+ const seconds = totalSeconds % 60;
349
+ return hours > 0
350
+ ? `${hours}h ${minutes.toString().padStart(2, "0")}m`
351
+ : `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
352
+ }
353
+
354
+ function writeDoctorStatus(status: Awaited<ReturnType<GoogleMeetRuntime["status"]>>): void {
355
+ if (!status.found) {
356
+ writeStdoutLine("Google Meet session: not found");
357
+ return;
358
+ }
359
+ const sessions = status.session ? [status.session] : (status.sessions ?? []);
360
+ if (sessions.length === 0) {
361
+ writeStdoutLine("Google Meet sessions: none");
362
+ return;
363
+ }
364
+ writeStdoutLine("Google Meet sessions: %d", sessions.length);
365
+ for (const session of sessions) {
366
+ const health = session.chrome?.health;
367
+ writeStdoutLine("");
368
+ writeStdoutLine("session: %s", session.id);
369
+ writeStdoutLine("url: %s", session.url);
370
+ writeStdoutLine("state: %s", session.state);
371
+ writeStdoutLine("transport: %s", session.transport);
372
+ writeStdoutLine("mode: %s", session.mode);
373
+ if (session.twilio) {
374
+ writeStdoutLine("twilio dial-in: %s", session.twilio.dialInNumber);
375
+ writeStdoutLine("voice call id: %s", formatOptional(session.twilio.voiceCallId));
376
+ writeStdoutLine("dtmf sent: %s", formatBoolean(session.twilio.dtmfSent));
377
+ writeStdoutLine("intro sent: %s", formatBoolean(session.twilio.introSent));
378
+ }
379
+ if (!session.chrome) {
380
+ continue;
381
+ }
382
+ writeStdoutLine("node: %s", session.chrome?.nodeId ?? "local/none");
383
+ writeStdoutLine("audio bridge: %s", session.chrome?.audioBridge?.type ?? "none");
384
+ const bridgeProvider =
385
+ session.chrome?.audioBridge?.provider ??
386
+ session.realtime.transcriptionProvider ??
387
+ session.realtime.provider ??
388
+ "n/a";
389
+ writeStdoutLine(
390
+ session.mode === "agent" ? "transcription provider: %s" : "provider: %s",
391
+ bridgeProvider,
392
+ );
393
+ if (session.realtime.enabled) {
394
+ writeStdoutLine("talk-back mode: %s", session.realtime.strategy ?? session.mode);
395
+ }
396
+ writeStdoutLine("in call: %s", formatBoolean(health?.inCall));
397
+ writeStdoutLine("lobby waiting: %s", formatBoolean(health?.lobbyWaiting));
398
+ writeStdoutLine("captioning: %s", formatBoolean(health?.captioning));
399
+ writeStdoutLine("transcript lines: %s", health?.transcriptLines ?? 0);
400
+ writeStdoutLine("last caption: %s", formatOptional(health?.lastCaptionAt));
401
+ writeStdoutLine("manual action: %s", formatBoolean(health?.manualActionRequired));
402
+ if (health?.manualActionRequired) {
403
+ writeStdoutLine("manual reason: %s", formatOptional(health.manualActionReason));
404
+ writeStdoutLine("manual message: %s", formatOptional(health.manualActionMessage));
405
+ }
406
+ writeStdoutLine("speech ready: %s", formatBoolean(health?.speechReady));
407
+ if (health?.speechReady === false) {
408
+ writeStdoutLine("speech blocked reason: %s", formatOptional(health.speechBlockedReason));
409
+ writeStdoutLine("speech blocked message: %s", formatOptional(health.speechBlockedMessage));
410
+ }
411
+ writeStdoutLine("provider connected: %s", formatBoolean(health?.providerConnected));
412
+ writeStdoutLine("realtime ready: %s", formatBoolean(health?.realtimeReady));
413
+ writeStdoutLine("audio input active: %s", formatBoolean(health?.audioInputActive));
414
+ writeStdoutLine("audio output active: %s", formatBoolean(health?.audioOutputActive));
415
+ writeStdoutLine("meet output routed: %s", formatBoolean(health?.audioOutputRouted));
416
+ if (health?.audioOutputDeviceLabel || health?.audioOutputRouteError) {
417
+ writeStdoutLine("meet output device: %s", formatOptional(health.audioOutputDeviceLabel));
418
+ writeStdoutLine("meet output route error: %s", formatOptional(health.audioOutputRouteError));
419
+ }
420
+ writeStdoutLine(
421
+ "last input: %s (%s bytes)",
422
+ formatOptional(health?.lastInputAt),
423
+ health?.lastInputBytes ?? 0,
424
+ );
425
+ writeStdoutLine(
426
+ "last output: %s (%s bytes)",
427
+ formatOptional(health?.lastOutputAt),
428
+ health?.lastOutputBytes ?? 0,
429
+ );
430
+ writeStdoutLine("bridge closed: %s", formatBoolean(health?.bridgeClosed));
431
+ writeStdoutLine("browser url: %s", formatOptional(health?.browserUrl));
432
+ if (health?.lastCaptionText) {
433
+ const speaker = health.lastCaptionSpeaker ? `${health.lastCaptionSpeaker}: ` : "";
434
+ writeStdoutLine("last caption text: %s%s", speaker, health.lastCaptionText);
435
+ }
436
+ writeStdoutLine("realtime transcript lines: %s", health?.realtimeTranscriptLines ?? 0);
437
+ if (health?.lastRealtimeTranscriptText) {
438
+ const role = health.lastRealtimeTranscriptRole
439
+ ? `${health.lastRealtimeTranscriptRole}: `
440
+ : "";
441
+ writeStdoutLine("last realtime transcript: %s%s", role, health.lastRealtimeTranscriptText);
442
+ }
443
+ if (health?.lastRealtimeEventType) {
444
+ const detail = health.lastRealtimeEventDetail ? ` ${health.lastRealtimeEventDetail}` : "";
445
+ writeStdoutLine("last realtime event: %s%s", health.lastRealtimeEventType, detail);
446
+ }
447
+ }
448
+ }
449
+
450
+ type OAuthDoctorCheck = {
451
+ id: string;
452
+ ok: boolean;
453
+ message: string;
454
+ };
455
+
456
+ type OAuthDoctorReport = {
457
+ ok: boolean;
458
+ configured: boolean;
459
+ tokenSource?: "cached-access-token" | "refresh-token";
460
+ expiresAt?: number;
461
+ scope?: string;
462
+ meetingUri?: string;
463
+ createdSpace?: string;
464
+ checks: OAuthDoctorCheck[];
465
+ };
466
+
467
+ function sanitizeOAuthErrorMessage(error: unknown): string {
468
+ const message = error instanceof Error ? error.message : String(error);
469
+ return message
470
+ .replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
471
+ .replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]")
472
+ .replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]");
473
+ }
474
+
475
+ async function buildOAuthDoctorReport(
476
+ config: GoogleMeetConfig,
477
+ options: DoctorOptions,
478
+ ): Promise<OAuthDoctorReport> {
479
+ const clientId = options.clientId?.trim() || config.oauth.clientId;
480
+ const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret;
481
+ const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken;
482
+ const accessToken = options.accessToken?.trim() || config.oauth.accessToken;
483
+ const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt;
484
+ const checks: OAuthDoctorCheck[] = [];
485
+
486
+ const hasRefreshConfig = Boolean(clientId && refreshToken);
487
+ const hasAccessConfig = Boolean(accessToken);
488
+ if (!hasRefreshConfig && !hasAccessConfig) {
489
+ checks.push({
490
+ id: "oauth-config",
491
+ ok: false,
492
+ message:
493
+ "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.",
494
+ });
495
+ return { ok: false, configured: false, checks };
496
+ }
497
+
498
+ checks.push({
499
+ id: "oauth-config",
500
+ ok: true,
501
+ message: hasRefreshConfig
502
+ ? "Google Meet OAuth refresh credentials are configured"
503
+ : "Google Meet cached access token is configured",
504
+ });
505
+
506
+ let token: Awaited<ReturnType<typeof resolveGoogleMeetAccessToken>>;
507
+ try {
508
+ token = await resolveGoogleMeetAccessToken({
509
+ clientId,
510
+ clientSecret,
511
+ refreshToken,
512
+ accessToken,
513
+ expiresAt,
514
+ });
515
+ checks.push({
516
+ id: "oauth-token",
517
+ ok: true,
518
+ message: token.refreshed
519
+ ? "Refresh token minted an access token"
520
+ : "Cached access token is still valid",
521
+ });
522
+ } catch (error) {
523
+ checks.push({
524
+ id: "oauth-token",
525
+ ok: false,
526
+ message: sanitizeOAuthErrorMessage(error),
527
+ });
528
+ return { ok: false, configured: true, checks };
529
+ }
530
+
531
+ const report: OAuthDoctorReport = {
532
+ ok: true,
533
+ configured: true,
534
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
535
+ expiresAt: token.expiresAt,
536
+ checks,
537
+ };
538
+
539
+ const meeting = options.meeting?.trim();
540
+ if (meeting) {
541
+ try {
542
+ const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting });
543
+ checks.push({
544
+ id: "meet-spaces-get",
545
+ ok: true,
546
+ message: `Resolved ${space.name}`,
547
+ });
548
+ report.meetingUri = space.meetingUri;
549
+ } catch (error) {
550
+ checks.push({
551
+ id: "meet-spaces-get",
552
+ ok: false,
553
+ message: sanitizeOAuthErrorMessage(error),
554
+ });
555
+ }
556
+ }
557
+
558
+ if (options.createSpace) {
559
+ try {
560
+ const created = await createGoogleMeetSpace({ accessToken: token.accessToken });
561
+ checks.push({
562
+ id: "meet-spaces-create",
563
+ ok: true,
564
+ message: `Created ${created.space.name}`,
565
+ });
566
+ report.createdSpace = created.space.name;
567
+ report.meetingUri = created.meetingUri;
568
+ } catch (error) {
569
+ checks.push({
570
+ id: "meet-spaces-create",
571
+ ok: false,
572
+ message: sanitizeOAuthErrorMessage(error),
573
+ });
574
+ }
575
+ }
576
+
577
+ report.ok = checks.every((check) => check.ok);
578
+ return report;
579
+ }
580
+
581
+ function writeOAuthDoctorReport(report: OAuthDoctorReport): void {
582
+ writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention");
583
+ writeStdoutLine("configured: %s", report.configured ? "yes" : "no");
584
+ if (report.tokenSource) {
585
+ writeStdoutLine("token source: %s", report.tokenSource);
586
+ }
587
+ if (report.meetingUri) {
588
+ writeStdoutLine("meeting uri: %s", report.meetingUri);
589
+ }
590
+ for (const check of report.checks) {
591
+ writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message);
592
+ }
593
+ }
594
+
595
+ function writeRecoverCurrentTabResult(
596
+ result: Awaited<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
597
+ ): void {
598
+ writeStdoutLine("Google Meet current tab: %s", result.found ? "found" : "not found");
599
+ writeStdoutLine("transport: %s", result.transport);
600
+ writeStdoutLine("node: %s", result.nodeId ?? "local/none");
601
+ if (result.targetId) {
602
+ writeStdoutLine("target: %s", result.targetId);
603
+ }
604
+ if (result.tab?.url) {
605
+ writeStdoutLine("tab url: %s", result.tab.url);
606
+ }
607
+ writeStdoutLine("message: %s", result.message);
608
+ if (result.browser) {
609
+ writeDoctorStatus({
610
+ found: true,
611
+ session: {
612
+ id: "current-tab",
613
+ url: result.browser.browserUrl ?? result.tab?.url ?? "unknown",
614
+ transport: result.transport,
615
+ mode: "transcribe",
616
+ state: "active",
617
+ createdAt: "",
618
+ updatedAt: "",
619
+ participantIdentity:
620
+ result.transport === "chrome-node"
621
+ ? "signed-in Google Chrome profile on a paired node"
622
+ : "signed-in Google Chrome profile",
623
+ realtime: { enabled: false, toolPolicy: "safe-read-only" },
624
+ chrome: {
625
+ audioBackend: "blackhole-2ch",
626
+ launched: true,
627
+ nodeId: result.nodeId,
628
+ health: result.browser,
629
+ },
630
+ notes: [],
631
+ },
632
+ });
633
+ }
634
+ }
635
+
636
+ function resolveMeetingInput(config: GoogleMeetConfig, value?: string): string {
637
+ const meeting = value?.trim() || config.defaults.meeting;
638
+ if (!meeting) {
639
+ throw new Error(
640
+ "Meeting input is required. Pass a URL/meeting code or configure defaults.meeting.",
641
+ );
642
+ }
643
+ return meeting;
644
+ }
645
+
646
+ function resolveOAuthTokenOptions(
647
+ config: GoogleMeetConfig,
648
+ options: ResolveSpaceOptions,
649
+ ): {
650
+ clientId?: string;
651
+ clientSecret?: string;
652
+ refreshToken?: string;
653
+ accessToken?: string;
654
+ expiresAt?: number;
655
+ } {
656
+ return {
657
+ clientId: options.clientId?.trim() || config.oauth.clientId,
658
+ clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret,
659
+ refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken,
660
+ accessToken: options.accessToken?.trim() || config.oauth.accessToken,
661
+ expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt,
662
+ };
663
+ }
664
+
665
+ function resolveTokenOptions(
666
+ config: GoogleMeetConfig,
667
+ options: ResolveSpaceOptions,
668
+ ): {
669
+ meeting: string;
670
+ clientId?: string;
671
+ clientSecret?: string;
672
+ refreshToken?: string;
673
+ accessToken?: string;
674
+ expiresAt?: number;
675
+ } {
676
+ return {
677
+ meeting: resolveMeetingInput(config, options.meeting),
678
+ ...resolveOAuthTokenOptions(config, options),
679
+ };
680
+ }
681
+
682
+ function hasCalendarLookupOptions(options: ResolveSpaceOptions): boolean {
683
+ return Boolean(options.today || options.event?.trim());
684
+ }
685
+
686
+ async function resolveCalendarMeetingInput(params: {
687
+ accessToken: string;
688
+ options: ResolveSpaceOptions;
689
+ }): Promise<{ meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult }> {
690
+ if (!hasCalendarLookupOptions(params.options)) {
691
+ return {};
692
+ }
693
+ const window = params.options.today ? buildGoogleMeetCalendarDayWindow() : {};
694
+ const calendarEvent = await findGoogleMeetCalendarEvent({
695
+ accessToken: params.accessToken,
696
+ calendarId: params.options.calendar,
697
+ eventQuery: params.options.event,
698
+ ...window,
699
+ });
700
+ return { meeting: calendarEvent.meetingUri, calendarEvent };
701
+ }
702
+
703
+ async function resolveMeetingForToken(params: {
704
+ config: GoogleMeetConfig;
705
+ options: ResolveSpaceOptions;
706
+ accessToken: string;
707
+ configuredMeeting?: string;
708
+ }): Promise<{ meeting: string; calendarEvent?: GoogleMeetCalendarLookupResult }> {
709
+ const calendarMeeting = await resolveCalendarMeetingInput({
710
+ accessToken: params.accessToken,
711
+ options: params.options,
712
+ });
713
+ const meeting =
714
+ calendarMeeting.meeting ?? params.configuredMeeting ?? params.config.defaults.meeting;
715
+ if (!meeting) {
716
+ throw new Error(
717
+ "Meeting input is required. Pass --meeting, --today, --event, or configure defaults.meeting.",
718
+ );
719
+ }
720
+ return calendarMeeting.calendarEvent
721
+ ? { meeting, calendarEvent: calendarMeeting.calendarEvent }
722
+ : { meeting };
723
+ }
724
+
725
+ function resolveCreateTokenOptions(
726
+ config: GoogleMeetConfig,
727
+ options: CreateOptions,
728
+ ): {
729
+ clientId?: string;
730
+ clientSecret?: string;
731
+ refreshToken?: string;
732
+ accessToken?: string;
733
+ expiresAt?: number;
734
+ } {
735
+ return {
736
+ clientId: options.clientId?.trim() || config.oauth.clientId,
737
+ clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret,
738
+ refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken,
739
+ accessToken: options.accessToken?.trim() || config.oauth.accessToken,
740
+ expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt,
741
+ };
742
+ }
743
+
744
+ function resolveArtifactTokenOptions(
745
+ config: GoogleMeetConfig,
746
+ options: MeetArtifactOptions,
747
+ ): {
748
+ meeting?: string;
749
+ conferenceRecord?: string;
750
+ clientId?: string;
751
+ clientSecret?: string;
752
+ refreshToken?: string;
753
+ accessToken?: string;
754
+ expiresAt?: number;
755
+ pageSize?: number;
756
+ includeTranscriptEntries?: boolean;
757
+ allConferenceRecords?: boolean;
758
+ includeDocumentBodies?: boolean;
759
+ mergeDuplicateParticipants?: boolean;
760
+ lateAfterMinutes?: number;
761
+ earlyBeforeMinutes?: number;
762
+ } {
763
+ const meeting = options.meeting?.trim() || config.defaults.meeting;
764
+ const conferenceRecord = options.conferenceRecord?.trim();
765
+ if (!meeting && !conferenceRecord && !hasCalendarLookupOptions(options)) {
766
+ throw new Error(
767
+ "Meeting input or conference record is required. Pass --meeting, --today, --event, --conference-record, or configure defaults.meeting.",
768
+ );
769
+ }
770
+ return {
771
+ meeting,
772
+ conferenceRecord,
773
+ clientId: options.clientId?.trim() || config.oauth.clientId,
774
+ clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret,
775
+ refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken,
776
+ accessToken: options.accessToken?.trim() || config.oauth.accessToken,
777
+ expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt,
778
+ pageSize: parsePositiveIntegerOption(options.pageSize, "page-size"),
779
+ includeTranscriptEntries: options.transcriptEntries !== false,
780
+ allConferenceRecords: Boolean(options.allConferenceRecords),
781
+ includeDocumentBodies: Boolean(options.includeDocBodies),
782
+ mergeDuplicateParticipants: options.mergeDuplicates !== false,
783
+ lateAfterMinutes: parseOptionalNumber(options.lateAfterMinutes),
784
+ earlyBeforeMinutes: parseOptionalNumber(options.earlyBeforeMinutes),
785
+ };
786
+ }
787
+
788
+ function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boolean {
789
+ return Boolean(
790
+ options.accessToken?.trim() ||
791
+ options.refreshToken?.trim() ||
792
+ config.oauth.accessToken ||
793
+ config.oauth.refreshToken,
794
+ );
795
+ }
796
+
797
+ function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void {
798
+ if (result.input) {
799
+ writeStdoutLine("input: %s", result.input);
800
+ }
801
+ if (result.space) {
802
+ writeStdoutLine("space: %s", result.space.name);
803
+ }
804
+ writeStdoutLine("conference records: %d", result.conferenceRecords.length);
805
+ for (const entry of result.artifacts) {
806
+ writeStdoutLine("");
807
+ writeStdoutLine("record: %s", entry.conferenceRecord.name);
808
+ writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime));
809
+ writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime));
810
+ writeStdoutLine("participants: %d", entry.participants.length);
811
+ writeStdoutLine("recordings: %d", entry.recordings.length);
812
+ writeStdoutLine("transcripts: %d", entry.transcripts.length);
813
+ writeStdoutLine(
814
+ "transcript entries: %d",
815
+ entry.transcriptEntries.reduce((count, transcript) => count + transcript.entries.length, 0),
816
+ );
817
+ writeStdoutLine("smart notes: %d", entry.smartNotes.length);
818
+ if (entry.smartNotesError) {
819
+ writeStdoutLine("smart notes warning: %s", entry.smartNotesError);
820
+ }
821
+ for (const recording of entry.recordings) {
822
+ writeStdoutLine("- recording: %s", recording.name);
823
+ }
824
+ for (const transcript of entry.transcripts) {
825
+ writeStdoutLine("- transcript: %s", transcript.name);
826
+ if (transcript.documentTextError) {
827
+ writeStdoutLine("- transcript document body warning: %s", transcript.documentTextError);
828
+ }
829
+ }
830
+ for (const transcriptEntries of entry.transcriptEntries) {
831
+ if (transcriptEntries.entriesError) {
832
+ writeStdoutLine(
833
+ "- transcript entries warning: %s: %s",
834
+ transcriptEntries.transcript,
835
+ transcriptEntries.entriesError,
836
+ );
837
+ }
838
+ }
839
+ for (const smartNote of entry.smartNotes) {
840
+ writeStdoutLine("- smart note: %s", smartNote.name);
841
+ if (smartNote.documentTextError) {
842
+ writeStdoutLine("- smart note document body warning: %s", smartNote.documentTextError);
843
+ }
844
+ }
845
+ }
846
+ }
847
+
848
+ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void {
849
+ if (result.input) {
850
+ writeStdoutLine("input: %s", result.input);
851
+ }
852
+ if (result.space) {
853
+ writeStdoutLine("space: %s", result.space.name);
854
+ }
855
+ writeStdoutLine("conference records: %d", result.conferenceRecords.length);
856
+ writeStdoutLine("attendance rows: %d", result.attendance.length);
857
+ for (const row of result.attendance) {
858
+ const identity = row.displayName || row.user || row.participant;
859
+ writeStdoutLine("");
860
+ writeStdoutLine("participant: %s", identity);
861
+ writeStdoutLine("record: %s", row.conferenceRecord);
862
+ writeStdoutLine("resource: %s", row.participant);
863
+ writeStdoutLine("participants merged: %d", row.participants?.length ?? 1);
864
+ writeStdoutLine("first joined: %s", formatOptional(row.firstJoinTime ?? row.earliestStartTime));
865
+ writeStdoutLine("last left: %s", formatOptional(row.lastLeaveTime ?? row.latestEndTime));
866
+ writeStdoutLine("duration: %s", formatDuration(row.durationMs));
867
+ writeStdoutLine("late: %s", row.late ? formatDuration(row.lateByMs) : "no");
868
+ writeStdoutLine("early leave: %s", row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no");
869
+ writeStdoutLine("sessions: %d", row.sessions.length);
870
+ for (const session of row.sessions) {
871
+ writeStdoutLine(
872
+ "- %s: %s -> %s",
873
+ session.name,
874
+ formatOptional(session.startTime),
875
+ formatOptional(session.endTime),
876
+ );
877
+ }
878
+ }
879
+ }
880
+
881
+ function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRecordResult): void {
882
+ writeStdoutLine("input: %s", result.input);
883
+ writeStdoutLine("space: %s", result.space.name);
884
+ if (!result.conferenceRecord) {
885
+ writeStdoutLine("conference record: none");
886
+ return;
887
+ }
888
+ writeStdoutLine("conference record: %s", result.conferenceRecord.name);
889
+ writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime));
890
+ writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime));
891
+ }
892
+
893
+ function writeCalendarEventsSummary(
894
+ result: Awaited<ReturnType<typeof listGoogleMeetCalendarEvents>>,
895
+ ): void {
896
+ writeStdoutLine("calendar: %s", result.calendarId);
897
+ writeStdoutLine("meet events: %d", result.events.length);
898
+ for (const entry of result.events) {
899
+ writeStdoutLine("");
900
+ writeStdoutLine("%s%s", entry.selected ? "* " : "- ", entry.event.summary ?? "untitled");
901
+ writeStdoutLine("meeting uri: %s", entry.meetingUri);
902
+ writeStdoutLine(
903
+ "starts: %s",
904
+ formatOptional(entry.event.start?.dateTime ?? entry.event.start?.date),
905
+ );
906
+ writeStdoutLine("ends: %s", formatOptional(entry.event.end?.dateTime ?? entry.event.end?.date));
907
+ }
908
+ }
909
+
910
+ function pushMarkdownLine(lines: string[], text = ""): void {
911
+ lines.push(text);
912
+ }
913
+
914
+ function formatMarkdownOptional(value: unknown): string {
915
+ return typeof value === "string" && value.trim() ? value : "n/a";
916
+ }
917
+
918
+ function formatMarkdownIdentity(row: GoogleMeetAttendanceResult["attendance"][number]): string {
919
+ return row.displayName || row.user || row.participant;
920
+ }
921
+
922
+ function participantDisplayName(
923
+ entry: GoogleMeetArtifactsResult["artifacts"][number],
924
+ name: string,
925
+ ): string {
926
+ const participant = entry.participants.find((candidate) => candidate.name === name);
927
+ if (!participant) {
928
+ return name;
929
+ }
930
+ return (
931
+ participant.signedinUser?.displayName ??
932
+ participant.anonymousUser?.displayName ??
933
+ participant.phoneUser?.displayName ??
934
+ participant.signedinUser?.user ??
935
+ name
936
+ );
937
+ }
938
+
939
+ function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
940
+ const lines: string[] = ["# Google Meet Artifacts"];
941
+ if (result.input) {
942
+ pushMarkdownLine(lines, `Input: ${result.input}`);
943
+ }
944
+ if (result.space) {
945
+ pushMarkdownLine(lines, `Space: ${result.space.name}`);
946
+ }
947
+ pushMarkdownLine(lines);
948
+ pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`);
949
+ for (const entry of result.artifacts) {
950
+ pushMarkdownLine(lines);
951
+ pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`);
952
+ pushMarkdownLine(lines, `Started: ${formatMarkdownOptional(entry.conferenceRecord.startTime)}`);
953
+ pushMarkdownLine(lines, `Ended: ${formatMarkdownOptional(entry.conferenceRecord.endTime)}`);
954
+ pushMarkdownLine(lines);
955
+ pushMarkdownLine(lines, `Participants: ${entry.participants.length}`);
956
+ pushMarkdownLine(lines, `Recordings: ${entry.recordings.length}`);
957
+ pushMarkdownLine(lines, `Transcripts: ${entry.transcripts.length}`);
958
+ pushMarkdownLine(
959
+ lines,
960
+ `Transcript entries: ${entry.transcriptEntries.reduce(
961
+ (count, transcript) => count + transcript.entries.length,
962
+ 0,
963
+ )}`,
964
+ );
965
+ pushMarkdownLine(lines, `Smart notes: ${entry.smartNotes.length}`);
966
+ const warnings = collectGoogleMeetArtifactWarnings({
967
+ conferenceRecords: [entry.conferenceRecord],
968
+ artifacts: [entry],
969
+ });
970
+ if (warnings.length > 0) {
971
+ pushMarkdownLine(lines);
972
+ pushMarkdownLine(lines, "### Warnings");
973
+ for (const warning of warnings) {
974
+ const resource = warning.resource ? `${warning.resource}: ` : "";
975
+ pushMarkdownLine(lines, `- ${resource}${warning.message}`);
976
+ }
977
+ }
978
+ if (entry.recordings.length > 0) {
979
+ pushMarkdownLine(lines);
980
+ pushMarkdownLine(lines, "### Recordings");
981
+ for (const recording of entry.recordings) {
982
+ pushMarkdownLine(lines, `- ${recording.name}`);
983
+ }
984
+ }
985
+ if (entry.transcripts.length > 0) {
986
+ pushMarkdownLine(lines);
987
+ pushMarkdownLine(lines, "### Transcripts");
988
+ for (const transcript of entry.transcripts) {
989
+ pushMarkdownLine(lines, `- ${transcript.name}`);
990
+ if (transcript.documentTextError) {
991
+ pushMarkdownLine(lines, ` - Document body warning: ${transcript.documentTextError}`);
992
+ } else if (transcript.documentText) {
993
+ pushMarkdownLine(lines, ` - Document body: ${transcript.documentText.length} chars`);
994
+ }
995
+ }
996
+ }
997
+ for (const transcriptEntries of entry.transcriptEntries) {
998
+ pushMarkdownLine(lines);
999
+ pushMarkdownLine(lines, `### Transcript Entries: ${transcriptEntries.transcript}`);
1000
+ if (transcriptEntries.entriesError) {
1001
+ pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`);
1002
+ continue;
1003
+ }
1004
+ if (transcriptEntries.entries.length === 0) {
1005
+ pushMarkdownLine(lines, "_No transcript entries._");
1006
+ continue;
1007
+ }
1008
+ for (const transcriptEntry of transcriptEntries.entries) {
1009
+ const times =
1010
+ transcriptEntry.startTime || transcriptEntry.endTime
1011
+ ? ` (${formatMarkdownOptional(transcriptEntry.startTime)} -> ${formatMarkdownOptional(
1012
+ transcriptEntry.endTime,
1013
+ )})`
1014
+ : "";
1015
+ const speaker = transcriptEntry.participant
1016
+ ? `${participantDisplayName(entry, transcriptEntry.participant)}: `
1017
+ : "";
1018
+ pushMarkdownLine(lines, `- ${speaker}${transcriptEntry.text ?? ""}${times}`);
1019
+ }
1020
+ }
1021
+ if (entry.smartNotes.length > 0) {
1022
+ pushMarkdownLine(lines);
1023
+ pushMarkdownLine(lines, "### Smart Notes");
1024
+ for (const smartNote of entry.smartNotes) {
1025
+ pushMarkdownLine(lines, `- ${smartNote.name}`);
1026
+ if (smartNote.documentTextError) {
1027
+ pushMarkdownLine(lines, ` - Document body warning: ${smartNote.documentTextError}`);
1028
+ } else if (smartNote.documentText) {
1029
+ pushMarkdownLine(lines, ` - Document body: ${smartNote.documentText.length} chars`);
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+ return `${lines.join("\n")}\n`;
1035
+ }
1036
+
1037
+ function renderAttendanceMarkdown(result: GoogleMeetAttendanceResult): string {
1038
+ const lines: string[] = ["# Google Meet Attendance"];
1039
+ if (result.input) {
1040
+ pushMarkdownLine(lines, `Input: ${result.input}`);
1041
+ }
1042
+ if (result.space) {
1043
+ pushMarkdownLine(lines, `Space: ${result.space.name}`);
1044
+ }
1045
+ pushMarkdownLine(lines);
1046
+ pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`);
1047
+ pushMarkdownLine(lines, `Attendance rows: ${result.attendance.length}`);
1048
+ for (const row of result.attendance) {
1049
+ pushMarkdownLine(lines);
1050
+ pushMarkdownLine(lines, `## ${formatMarkdownIdentity(row)}`);
1051
+ pushMarkdownLine(lines, `Record: ${row.conferenceRecord}`);
1052
+ pushMarkdownLine(lines, `Resource: ${row.participant}`);
1053
+ pushMarkdownLine(lines, `Participants merged: ${row.participants?.length ?? 1}`);
1054
+ pushMarkdownLine(
1055
+ lines,
1056
+ `First joined: ${formatMarkdownOptional(row.firstJoinTime ?? row.earliestStartTime)}`,
1057
+ );
1058
+ pushMarkdownLine(
1059
+ lines,
1060
+ `Last left: ${formatMarkdownOptional(row.lastLeaveTime ?? row.latestEndTime)}`,
1061
+ );
1062
+ pushMarkdownLine(lines, `Duration: ${formatDuration(row.durationMs)}`);
1063
+ pushMarkdownLine(lines, `Late: ${row.late ? formatDuration(row.lateByMs) : "no"}`);
1064
+ pushMarkdownLine(
1065
+ lines,
1066
+ `Early leave: ${row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"}`,
1067
+ );
1068
+ pushMarkdownLine(lines, `Sessions: ${row.sessions.length}`);
1069
+ for (const session of row.sessions) {
1070
+ pushMarkdownLine(
1071
+ lines,
1072
+ `- ${session.name}: ${formatMarkdownOptional(session.startTime)} -> ${formatMarkdownOptional(
1073
+ session.endTime,
1074
+ )}`,
1075
+ );
1076
+ }
1077
+ }
1078
+ return `${lines.join("\n")}\n`;
1079
+ }
1080
+
1081
+ function csvCell(value: unknown): string {
1082
+ const text =
1083
+ value === undefined || value === null
1084
+ ? ""
1085
+ : typeof value === "string" || typeof value === "number" || typeof value === "boolean"
1086
+ ? String(value)
1087
+ : JSON.stringify(value);
1088
+ return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
1089
+ }
1090
+
1091
+ function renderAttendanceCsv(result: GoogleMeetAttendanceResult): string {
1092
+ const rows: unknown[][] = [
1093
+ [
1094
+ "conferenceRecord",
1095
+ "displayName",
1096
+ "user",
1097
+ "participants",
1098
+ "firstJoined",
1099
+ "lastLeft",
1100
+ "durationMs",
1101
+ "sessions",
1102
+ "late",
1103
+ "lateByMs",
1104
+ "earlyLeave",
1105
+ "earlyLeaveByMs",
1106
+ ],
1107
+ ];
1108
+ for (const row of result.attendance) {
1109
+ rows.push([
1110
+ row.conferenceRecord,
1111
+ row.displayName ?? "",
1112
+ row.user ?? "",
1113
+ (row.participants ?? [row.participant]).join(";"),
1114
+ row.firstJoinTime ?? row.earliestStartTime ?? "",
1115
+ row.lastLeaveTime ?? row.latestEndTime ?? "",
1116
+ row.durationMs ?? "",
1117
+ row.sessions.length,
1118
+ row.late ?? "",
1119
+ row.lateByMs ?? "",
1120
+ row.earlyLeave ?? "",
1121
+ row.earlyLeaveByMs ?? "",
1122
+ ]);
1123
+ }
1124
+ return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`;
1125
+ }
1126
+
1127
+ function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string {
1128
+ const lines: string[] = ["# Google Meet Transcript"];
1129
+ if (result.input) {
1130
+ pushMarkdownLine(lines, `Input: ${result.input}`);
1131
+ }
1132
+ for (const entry of result.artifacts) {
1133
+ pushMarkdownLine(lines);
1134
+ pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`);
1135
+ if (entry.transcriptEntries.length === 0) {
1136
+ pushMarkdownLine(lines, "_No transcript entries._");
1137
+ continue;
1138
+ }
1139
+ for (const transcriptEntries of entry.transcriptEntries) {
1140
+ pushMarkdownLine(lines);
1141
+ pushMarkdownLine(lines, `### ${transcriptEntries.transcript}`);
1142
+ if (transcriptEntries.entriesError) {
1143
+ pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`);
1144
+ continue;
1145
+ }
1146
+ for (const transcriptEntry of transcriptEntries.entries) {
1147
+ const speaker = transcriptEntry.participant
1148
+ ? participantDisplayName(entry, transcriptEntry.participant)
1149
+ : "unknown";
1150
+ const time = transcriptEntry.startTime ? ` [${transcriptEntry.startTime}]` : "";
1151
+ pushMarkdownLine(lines, `- ${speaker}${time}: ${transcriptEntry.text ?? ""}`);
1152
+ }
1153
+ }
1154
+ const docsTranscripts = entry.transcripts.filter((transcript) => transcript.documentText);
1155
+ if (docsTranscripts.length > 0) {
1156
+ pushMarkdownLine(lines);
1157
+ pushMarkdownLine(lines, "### Transcript Document Bodies");
1158
+ for (const transcript of docsTranscripts) {
1159
+ pushMarkdownLine(lines);
1160
+ pushMarkdownLine(lines, `#### ${transcript.name}`);
1161
+ pushMarkdownLine(lines, transcript.documentText?.trim() || "_Empty document body._");
1162
+ }
1163
+ }
1164
+ const smartNotes = entry.smartNotes.filter((smartNote) => smartNote.documentText);
1165
+ if (smartNotes.length > 0) {
1166
+ pushMarkdownLine(lines);
1167
+ pushMarkdownLine(lines, "### Smart Note Document Bodies");
1168
+ for (const smartNote of smartNotes) {
1169
+ pushMarkdownLine(lines);
1170
+ pushMarkdownLine(lines, `#### ${smartNote.name}`);
1171
+ pushMarkdownLine(lines, smartNote.documentText?.trim() || "_Empty document body._");
1172
+ }
1173
+ }
1174
+ }
1175
+ return `${lines.join("\n")}\n`;
1176
+ }
1177
+
1178
+ function collectGoogleMeetArtifactWarnings(
1179
+ result: GoogleMeetArtifactsResult,
1180
+ ): GoogleMeetExportWarning[] {
1181
+ const warnings: GoogleMeetExportWarning[] = [];
1182
+ for (const entry of result.artifacts) {
1183
+ const conferenceRecord = entry.conferenceRecord.name;
1184
+ if (entry.smartNotesError) {
1185
+ warnings.push({
1186
+ type: "smart_notes",
1187
+ conferenceRecord,
1188
+ message: entry.smartNotesError,
1189
+ });
1190
+ }
1191
+ for (const transcriptEntries of entry.transcriptEntries) {
1192
+ if (transcriptEntries.entriesError) {
1193
+ warnings.push({
1194
+ type: "transcript_entries",
1195
+ conferenceRecord,
1196
+ resource: transcriptEntries.transcript,
1197
+ message: transcriptEntries.entriesError,
1198
+ });
1199
+ }
1200
+ }
1201
+ for (const transcript of entry.transcripts) {
1202
+ if (transcript.documentTextError) {
1203
+ warnings.push({
1204
+ type: "transcript_document_body",
1205
+ conferenceRecord,
1206
+ resource: transcript.name,
1207
+ message: transcript.documentTextError,
1208
+ });
1209
+ }
1210
+ }
1211
+ for (const smartNote of entry.smartNotes) {
1212
+ if (smartNote.documentTextError) {
1213
+ warnings.push({
1214
+ type: "smart_note_document_body",
1215
+ conferenceRecord,
1216
+ resource: smartNote.name,
1217
+ message: smartNote.documentTextError,
1218
+ });
1219
+ }
1220
+ }
1221
+ }
1222
+ return warnings;
1223
+ }
1224
+
1225
+ export function buildGoogleMeetExportManifest(params: {
1226
+ artifacts: GoogleMeetArtifactsResult;
1227
+ attendance: GoogleMeetAttendanceResult;
1228
+ files: string[];
1229
+ request?: GoogleMeetExportRequest;
1230
+ tokenSource?: "cached-access-token" | "refresh-token";
1231
+ calendarEvent?: GoogleMeetCalendarLookupResult;
1232
+ zipFile?: string;
1233
+ }): GoogleMeetExportManifest {
1234
+ const transcriptEntryCount = params.artifacts.artifacts.reduce(
1235
+ (count, entry) =>
1236
+ count +
1237
+ entry.transcriptEntries.reduce(
1238
+ (entryCount, transcript) => entryCount + transcript.entries.length,
1239
+ 0,
1240
+ ),
1241
+ 0,
1242
+ );
1243
+ const warnings = collectGoogleMeetArtifactWarnings(params.artifacts);
1244
+ return {
1245
+ generatedAt: new Date().toISOString(),
1246
+ ...(params.request ? { request: params.request } : {}),
1247
+ ...(params.tokenSource ? { tokenSource: params.tokenSource } : {}),
1248
+ ...(params.calendarEvent ? { calendarEvent: params.calendarEvent } : {}),
1249
+ inputs: {
1250
+ ...(params.artifacts.input ? { artifacts: params.artifacts.input } : {}),
1251
+ ...(params.attendance.input ? { attendance: params.attendance.input } : {}),
1252
+ },
1253
+ counts: {
1254
+ conferenceRecords: params.artifacts.conferenceRecords.length,
1255
+ artifacts: params.artifacts.artifacts.length,
1256
+ attendanceRows: params.attendance.attendance.length,
1257
+ recordings: params.artifacts.artifacts.reduce(
1258
+ (count, entry) => count + entry.recordings.length,
1259
+ 0,
1260
+ ),
1261
+ transcripts: params.artifacts.artifacts.reduce(
1262
+ (count, entry) => count + entry.transcripts.length,
1263
+ 0,
1264
+ ),
1265
+ transcriptEntries: transcriptEntryCount,
1266
+ smartNotes: params.artifacts.artifacts.reduce(
1267
+ (count, entry) => count + entry.smartNotes.length,
1268
+ 0,
1269
+ ),
1270
+ warnings: warnings.length,
1271
+ },
1272
+ conferenceRecords: params.artifacts.conferenceRecords.map((record) => record.name),
1273
+ files: params.files,
1274
+ ...(params.zipFile ? { zipFile: params.zipFile } : {}),
1275
+ warnings,
1276
+ };
1277
+ }
1278
+
1279
+ export function googleMeetExportFileNames(): string[] {
1280
+ return [
1281
+ "summary.md",
1282
+ "attendance.csv",
1283
+ "transcript.md",
1284
+ "artifacts.json",
1285
+ "attendance.json",
1286
+ "manifest.json",
1287
+ ];
1288
+ }
1289
+
1290
+ function defaultExportDirectory(): string {
1291
+ return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`;
1292
+ }
1293
+
1294
+ const CRC32_TABLE = new Uint32Array(
1295
+ Array.from({ length: 256 }, (_, index) => {
1296
+ let value = index;
1297
+ for (let bit = 0; bit < 8; bit += 1) {
1298
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
1299
+ }
1300
+ return value >>> 0;
1301
+ }),
1302
+ );
1303
+
1304
+ function crc32(buffer: Buffer): number {
1305
+ let value = 0xffffffff;
1306
+ for (const byte of buffer) {
1307
+ value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
1308
+ }
1309
+ return (value ^ 0xffffffff) >>> 0;
1310
+ }
1311
+
1312
+ function dosDateTime(date = new Date()): { date: number; time: number } {
1313
+ return {
1314
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
1315
+ date: ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
1316
+ };
1317
+ }
1318
+
1319
+ function buildZipArchive(files: Array<{ name: string; content: string }>): Buffer {
1320
+ const localParts: Buffer[] = [];
1321
+ const centralParts: Buffer[] = [];
1322
+ let offset = 0;
1323
+ const stamp = dosDateTime();
1324
+ for (const file of files) {
1325
+ const name = Buffer.from(file.name, "utf8");
1326
+ const content = Buffer.from(file.content, "utf8");
1327
+ const checksum = crc32(content);
1328
+ const local = Buffer.alloc(30);
1329
+ local.writeUInt32LE(0x04034b50, 0);
1330
+ local.writeUInt16LE(20, 4);
1331
+ local.writeUInt16LE(0, 6);
1332
+ local.writeUInt16LE(0, 8);
1333
+ local.writeUInt16LE(stamp.time, 10);
1334
+ local.writeUInt16LE(stamp.date, 12);
1335
+ local.writeUInt32LE(checksum, 14);
1336
+ local.writeUInt32LE(content.length, 18);
1337
+ local.writeUInt32LE(content.length, 22);
1338
+ local.writeUInt16LE(name.length, 26);
1339
+ local.writeUInt16LE(0, 28);
1340
+ localParts.push(local, name, content);
1341
+
1342
+ const central = Buffer.alloc(46);
1343
+ central.writeUInt32LE(0x02014b50, 0);
1344
+ central.writeUInt16LE(20, 4);
1345
+ central.writeUInt16LE(20, 6);
1346
+ central.writeUInt16LE(0, 8);
1347
+ central.writeUInt16LE(0, 10);
1348
+ central.writeUInt16LE(stamp.time, 12);
1349
+ central.writeUInt16LE(stamp.date, 14);
1350
+ central.writeUInt32LE(checksum, 16);
1351
+ central.writeUInt32LE(content.length, 20);
1352
+ central.writeUInt32LE(content.length, 24);
1353
+ central.writeUInt16LE(name.length, 28);
1354
+ central.writeUInt16LE(0, 30);
1355
+ central.writeUInt16LE(0, 32);
1356
+ central.writeUInt16LE(0, 34);
1357
+ central.writeUInt16LE(0, 36);
1358
+ central.writeUInt32LE(0, 38);
1359
+ central.writeUInt32LE(offset, 42);
1360
+ centralParts.push(central, name);
1361
+ offset += local.length + name.length + content.length;
1362
+ }
1363
+ const centralDirectory = Buffer.concat(centralParts);
1364
+ const end = Buffer.alloc(22);
1365
+ end.writeUInt32LE(0x06054b50, 0);
1366
+ end.writeUInt16LE(0, 4);
1367
+ end.writeUInt16LE(0, 6);
1368
+ end.writeUInt16LE(files.length, 8);
1369
+ end.writeUInt16LE(files.length, 10);
1370
+ end.writeUInt32LE(centralDirectory.length, 12);
1371
+ end.writeUInt32LE(offset, 16);
1372
+ end.writeUInt16LE(0, 20);
1373
+ return Buffer.concat([...localParts, centralDirectory, end]);
1374
+ }
1375
+
1376
+ export async function writeMeetExportBundle(params: {
1377
+ outputDir?: string;
1378
+ artifacts: GoogleMeetArtifactsResult;
1379
+ attendance: GoogleMeetAttendanceResult;
1380
+ zip?: boolean;
1381
+ request?: GoogleMeetExportRequest;
1382
+ tokenSource?: "cached-access-token" | "refresh-token";
1383
+ calendarEvent?: GoogleMeetCalendarLookupResult;
1384
+ }): Promise<{ outputDir: string; files: string[]; zipFile?: string }> {
1385
+ const outputDir = params.outputDir?.trim() || defaultExportDirectory();
1386
+ await mkdir(outputDir, { recursive: true });
1387
+ const zipFile = params.zip ? `${outputDir.replace(/\/$/, "")}.zip` : undefined;
1388
+ const fileNames = googleMeetExportFileNames();
1389
+ const files = [
1390
+ {
1391
+ name: "summary.md",
1392
+ content: `${renderArtifactsMarkdown(params.artifacts)}\n${renderAttendanceMarkdown(params.attendance)}`,
1393
+ },
1394
+ { name: "attendance.csv", content: renderAttendanceCsv(params.attendance) },
1395
+ { name: "transcript.md", content: renderTranscriptMarkdown(params.artifacts) },
1396
+ { name: "artifacts.json", content: `${JSON.stringify(params.artifacts, null, 2)}\n` },
1397
+ { name: "attendance.json", content: `${JSON.stringify(params.attendance, null, 2)}\n` },
1398
+ {
1399
+ name: "manifest.json",
1400
+ content: `${JSON.stringify(
1401
+ buildGoogleMeetExportManifest({
1402
+ artifacts: params.artifacts,
1403
+ attendance: params.attendance,
1404
+ files: fileNames,
1405
+ ...(params.request ? { request: params.request } : {}),
1406
+ ...(params.tokenSource ? { tokenSource: params.tokenSource } : {}),
1407
+ ...(params.calendarEvent ? { calendarEvent: params.calendarEvent } : {}),
1408
+ ...(zipFile ? { zipFile } : {}),
1409
+ }),
1410
+ null,
1411
+ 2,
1412
+ )}\n`,
1413
+ },
1414
+ ];
1415
+ for (const file of files) {
1416
+ await writeFile(path.join(outputDir, file.name), file.content, "utf8");
1417
+ }
1418
+ const result: { outputDir: string; files: string[]; zipFile?: string } = {
1419
+ outputDir,
1420
+ files: files.map((file) => path.join(outputDir, file.name)),
1421
+ };
1422
+ if (zipFile) {
1423
+ await writeFile(zipFile, buildZipArchive(files));
1424
+ result.zipFile = zipFile;
1425
+ }
1426
+ return result;
1427
+ }
1428
+
1429
+ export function registerGoogleMeetCli(params: {
1430
+ program: Command;
1431
+ config: GoogleMeetConfig;
1432
+ ensureRuntime: () => Promise<GoogleMeetRuntime>;
1433
+ callGatewayFromCli?: typeof callGatewayFromCli;
1434
+ }) {
1435
+ const callGateway = params.callGatewayFromCli ?? callGatewayFromCli;
1436
+ const operationTimeoutMs = resolveGoogleMeetGatewayOperationTimeoutMs(params.config);
1437
+ const root = params.program
1438
+ .command("googlemeet")
1439
+ .description("Google Meet participant utilities")
1440
+ .addHelpText("after", () => `\nDocs: https://docs.actagent.ai/plugins/google-meet\n`);
1441
+
1442
+ const auth = root.command("auth").description("Google Meet OAuth helpers");
1443
+
1444
+ auth
1445
+ .command("login")
1446
+ .description("Run a PKCE OAuth flow and print refresh-token JSON to store in plugin config")
1447
+ .option("--client-id <id>", "OAuth client id override")
1448
+ .option("--client-secret <secret>", "OAuth client secret override")
1449
+ .option("--manual", "Use copy/paste callback flow instead of localhost callback")
1450
+ .option("--json", "Print the token payload as JSON", false)
1451
+ .option("--timeout-sec <n>", "Local callback timeout in seconds", "300")
1452
+ .action(async (options: OAuthLoginOptions) => {
1453
+ const clientId = options.clientId?.trim() || params.config.oauth.clientId;
1454
+ const clientSecret = options.clientSecret?.trim() || params.config.oauth.clientSecret;
1455
+ if (!clientId) {
1456
+ throw new Error(
1457
+ "Missing Google Meet OAuth client id. Configure oauth.clientId or pass --client-id.",
1458
+ );
1459
+ }
1460
+ const { verifier, challenge } = createGoogleMeetPkce();
1461
+ const state = createGoogleMeetOAuthState();
1462
+ const authUrl = buildGoogleMeetAuthUrl({
1463
+ clientId,
1464
+ challenge,
1465
+ state,
1466
+ });
1467
+ const code = await waitForGoogleMeetAuthCode({
1468
+ state,
1469
+ manual: Boolean(options.manual),
1470
+ timeoutMs: resolveGoogleMeetOAuthCallbackTimeoutMs(options.timeoutSec),
1471
+ authUrl,
1472
+ promptInput,
1473
+ writeLine: (message) => writeStdoutLine("%s", message),
1474
+ });
1475
+ const tokens = await exchangeGoogleMeetAuthCode({
1476
+ clientId,
1477
+ clientSecret,
1478
+ code,
1479
+ verifier,
1480
+ });
1481
+ if (!tokens.refreshToken) {
1482
+ throw new Error(
1483
+ "Google OAuth did not return a refresh token. Re-run the flow with consent and offline access.",
1484
+ );
1485
+ }
1486
+ const payload = {
1487
+ oauth: {
1488
+ clientId,
1489
+ ...(clientSecret ? { clientSecret } : {}),
1490
+ refreshToken: tokens.refreshToken,
1491
+ accessToken: tokens.accessToken,
1492
+ expiresAt: tokens.expiresAt,
1493
+ },
1494
+ scope: tokens.scope,
1495
+ tokenType: tokens.tokenType,
1496
+ };
1497
+ if (!options.json) {
1498
+ writeStdoutLine("Paste this into plugins.entries.google-meet.config:");
1499
+ }
1500
+ writeStdoutJson(payload);
1501
+ });
1502
+
1503
+ root
1504
+ .command("create")
1505
+ .description("Create a new Google Meet space and print its meeting URL")
1506
+ .option("--access-token <token>", "Access token override")
1507
+ .option("--refresh-token <token>", "Refresh token override")
1508
+ .option("--client-id <id>", "OAuth client id override")
1509
+ .option("--client-secret <secret>", "OAuth client secret override")
1510
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1511
+ .option(
1512
+ "--access-type <type>",
1513
+ "Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED",
1514
+ )
1515
+ .option(
1516
+ "--entry-point-access <type>",
1517
+ "Google Meet SpaceConfig entryPointAccess for API create: ALL or CREATOR_APP_ONLY",
1518
+ )
1519
+ .option("--no-join", "Only create the meeting URL; do not join it")
1520
+ .option("--transport <transport>", "Join transport: chrome, chrome-node, or twilio")
1521
+ .option("--mode <mode>", "Join mode: agent, bidi, or transcribe")
1522
+ .option("--message <text>", "Realtime speech to trigger after join")
1523
+ .option("--dial-in-number <phone>", "Meet dial-in number for Twilio transport")
1524
+ .option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
1525
+ .option("--dtmf-sequence <sequence>", "Explicit Twilio DTMF sequence")
1526
+ .option("--json", "Print JSON output", false)
1527
+ .action(async (options: CreateOptions) => {
1528
+ if (options.join !== false) {
1529
+ const delegated = await callGoogleMeetGateway({
1530
+ callGateway,
1531
+ method: "googlemeet.create",
1532
+ payload: { ...options },
1533
+ timeoutMs: operationTimeoutMs,
1534
+ });
1535
+ if (delegated.ok) {
1536
+ const payload = delegated.payload as {
1537
+ browser?: { nodeId?: string };
1538
+ joined?: boolean;
1539
+ join?: { session?: { id?: string } };
1540
+ meetingUri?: string;
1541
+ source?: string;
1542
+ space?: { name?: string; meetingCode?: string };
1543
+ tokenSource?: string;
1544
+ };
1545
+ if (options.json) {
1546
+ writeStdoutJson(payload);
1547
+ return;
1548
+ }
1549
+ writeStdoutLine("meeting uri: %s", payload.meetingUri);
1550
+ if (payload.space?.name) {
1551
+ writeStdoutLine("space: %s", payload.space.name);
1552
+ }
1553
+ if (payload.space?.meetingCode) {
1554
+ writeStdoutLine("meeting code: %s", payload.space.meetingCode);
1555
+ }
1556
+ if (payload.source) {
1557
+ writeStdoutLine("source: %s", payload.source);
1558
+ }
1559
+ if (payload.browser?.nodeId) {
1560
+ writeStdoutLine("node: %s", payload.browser.nodeId);
1561
+ }
1562
+ if (payload.tokenSource) {
1563
+ writeStdoutLine("token source: %s", payload.tokenSource);
1564
+ }
1565
+ if (payload.joined && payload.join?.session?.id) {
1566
+ writeStdoutLine("joined: %s", payload.join.session.id);
1567
+ } else {
1568
+ writeStdoutLine("joined: no (run `actagent googlemeet join %s`)", payload.meetingUri);
1569
+ }
1570
+ return;
1571
+ }
1572
+ }
1573
+ if (!hasCreateOAuth(params.config, options)) {
1574
+ if (hasCreateSpaceConfigInput(options as Record<string, unknown>)) {
1575
+ throw new Error(
1576
+ "Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove --access-type/--entry-point-access.",
1577
+ );
1578
+ }
1579
+ const rt = await params.ensureRuntime();
1580
+ const result = await rt.createViaBrowser();
1581
+ const join =
1582
+ options.join !== false
1583
+ ? await rt.join({
1584
+ url: result.meetingUri,
1585
+ transport: options.transport,
1586
+ mode: options.mode,
1587
+ message: options.message,
1588
+ dialInNumber: options.dialInNumber,
1589
+ pin: options.pin,
1590
+ dtmfSequence: options.dtmfSequence,
1591
+ })
1592
+ : undefined;
1593
+ const payload = {
1594
+ source: result.source,
1595
+ meetingUri: result.meetingUri,
1596
+ joined: Boolean(join),
1597
+ ...(join ? { join } : {}),
1598
+ browser: {
1599
+ nodeId: result.nodeId,
1600
+ targetId: result.targetId,
1601
+ browserUrl: result.browserUrl,
1602
+ browserTitle: result.browserTitle,
1603
+ },
1604
+ };
1605
+ if (options.json) {
1606
+ writeStdoutJson(payload);
1607
+ return;
1608
+ }
1609
+ writeStdoutLine("meeting uri: %s", result.meetingUri);
1610
+ writeStdoutLine("source: browser");
1611
+ writeStdoutLine("node: %s", result.nodeId);
1612
+ if (join) {
1613
+ writeStdoutLine("joined: %s", join.session.id);
1614
+ } else {
1615
+ writeStdoutLine("joined: no (run `actagent googlemeet join %s`)", result.meetingUri);
1616
+ }
1617
+ return;
1618
+ }
1619
+ const token = await resolveGoogleMeetAccessToken(
1620
+ resolveCreateTokenOptions(params.config, options),
1621
+ );
1622
+ const result = await createGoogleMeetSpace({
1623
+ accessToken: token.accessToken,
1624
+ config: resolveCreateSpaceConfig(options as Record<string, unknown>),
1625
+ });
1626
+ const join =
1627
+ options.join !== false
1628
+ ? await (
1629
+ await params.ensureRuntime()
1630
+ ).join({
1631
+ url: result.meetingUri,
1632
+ transport: options.transport,
1633
+ mode: options.mode,
1634
+ message: options.message,
1635
+ dialInNumber: options.dialInNumber,
1636
+ pin: options.pin,
1637
+ dtmfSequence: options.dtmfSequence,
1638
+ })
1639
+ : undefined;
1640
+ if (options.json) {
1641
+ writeStdoutJson({
1642
+ ...result,
1643
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
1644
+ joined: Boolean(join),
1645
+ ...(join ? { join } : {}),
1646
+ });
1647
+ return;
1648
+ }
1649
+ writeStdoutLine("meeting uri: %s", result.meetingUri);
1650
+ writeStdoutLine("space: %s", result.space.name);
1651
+ if (result.space.meetingCode) {
1652
+ writeStdoutLine("meeting code: %s", result.space.meetingCode);
1653
+ }
1654
+ writeStdoutLine(
1655
+ "token source: %s",
1656
+ token.refreshed ? "refresh-token" : "cached-access-token",
1657
+ );
1658
+ if (join) {
1659
+ writeStdoutLine("joined: %s", join.session.id);
1660
+ } else {
1661
+ writeStdoutLine("joined: no (run `actagent googlemeet join %s`)", result.meetingUri);
1662
+ }
1663
+ });
1664
+
1665
+ root
1666
+ .command("end-active-conference")
1667
+ .description("End the active conference for a Google Meet space")
1668
+ .argument("[meeting]", "Meet URL, meeting code, or spaces/{id}")
1669
+ .option("--access-token <token>", "Access token override")
1670
+ .option("--refresh-token <token>", "Refresh token override")
1671
+ .option("--client-id <id>", "OAuth client id override")
1672
+ .option("--client-secret <secret>", "OAuth client secret override")
1673
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1674
+ .option("--json", "Print JSON output", false)
1675
+ .action(async (meeting: string | undefined, options: ResolveSpaceOptions & JsonOptions) => {
1676
+ const token = await resolveGoogleMeetAccessToken(
1677
+ resolveOAuthTokenOptions(params.config, options),
1678
+ );
1679
+ const result = await endGoogleMeetActiveConference({
1680
+ accessToken: token.accessToken,
1681
+ meeting: resolveMeetingInput(params.config, meeting ?? options.meeting),
1682
+ });
1683
+ if (options.json) {
1684
+ writeStdoutJson({
1685
+ ...result,
1686
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
1687
+ });
1688
+ return;
1689
+ }
1690
+ writeStdoutLine("space: %s", result.space);
1691
+ writeStdoutLine("ended: yes");
1692
+ writeStdoutLine(
1693
+ "token source: %s",
1694
+ token.refreshed ? "refresh-token" : "cached-access-token",
1695
+ );
1696
+ });
1697
+
1698
+ root
1699
+ .command("join")
1700
+ .argument("[url]", "Explicit https://meet.google.com/... URL")
1701
+ .option("--transport <transport>", "Transport: chrome, chrome-node, or twilio")
1702
+ .option("--mode <mode>", "Mode: agent, bidi, or transcribe")
1703
+ .option("--message <text>", "Realtime speech to trigger after join")
1704
+ .option("--dial-in-number <phone>", "Meet dial-in number for Twilio transport")
1705
+ .option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
1706
+ .option("--dtmf-sequence <sequence>", "Explicit Twilio DTMF sequence")
1707
+ .action(async (url: string | undefined, options: JoinOptions) => {
1708
+ const payload = {
1709
+ url: resolveMeetingInput(params.config, url),
1710
+ transport: options.transport,
1711
+ mode: options.mode,
1712
+ message: options.message,
1713
+ dialInNumber: options.dialInNumber,
1714
+ pin: options.pin,
1715
+ dtmfSequence: options.dtmfSequence,
1716
+ };
1717
+ const delegated = await callGoogleMeetGateway({
1718
+ callGateway,
1719
+ method: "googlemeet.join",
1720
+ payload,
1721
+ timeoutMs: operationTimeoutMs,
1722
+ });
1723
+ if (delegated.ok) {
1724
+ const result = delegated.payload as { session?: unknown };
1725
+ writeStdoutJson(result.session ?? delegated.payload);
1726
+ return;
1727
+ }
1728
+ const rt = await params.ensureRuntime();
1729
+ const result = await rt.join(payload);
1730
+ writeStdoutJson(result.session);
1731
+ });
1732
+
1733
+ root
1734
+ .command("test-speech")
1735
+ .argument("[url]", "Explicit https://meet.google.com/... URL")
1736
+ .option("--transport <transport>", "Transport: chrome, chrome-node, or twilio")
1737
+ .option("--mode <mode>", "Mode: agent, bidi, or transcribe")
1738
+ .option(
1739
+ "--message <text>",
1740
+ "Realtime speech to trigger",
1741
+ "Say exactly: Google Meet speech test complete.",
1742
+ )
1743
+ .action(async (url: string | undefined, options: JoinOptions) => {
1744
+ const payload = {
1745
+ url: resolveMeetingInput(params.config, url),
1746
+ transport: options.transport,
1747
+ mode: options.mode,
1748
+ message: options.message,
1749
+ };
1750
+ const delegated = await callGoogleMeetGateway({
1751
+ callGateway,
1752
+ method: "googlemeet.testSpeech",
1753
+ payload,
1754
+ timeoutMs: operationTimeoutMs,
1755
+ });
1756
+ if (delegated.ok) {
1757
+ writeStdoutJson(delegated.payload);
1758
+ return;
1759
+ }
1760
+ const rt = await params.ensureRuntime();
1761
+ writeStdoutJson(await rt.testSpeech(payload));
1762
+ });
1763
+
1764
+ root
1765
+ .command("test-listen")
1766
+ .argument("[url]", "Explicit https://meet.google.com/... URL")
1767
+ .option("--transport <transport>", "Transport: chrome or chrome-node")
1768
+ .option("--timeout-ms <ms>", "How long to wait for fresh captions/transcript movement")
1769
+ .action(async (url: string | undefined, options: JoinOptions) => {
1770
+ const payload = {
1771
+ url: resolveMeetingInput(params.config, url),
1772
+ transport: options.transport,
1773
+ timeoutMs: parsePositiveNumber(options.timeoutMs, "timeout-ms"),
1774
+ };
1775
+ const delegated = await callGoogleMeetGateway({
1776
+ callGateway,
1777
+ method: "googlemeet.testListen",
1778
+ payload,
1779
+ timeoutMs: operationTimeoutMs,
1780
+ });
1781
+ if (delegated.ok) {
1782
+ writeStdoutJson(delegated.payload);
1783
+ return;
1784
+ }
1785
+ const rt = await params.ensureRuntime();
1786
+ writeStdoutJson(await rt.testListen(payload));
1787
+ });
1788
+
1789
+ root
1790
+ .command("resolve-space")
1791
+ .description("Resolve a Meet URL, meeting code, or spaces/{id} to its canonical space")
1792
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
1793
+ .option("--access-token <token>", "Access token override")
1794
+ .option("--refresh-token <token>", "Refresh token override")
1795
+ .option("--client-id <id>", "OAuth client id override")
1796
+ .option("--client-secret <secret>", "OAuth client secret override")
1797
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1798
+ .option("--json", "Print JSON output", false)
1799
+ .action(async (options: ResolveSpaceOptions) => {
1800
+ const resolved = resolveTokenOptions(params.config, options);
1801
+ const token = await resolveGoogleMeetAccessToken(resolved);
1802
+ const space = await fetchGoogleMeetSpace({
1803
+ accessToken: token.accessToken,
1804
+ meeting: resolved.meeting,
1805
+ });
1806
+ if (options.json) {
1807
+ writeStdoutJson(space);
1808
+ return;
1809
+ }
1810
+ writeStdoutLine("input: %s", resolved.meeting);
1811
+ writeStdoutLine("space: %s", space.name);
1812
+ if (space.meetingCode) {
1813
+ writeStdoutLine("meeting code: %s", space.meetingCode);
1814
+ }
1815
+ if (space.meetingUri) {
1816
+ writeStdoutLine("meeting uri: %s", space.meetingUri);
1817
+ }
1818
+ writeStdoutLine("active conference: %s", space.activeConference ? "yes" : "no");
1819
+ writeStdoutLine(
1820
+ "token source: %s",
1821
+ token.refreshed ? "refresh-token" : "cached-access-token",
1822
+ );
1823
+ });
1824
+
1825
+ root
1826
+ .command("preflight")
1827
+ .description("Validate OAuth + meeting resolution prerequisites for Meet media work")
1828
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
1829
+ .option("--access-token <token>", "Access token override")
1830
+ .option("--refresh-token <token>", "Refresh token override")
1831
+ .option("--client-id <id>", "OAuth client id override")
1832
+ .option("--client-secret <secret>", "OAuth client secret override")
1833
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1834
+ .option("--json", "Print JSON output", false)
1835
+ .action(async (options: ResolveSpaceOptions) => {
1836
+ const resolved = resolveTokenOptions(params.config, options);
1837
+ const token = await resolveGoogleMeetAccessToken(resolved);
1838
+ const space = await fetchGoogleMeetSpace({
1839
+ accessToken: token.accessToken,
1840
+ meeting: resolved.meeting,
1841
+ });
1842
+ const report = buildGoogleMeetPreflightReport({
1843
+ input: resolved.meeting,
1844
+ space,
1845
+ previewAcknowledged: params.config.preview.enrollmentAcknowledged,
1846
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
1847
+ });
1848
+ if (options.json) {
1849
+ writeStdoutJson(report);
1850
+ return;
1851
+ }
1852
+ writeStdoutLine("input: %s", report.input);
1853
+ writeStdoutLine("resolved space: %s", report.resolvedSpaceName);
1854
+ if (report.meetingCode) {
1855
+ writeStdoutLine("meeting code: %s", report.meetingCode);
1856
+ }
1857
+ if (report.meetingUri) {
1858
+ writeStdoutLine("meeting uri: %s", report.meetingUri);
1859
+ }
1860
+ writeStdoutLine("active conference: %s", report.hasActiveConference ? "yes" : "no");
1861
+ writeStdoutLine("preview acknowledged: %s", report.previewAcknowledged ? "yes" : "no");
1862
+ writeStdoutLine("token source: %s", report.tokenSource);
1863
+ if (report.blockers.length === 0) {
1864
+ writeStdoutLine("blockers: none");
1865
+ return;
1866
+ }
1867
+ writeStdoutLine("blockers:");
1868
+ for (const blocker of report.blockers) {
1869
+ writeStdoutLine("- %s", blocker);
1870
+ }
1871
+ });
1872
+
1873
+ root
1874
+ .command("latest")
1875
+ .description("Find the latest Meet conference record for a meeting")
1876
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
1877
+ .option("--today", "Find a Meet link on today's calendar")
1878
+ .option("--event <query>", "Find a matching calendar event with a Meet link")
1879
+ .option("--calendar <id>", "Calendar id for --today or --event", "primary")
1880
+ .option("--access-token <token>", "Access token override")
1881
+ .option("--refresh-token <token>", "Refresh token override")
1882
+ .option("--client-id <id>", "OAuth client id override")
1883
+ .option("--client-secret <secret>", "OAuth client secret override")
1884
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1885
+ .option("--json", "Print JSON output", false)
1886
+ .action(async (options: ResolveSpaceOptions) => {
1887
+ const token = await resolveGoogleMeetAccessToken(
1888
+ resolveOAuthTokenOptions(params.config, options),
1889
+ );
1890
+ const resolved = await resolveMeetingForToken({
1891
+ config: params.config,
1892
+ options,
1893
+ accessToken: token.accessToken,
1894
+ configuredMeeting: options.meeting?.trim(),
1895
+ });
1896
+ const result = await fetchLatestGoogleMeetConferenceRecord({
1897
+ accessToken: token.accessToken,
1898
+ meeting: resolved.meeting,
1899
+ });
1900
+ if (options.json) {
1901
+ writeStdoutJson({
1902
+ ...result,
1903
+ ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}),
1904
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
1905
+ });
1906
+ return;
1907
+ }
1908
+ if (resolved.calendarEvent) {
1909
+ writeStdoutLine("calendar event: %s", resolved.calendarEvent.event.summary ?? "untitled");
1910
+ writeStdoutLine("calendar meet: %s", resolved.calendarEvent.meetingUri);
1911
+ }
1912
+ writeLatestConferenceRecordSummary(result);
1913
+ writeStdoutLine(
1914
+ "token source: %s",
1915
+ token.refreshed ? "refresh-token" : "cached-access-token",
1916
+ );
1917
+ });
1918
+
1919
+ root
1920
+ .command("calendar-events")
1921
+ .description("Preview Calendar events with Google Meet links")
1922
+ .option("--today", "Find Meet links on today's calendar")
1923
+ .option("--event <query>", "Find matching calendar events with Meet links")
1924
+ .option("--calendar <id>", "Calendar id for lookup", "primary")
1925
+ .option("--access-token <token>", "Access token override")
1926
+ .option("--refresh-token <token>", "Refresh token override")
1927
+ .option("--client-id <id>", "OAuth client id override")
1928
+ .option("--client-secret <secret>", "OAuth client secret override")
1929
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1930
+ .option("--json", "Print JSON output", false)
1931
+ .action(async (options: ResolveSpaceOptions) => {
1932
+ const token = await resolveGoogleMeetAccessToken(
1933
+ resolveOAuthTokenOptions(params.config, options),
1934
+ );
1935
+ const window = options.today ? buildGoogleMeetCalendarDayWindow() : {};
1936
+ const result = await listGoogleMeetCalendarEvents({
1937
+ accessToken: token.accessToken,
1938
+ calendarId: options.calendar,
1939
+ eventQuery: options.event,
1940
+ ...window,
1941
+ });
1942
+ const payload = {
1943
+ ...result,
1944
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
1945
+ };
1946
+ if (options.json) {
1947
+ writeStdoutJson(payload);
1948
+ return;
1949
+ }
1950
+ writeCalendarEventsSummary(result);
1951
+ writeStdoutLine(
1952
+ "token source: %s",
1953
+ token.refreshed ? "refresh-token" : "cached-access-token",
1954
+ );
1955
+ });
1956
+
1957
+ root
1958
+ .command("artifacts")
1959
+ .description("List Meet conference records and available participant/artifact metadata")
1960
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
1961
+ .option("--conference-record <name>", "Conference record name or id")
1962
+ .option("--today", "Find a Meet link on today's calendar")
1963
+ .option("--event <query>", "Find a matching calendar event with a Meet link")
1964
+ .option("--calendar <id>", "Calendar id for --today or --event", "primary")
1965
+ .option("--access-token <token>", "Access token override")
1966
+ .option("--refresh-token <token>", "Refresh token override")
1967
+ .option("--client-id <id>", "OAuth client id override")
1968
+ .option("--client-secret <secret>", "OAuth client secret override")
1969
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
1970
+ .option("--page-size <n>", "Max resources per Meet API page")
1971
+ .option("--all-conference-records", "Fetch every conference record for --meeting")
1972
+ .option("--no-transcript-entries", "Skip structured transcript entry lookup")
1973
+ .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text")
1974
+ .option("--format <format>", "Output format: summary or markdown", "summary")
1975
+ .option("--output <path>", "Write output to a file instead of stdout")
1976
+ .option("--json", "Print JSON output", false)
1977
+ .action(async (options: MeetArtifactOptions) => {
1978
+ const resolved = resolveArtifactTokenOptions(params.config, options);
1979
+ const token = await resolveGoogleMeetAccessToken(resolved);
1980
+ const meeting = resolved.conferenceRecord
1981
+ ? resolved.meeting
1982
+ : (
1983
+ await resolveMeetingForToken({
1984
+ config: params.config,
1985
+ options,
1986
+ accessToken: token.accessToken,
1987
+ configuredMeeting: resolved.meeting,
1988
+ })
1989
+ ).meeting;
1990
+ const result = await fetchGoogleMeetArtifacts({
1991
+ accessToken: token.accessToken,
1992
+ meeting,
1993
+ conferenceRecord: resolved.conferenceRecord,
1994
+ pageSize: resolved.pageSize,
1995
+ includeTranscriptEntries: resolved.includeTranscriptEntries,
1996
+ allConferenceRecords: resolved.allConferenceRecords,
1997
+ includeDocumentBodies: resolved.includeDocumentBodies,
1998
+ });
1999
+ if (options.json) {
2000
+ await writeCliOutput(
2001
+ options,
2002
+ JSON.stringify(
2003
+ {
2004
+ ...result,
2005
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2006
+ },
2007
+ null,
2008
+ 2,
2009
+ ),
2010
+ );
2011
+ return;
2012
+ }
2013
+ if (options.format === "markdown") {
2014
+ await writeCliOutput(options, renderArtifactsMarkdown(result));
2015
+ return;
2016
+ }
2017
+ if (options.format && options.format !== "summary") {
2018
+ throw new Error("Unsupported format. Expected summary or markdown.");
2019
+ }
2020
+ writeArtifactsSummary(result);
2021
+ writeStdoutLine(
2022
+ "token source: %s",
2023
+ token.refreshed ? "refresh-token" : "cached-access-token",
2024
+ );
2025
+ });
2026
+
2027
+ root
2028
+ .command("attendance")
2029
+ .description("List Meet participants and participant sessions")
2030
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
2031
+ .option("--conference-record <name>", "Conference record name or id")
2032
+ .option("--today", "Find a Meet link on today's calendar")
2033
+ .option("--event <query>", "Find a matching calendar event with a Meet link")
2034
+ .option("--calendar <id>", "Calendar id for --today or --event", "primary")
2035
+ .option("--access-token <token>", "Access token override")
2036
+ .option("--refresh-token <token>", "Refresh token override")
2037
+ .option("--client-id <id>", "OAuth client id override")
2038
+ .option("--client-secret <secret>", "OAuth client secret override")
2039
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
2040
+ .option("--page-size <n>", "Max resources per Meet API page")
2041
+ .option("--all-conference-records", "Fetch every conference record for --meeting")
2042
+ .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows")
2043
+ .option("--late-after-minutes <n>", "Mark participants late after this many minutes", "5")
2044
+ .option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
2045
+ .option("--format <format>", "Output format: summary, markdown, or csv", "summary")
2046
+ .option("--output <path>", "Write output to a file instead of stdout")
2047
+ .option("--json", "Print JSON output", false)
2048
+ .action(async (options: MeetArtifactOptions) => {
2049
+ const resolved = resolveArtifactTokenOptions(params.config, options);
2050
+ const token = await resolveGoogleMeetAccessToken(resolved);
2051
+ const meeting = resolved.conferenceRecord
2052
+ ? resolved.meeting
2053
+ : (
2054
+ await resolveMeetingForToken({
2055
+ config: params.config,
2056
+ options,
2057
+ accessToken: token.accessToken,
2058
+ configuredMeeting: resolved.meeting,
2059
+ })
2060
+ ).meeting;
2061
+ const result = await fetchGoogleMeetAttendance({
2062
+ accessToken: token.accessToken,
2063
+ meeting,
2064
+ conferenceRecord: resolved.conferenceRecord,
2065
+ pageSize: resolved.pageSize,
2066
+ allConferenceRecords: resolved.allConferenceRecords,
2067
+ mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
2068
+ lateAfterMinutes: resolved.lateAfterMinutes,
2069
+ earlyBeforeMinutes: resolved.earlyBeforeMinutes,
2070
+ });
2071
+ if (options.json) {
2072
+ await writeCliOutput(
2073
+ options,
2074
+ JSON.stringify(
2075
+ {
2076
+ ...result,
2077
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2078
+ },
2079
+ null,
2080
+ 2,
2081
+ ),
2082
+ );
2083
+ return;
2084
+ }
2085
+ if (options.format === "markdown") {
2086
+ await writeCliOutput(options, renderAttendanceMarkdown(result));
2087
+ return;
2088
+ }
2089
+ if (options.format === "csv") {
2090
+ await writeCliOutput(options, renderAttendanceCsv(result));
2091
+ return;
2092
+ }
2093
+ if (options.format && options.format !== "summary") {
2094
+ throw new Error("Unsupported format. Expected summary, markdown, or csv.");
2095
+ }
2096
+ writeAttendanceSummary(result);
2097
+ writeStdoutLine(
2098
+ "token source: %s",
2099
+ token.refreshed ? "refresh-token" : "cached-access-token",
2100
+ );
2101
+ });
2102
+
2103
+ root
2104
+ .command("export")
2105
+ .description("Write Meet artifacts, attendance, transcript, and raw JSON into a folder")
2106
+ .option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
2107
+ .option("--conference-record <name>", "Conference record name or id")
2108
+ .option("--today", "Find a Meet link on today's calendar")
2109
+ .option("--event <query>", "Find a matching calendar event with a Meet link")
2110
+ .option("--calendar <id>", "Calendar id for --today or --event", "primary")
2111
+ .option("--access-token <token>", "Access token override")
2112
+ .option("--refresh-token <token>", "Refresh token override")
2113
+ .option("--client-id <id>", "OAuth client id override")
2114
+ .option("--client-secret <secret>", "OAuth client secret override")
2115
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
2116
+ .option("--page-size <n>", "Max resources per Meet API page")
2117
+ .option("--all-conference-records", "Fetch every conference record for --meeting")
2118
+ .option("--no-transcript-entries", "Skip structured transcript entry lookup")
2119
+ .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text")
2120
+ .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows")
2121
+ .option("--late-after-minutes <n>", "Mark participants late after this many minutes", "5")
2122
+ .option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
2123
+ .option("--output <dir>", "Output directory")
2124
+ .option("--zip", "Also write a portable .zip archive")
2125
+ .option("--dry-run", "Fetch export data and print the manifest without writing files", false)
2126
+ .option("--json", "Print JSON output", false)
2127
+ .action(async (options: MeetArtifactOptions) => {
2128
+ const resolved = resolveArtifactTokenOptions(params.config, options);
2129
+ const token = await resolveGoogleMeetAccessToken(resolved);
2130
+ const meetingResult: { meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult } =
2131
+ resolved.conferenceRecord
2132
+ ? { meeting: resolved.meeting }
2133
+ : await resolveMeetingForToken({
2134
+ config: params.config,
2135
+ options,
2136
+ accessToken: token.accessToken,
2137
+ configuredMeeting: resolved.meeting,
2138
+ });
2139
+ const artifacts = await fetchGoogleMeetArtifacts({
2140
+ accessToken: token.accessToken,
2141
+ meeting: meetingResult.meeting,
2142
+ conferenceRecord: resolved.conferenceRecord,
2143
+ pageSize: resolved.pageSize,
2144
+ includeTranscriptEntries: resolved.includeTranscriptEntries,
2145
+ allConferenceRecords: resolved.allConferenceRecords,
2146
+ includeDocumentBodies: resolved.includeDocumentBodies,
2147
+ });
2148
+ const attendance = await fetchGoogleMeetAttendance({
2149
+ accessToken: token.accessToken,
2150
+ meeting: meetingResult.meeting,
2151
+ conferenceRecord: resolved.conferenceRecord,
2152
+ pageSize: resolved.pageSize,
2153
+ allConferenceRecords: resolved.allConferenceRecords,
2154
+ mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
2155
+ lateAfterMinutes: resolved.lateAfterMinutes,
2156
+ earlyBeforeMinutes: resolved.earlyBeforeMinutes,
2157
+ });
2158
+ const resolvedMeeting = meetingResult.meeting ?? resolved.meeting;
2159
+ const request: GoogleMeetExportRequest = {
2160
+ ...(resolvedMeeting ? { meeting: resolvedMeeting } : {}),
2161
+ ...(resolved.conferenceRecord ? { conferenceRecord: resolved.conferenceRecord } : {}),
2162
+ ...(meetingResult.calendarEvent?.event.id
2163
+ ? { calendarEventId: meetingResult.calendarEvent.event.id }
2164
+ : {}),
2165
+ ...(meetingResult.calendarEvent?.event.summary
2166
+ ? { calendarEventSummary: meetingResult.calendarEvent.event.summary }
2167
+ : {}),
2168
+ ...(options.calendar ? { calendarId: options.calendar } : {}),
2169
+ ...(resolved.pageSize !== undefined ? { pageSize: resolved.pageSize } : {}),
2170
+ includeTranscriptEntries: resolved.includeTranscriptEntries,
2171
+ includeDocumentBodies: resolved.includeDocumentBodies,
2172
+ allConferenceRecords: resolved.allConferenceRecords,
2173
+ mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
2174
+ ...(resolved.lateAfterMinutes !== undefined
2175
+ ? { lateAfterMinutes: resolved.lateAfterMinutes }
2176
+ : {}),
2177
+ ...(resolved.earlyBeforeMinutes !== undefined
2178
+ ? { earlyBeforeMinutes: resolved.earlyBeforeMinutes }
2179
+ : {}),
2180
+ };
2181
+ if (options.dryRun) {
2182
+ writeStdoutJson({
2183
+ dryRun: true,
2184
+ manifest: buildGoogleMeetExportManifest({
2185
+ artifacts,
2186
+ attendance,
2187
+ files: googleMeetExportFileNames(),
2188
+ request,
2189
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2190
+ ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}),
2191
+ }),
2192
+ ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}),
2193
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2194
+ });
2195
+ return;
2196
+ }
2197
+ const bundle = await writeMeetExportBundle({
2198
+ outputDir: options.output,
2199
+ artifacts,
2200
+ attendance,
2201
+ zip: Boolean(options.zip),
2202
+ request,
2203
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2204
+ ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}),
2205
+ });
2206
+ const payload = {
2207
+ ...bundle,
2208
+ ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}),
2209
+ tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
2210
+ };
2211
+ if (options.json) {
2212
+ writeStdoutJson(payload);
2213
+ return;
2214
+ }
2215
+ writeStdoutLine("export: %s", bundle.outputDir);
2216
+ for (const file of bundle.files) {
2217
+ writeStdoutLine("- %s", file);
2218
+ }
2219
+ if (bundle.zipFile) {
2220
+ writeStdoutLine("zip: %s", bundle.zipFile);
2221
+ }
2222
+ });
2223
+
2224
+ root
2225
+ .command("status")
2226
+ .argument("[session-id]", "Meet session ID")
2227
+ .option("--json", "Print JSON output", false)
2228
+ .action(async (sessionId?: string) => {
2229
+ const delegated = await callGoogleMeetGateway({
2230
+ callGateway,
2231
+ method: "googlemeet.status",
2232
+ payload: { sessionId },
2233
+ });
2234
+ if (delegated.ok) {
2235
+ writeStdoutJson(delegated.payload);
2236
+ return;
2237
+ }
2238
+ const rt = await params.ensureRuntime();
2239
+ writeStdoutJson(await rt.status(sessionId));
2240
+ });
2241
+
2242
+ root
2243
+ .command("doctor")
2244
+ .description("Show human-readable Meet session/browser/realtime health")
2245
+ .argument("[session-id]", "Meet session ID")
2246
+ .option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false)
2247
+ .option("--meeting <value>", "Also verify spaces.get for a Meet URL, code, or spaces/{id}")
2248
+ .option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false)
2249
+ .option("--access-token <token>", "Access token override")
2250
+ .option("--refresh-token <token>", "Refresh token override")
2251
+ .option("--client-id <id>", "OAuth client id override")
2252
+ .option("--client-secret <secret>", "OAuth client secret override")
2253
+ .option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
2254
+ .option("--json", "Print JSON output", false)
2255
+ .action(async (sessionId: string | undefined, options: DoctorOptions) => {
2256
+ if (options.oauth) {
2257
+ const report = await buildOAuthDoctorReport(params.config, options);
2258
+ if (options.json) {
2259
+ writeStdoutJson(report);
2260
+ return;
2261
+ }
2262
+ writeOAuthDoctorReport(report);
2263
+ return;
2264
+ }
2265
+ const delegated = await callGoogleMeetGateway({
2266
+ callGateway,
2267
+ method: "googlemeet.status",
2268
+ payload: { sessionId },
2269
+ });
2270
+ if (delegated.ok) {
2271
+ const status = delegated.payload as Awaited<ReturnType<GoogleMeetRuntime["status"]>>;
2272
+ if (options.json) {
2273
+ writeStdoutJson(status);
2274
+ return;
2275
+ }
2276
+ writeDoctorStatus(status);
2277
+ return;
2278
+ }
2279
+ const rt = await params.ensureRuntime();
2280
+ const status = await rt.status(sessionId);
2281
+ if (options.json) {
2282
+ writeStdoutJson(status);
2283
+ return;
2284
+ }
2285
+ writeDoctorStatus(status);
2286
+ });
2287
+
2288
+ root
2289
+ .command("recover-tab")
2290
+ .description("Focus and inspect an existing Google Meet tab")
2291
+ .argument("[url]", "Optional Meet URL to match")
2292
+ .option("--transport <transport>", "Transport to inspect: chrome or chrome-node")
2293
+ .option("--json", "Print JSON output", false)
2294
+ .action(async (url: string | undefined, options: RecoverTabOptions) => {
2295
+ const rt = await params.ensureRuntime();
2296
+ const result = await rt.recoverCurrentTab({ url, transport: options.transport });
2297
+ if (options.json) {
2298
+ writeStdoutJson(result);
2299
+ return;
2300
+ }
2301
+ writeRecoverCurrentTabResult(result);
2302
+ });
2303
+
2304
+ root
2305
+ .command("setup")
2306
+ .description("Show Google Meet transport setup status")
2307
+ .option("--transport <transport>", "Transport to check: chrome, chrome-node, or twilio")
2308
+ .option("--mode <mode>", "Mode to check: agent, bidi, or transcribe")
2309
+ .option("--json", "Print JSON output", false)
2310
+ .action(async (options: SetupOptions) => {
2311
+ const rt = await params.ensureRuntime();
2312
+ const status = await rt.setupStatus({ transport: options.transport, mode: options.mode });
2313
+ if (options.json) {
2314
+ writeStdoutJson(status);
2315
+ return;
2316
+ }
2317
+ writeSetupStatus(status);
2318
+ });
2319
+
2320
+ root
2321
+ .command("leave")
2322
+ .argument("<session-id>", "Meet session ID")
2323
+ .action(async (sessionId: string) => {
2324
+ const delegated = await callGoogleMeetGateway({
2325
+ callGateway,
2326
+ method: "googlemeet.leave",
2327
+ payload: { sessionId },
2328
+ });
2329
+ if (delegated.ok) {
2330
+ const result = delegated.payload as { found?: boolean };
2331
+ if (!result.found) {
2332
+ throw new Error("session not found");
2333
+ }
2334
+ writeStdoutLine("left %s", sessionId);
2335
+ return;
2336
+ }
2337
+ const rt = await params.ensureRuntime();
2338
+ const result = await rt.leave(sessionId);
2339
+ if (!result.found) {
2340
+ throw new Error("session not found");
2341
+ }
2342
+ writeStdoutLine("left %s", sessionId);
2343
+ });
2344
+
2345
+ root
2346
+ .command("speak")
2347
+ .argument("<session-id>", "Meet session ID")
2348
+ .argument("[message]", "Realtime instructions to speak now")
2349
+ .action(async (sessionId: string, message?: string) => {
2350
+ const delegated = await callGoogleMeetGateway({
2351
+ callGateway,
2352
+ method: "googlemeet.speak",
2353
+ payload: { sessionId, message },
2354
+ });
2355
+ if (delegated.ok) {
2356
+ const result = delegated.payload as Awaited<ReturnType<GoogleMeetRuntime["speak"]>>;
2357
+ if (!result.found) {
2358
+ throw new Error("session not found");
2359
+ }
2360
+ if (!result.spoken) {
2361
+ throw new Error(
2362
+ result.session?.chrome?.health?.speechBlockedMessage ??
2363
+ "session has no active realtime audio bridge",
2364
+ );
2365
+ }
2366
+ writeStdoutLine("speaking on %s", sessionId);
2367
+ return;
2368
+ }
2369
+ const rt = await params.ensureRuntime();
2370
+ const result = await rt.speak(sessionId, message);
2371
+ if (!result.found) {
2372
+ throw new Error("session not found");
2373
+ }
2374
+ if (!result.spoken) {
2375
+ throw new Error(
2376
+ result.session?.chrome?.health?.speechBlockedMessage ??
2377
+ "session has no active realtime audio bridge",
2378
+ );
2379
+ }
2380
+ writeStdoutLine("speaking on %s", sessionId);
2381
+ });
2382
+ }