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