@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/src/meet.ts ADDED
@@ -0,0 +1,1024 @@
1
+ import { fetchWithSsrFGuard } from "autobot/plugin-sdk/ssrf-runtime";
2
+ import { exportGoogleDriveDocumentText, extractGoogleDriveDocumentId } from "./drive.js";
3
+ import { googleApiError } from "./google-api-errors.js";
4
+
5
+ const GOOGLE_MEET_API_ORIGIN = "https://meet.googleapis.com";
6
+ const GOOGLE_MEET_API_BASE_URL = `${GOOGLE_MEET_API_ORIGIN}/v2`;
7
+ const GOOGLE_MEET_URL_HOST = "meet.google.com";
8
+ const GOOGLE_MEET_API_HOST = "meet.googleapis.com";
9
+ const GOOGLE_MEET_MEDIA_SCOPE =
10
+ "https://www.googleapis.com/auth/meetings.conference.media.readonly";
11
+ const GOOGLE_MEET_SPACE_SCOPE = "https://www.googleapis.com/auth/meetings.space.readonly";
12
+ const GOOGLE_MEET_SPACE_CREATED_SCOPE = "https://www.googleapis.com/auth/meetings.space.created";
13
+ const GOOGLE_MEET_SPACE_SETTINGS_SCOPE = "https://www.googleapis.com/auth/meetings.space.settings";
14
+
15
+ export type GoogleMeetAccessType = "OPEN" | "TRUSTED" | "RESTRICTED";
16
+ export type GoogleMeetEntryPointAccess = "ALL" | "CREATOR_APP_ONLY";
17
+
18
+ export type GoogleMeetSpaceConfig = {
19
+ accessType?: GoogleMeetAccessType;
20
+ entryPointAccess?: GoogleMeetEntryPointAccess;
21
+ };
22
+
23
+ export type GoogleMeetSpace = {
24
+ name: string;
25
+ meetingCode?: string;
26
+ meetingUri?: string;
27
+ activeConference?: Record<string, unknown>;
28
+ config?: GoogleMeetSpaceConfig & Record<string, unknown>;
29
+ };
30
+
31
+ export type GoogleMeetPreflightReport = {
32
+ input: string;
33
+ resolvedSpaceName: string;
34
+ meetingCode?: string;
35
+ meetingUri?: string;
36
+ hasActiveConference: boolean;
37
+ previewAcknowledged: boolean;
38
+ tokenSource: "cached-access-token" | "refresh-token";
39
+ blockers: string[];
40
+ };
41
+
42
+ export type GoogleMeetCreateSpaceResult = {
43
+ space: GoogleMeetSpace;
44
+ meetingUri: string;
45
+ };
46
+
47
+ export type GoogleMeetEndActiveConferenceResult = {
48
+ space: string;
49
+ ended: true;
50
+ };
51
+
52
+ export type GoogleMeetConferenceRecord = {
53
+ name: string;
54
+ space?: string;
55
+ startTime?: string;
56
+ endTime?: string;
57
+ expireTime?: string;
58
+ };
59
+
60
+ type GoogleMeetParticipant = {
61
+ name: string;
62
+ earliestStartTime?: string;
63
+ latestEndTime?: string;
64
+ signedinUser?: {
65
+ user?: string;
66
+ displayName?: string;
67
+ };
68
+ anonymousUser?: {
69
+ displayName?: string;
70
+ };
71
+ phoneUser?: {
72
+ displayName?: string;
73
+ };
74
+ };
75
+
76
+ type GoogleMeetParticipantSession = {
77
+ name: string;
78
+ startTime?: string;
79
+ endTime?: string;
80
+ };
81
+
82
+ type GoogleMeetRecording = {
83
+ name: string;
84
+ startTime?: string;
85
+ endTime?: string;
86
+ driveDestination?: Record<string, unknown>;
87
+ };
88
+
89
+ type GoogleMeetTranscript = {
90
+ name: string;
91
+ startTime?: string;
92
+ endTime?: string;
93
+ docsDestination?: Record<string, unknown>;
94
+ documentText?: string;
95
+ documentTextError?: string;
96
+ };
97
+
98
+ type GoogleMeetTranscriptEntry = {
99
+ name: string;
100
+ participant?: string;
101
+ text?: string;
102
+ languageCode?: string;
103
+ startTime?: string;
104
+ endTime?: string;
105
+ };
106
+
107
+ type GoogleMeetTranscriptEntries = {
108
+ transcript: string;
109
+ entries: GoogleMeetTranscriptEntry[];
110
+ entriesError?: string;
111
+ };
112
+
113
+ type GoogleMeetSmartNote = {
114
+ name: string;
115
+ startTime?: string;
116
+ endTime?: string;
117
+ docsDestination?: Record<string, unknown>;
118
+ documentText?: string;
119
+ documentTextError?: string;
120
+ };
121
+
122
+ type GoogleMeetArtifactsEntry = {
123
+ conferenceRecord: GoogleMeetConferenceRecord;
124
+ participants: GoogleMeetParticipant[];
125
+ recordings: GoogleMeetRecording[];
126
+ transcripts: GoogleMeetTranscript[];
127
+ transcriptEntries: GoogleMeetTranscriptEntries[];
128
+ smartNotes: GoogleMeetSmartNote[];
129
+ smartNotesError?: string;
130
+ };
131
+
132
+ export type GoogleMeetArtifactsResult = {
133
+ input?: string;
134
+ space?: GoogleMeetSpace;
135
+ conferenceRecords: GoogleMeetConferenceRecord[];
136
+ artifacts: GoogleMeetArtifactsEntry[];
137
+ };
138
+
139
+ export type GoogleMeetLatestConferenceRecordResult = {
140
+ input: string;
141
+ space: GoogleMeetSpace;
142
+ conferenceRecord?: GoogleMeetConferenceRecord;
143
+ };
144
+
145
+ type GoogleMeetAttendanceRow = {
146
+ conferenceRecord: string;
147
+ participant: string;
148
+ participants?: string[];
149
+ displayName?: string;
150
+ user?: string;
151
+ earliestStartTime?: string;
152
+ latestEndTime?: string;
153
+ firstJoinTime?: string;
154
+ lastLeaveTime?: string;
155
+ durationMs?: number;
156
+ late?: boolean;
157
+ lateByMs?: number;
158
+ earlyLeave?: boolean;
159
+ earlyLeaveByMs?: number;
160
+ sessions: GoogleMeetParticipantSession[];
161
+ };
162
+
163
+ export type GoogleMeetAttendanceResult = {
164
+ input?: string;
165
+ space?: GoogleMeetSpace;
166
+ conferenceRecords: GoogleMeetConferenceRecord[];
167
+ attendance: GoogleMeetAttendanceRow[];
168
+ };
169
+
170
+ type GoogleMeetSmartNotesListResult = {
171
+ smartNotes: GoogleMeetSmartNote[];
172
+ smartNotesError?: string;
173
+ };
174
+
175
+ export function normalizeGoogleMeetSpaceName(input: string): string {
176
+ const trimmed = input.trim();
177
+ if (!trimmed) {
178
+ throw new Error("Meeting input is required");
179
+ }
180
+ if (trimmed.startsWith("spaces/")) {
181
+ const suffix = trimmed.slice("spaces/".length).trim();
182
+ if (!suffix) {
183
+ throw new Error("spaces/ input must include a meeting code or space id");
184
+ }
185
+ return `spaces/${suffix}`;
186
+ }
187
+ if (/^https?:\/\//i.test(trimmed)) {
188
+ const url = new URL(trimmed);
189
+ if (url.hostname !== GOOGLE_MEET_URL_HOST) {
190
+ throw new Error(`Expected a ${GOOGLE_MEET_URL_HOST} URL, received ${url.hostname}`);
191
+ }
192
+ const firstSegment = url.pathname
193
+ .split("/")
194
+ .map((segment) => segment.trim())
195
+ .find(Boolean);
196
+ if (!firstSegment) {
197
+ throw new Error("Google Meet URL did not include a meeting code");
198
+ }
199
+ return `spaces/${firstSegment}`;
200
+ }
201
+ return `spaces/${trimmed}`;
202
+ }
203
+
204
+ function encodeSpaceNameForPath(name: string): string {
205
+ return name.split("/").map(encodeURIComponent).join("/");
206
+ }
207
+
208
+ function encodeResourceNameForPath(name: string): string {
209
+ const trimmed = name.trim();
210
+ if (!trimmed) {
211
+ throw new Error("Google Meet resource name is required");
212
+ }
213
+ return trimmed.split("/").map(encodeURIComponent).join("/");
214
+ }
215
+
216
+ function normalizeConferenceRecordName(input: string): string {
217
+ const trimmed = input.trim();
218
+ if (!trimmed) {
219
+ throw new Error("Conference record is required");
220
+ }
221
+ return trimmed.startsWith("conferenceRecords/") ? trimmed : `conferenceRecords/${trimmed}`;
222
+ }
223
+
224
+ function appendQuery(
225
+ url: string,
226
+ query?: Record<string, string | number | boolean | undefined>,
227
+ ): string {
228
+ if (!query) {
229
+ return url;
230
+ }
231
+ const parsed = new URL(url);
232
+ for (const [key, value] of Object.entries(query)) {
233
+ if (value !== undefined) {
234
+ parsed.searchParams.set(key, String(value));
235
+ }
236
+ }
237
+ return parsed.toString();
238
+ }
239
+
240
+ function assertResourceArray<T extends { name?: string }>(
241
+ value: unknown,
242
+ key: string,
243
+ context: string,
244
+ ): T[] {
245
+ if (value === undefined) {
246
+ return [];
247
+ }
248
+ if (!Array.isArray(value)) {
249
+ throw new Error(`Google Meet ${context} response had non-array ${key}`);
250
+ }
251
+ const resources = value as T[];
252
+ for (const resource of resources) {
253
+ if (!resource.name?.trim()) {
254
+ throw new Error(`Google Meet ${context} response included a resource without name`);
255
+ }
256
+ }
257
+ return resources;
258
+ }
259
+
260
+ function getErrorMessage(error: unknown): string {
261
+ return error instanceof Error ? error.message : String(error);
262
+ }
263
+
264
+ async function fetchGoogleMeetJson<T>(params: {
265
+ accessToken: string;
266
+ path: string;
267
+ query?: Record<string, string | number | boolean | undefined>;
268
+ auditContext: string;
269
+ errorPrefix: string;
270
+ }): Promise<T> {
271
+ const { response, release } = await fetchWithSsrFGuard({
272
+ url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query),
273
+ init: {
274
+ headers: {
275
+ Authorization: `Bearer ${params.accessToken}`,
276
+ Accept: "application/json",
277
+ },
278
+ },
279
+ policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
280
+ auditContext: params.auditContext,
281
+ });
282
+ try {
283
+ if (!response.ok) {
284
+ const detail = await response.text();
285
+ throw await googleApiError({
286
+ response,
287
+ detail,
288
+ prefix: params.errorPrefix,
289
+ scopes: [GOOGLE_MEET_MEDIA_SCOPE],
290
+ });
291
+ }
292
+ return (await response.json()) as T;
293
+ } finally {
294
+ await release();
295
+ }
296
+ }
297
+
298
+ async function listGoogleMeetCollection<T extends { name?: string }>(params: {
299
+ accessToken: string;
300
+ path: string;
301
+ collectionKey: string;
302
+ query?: Record<string, string | number | boolean | undefined>;
303
+ maxItems?: number;
304
+ auditContext: string;
305
+ errorPrefix: string;
306
+ }): Promise<T[]> {
307
+ const items: T[] = [];
308
+ let pageToken: string | undefined;
309
+ do {
310
+ const payload = await fetchGoogleMeetJson<Record<string, unknown>>({
311
+ accessToken: params.accessToken,
312
+ path: params.path,
313
+ query: { ...params.query, pageToken },
314
+ auditContext: params.auditContext,
315
+ errorPrefix: params.errorPrefix,
316
+ });
317
+ const pageItems = assertResourceArray<T>(
318
+ payload[params.collectionKey],
319
+ params.collectionKey,
320
+ params.errorPrefix,
321
+ );
322
+ const remaining =
323
+ typeof params.maxItems === "number" ? Math.max(params.maxItems - items.length, 0) : undefined;
324
+ items.push(...(remaining === undefined ? pageItems : pageItems.slice(0, remaining)));
325
+ if (typeof params.maxItems === "number" && items.length >= params.maxItems) {
326
+ break;
327
+ }
328
+ pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined;
329
+ } while (pageToken);
330
+ return items;
331
+ }
332
+
333
+ export async function fetchGoogleMeetSpace(params: {
334
+ accessToken: string;
335
+ meeting: string;
336
+ }): Promise<GoogleMeetSpace> {
337
+ const name = normalizeGoogleMeetSpaceName(params.meeting);
338
+ const { response, release } = await fetchWithSsrFGuard({
339
+ url: `${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(name)}`,
340
+ init: {
341
+ headers: {
342
+ Authorization: `Bearer ${params.accessToken}`,
343
+ Accept: "application/json",
344
+ },
345
+ },
346
+ policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
347
+ auditContext: "google-meet.spaces.get",
348
+ });
349
+ try {
350
+ if (!response.ok) {
351
+ const detail = await response.text();
352
+ throw await googleApiError({
353
+ response,
354
+ detail,
355
+ prefix: "Google Meet spaces.get",
356
+ scopes: [GOOGLE_MEET_SPACE_SCOPE],
357
+ });
358
+ }
359
+ const payload = (await response.json()) as GoogleMeetSpace;
360
+ if (!payload.name?.trim()) {
361
+ throw new Error("Google Meet spaces.get response was missing name");
362
+ }
363
+ return payload;
364
+ } finally {
365
+ await release();
366
+ }
367
+ }
368
+
369
+ export async function createGoogleMeetSpace(params: {
370
+ accessToken: string;
371
+ config?: GoogleMeetSpaceConfig;
372
+ }): Promise<GoogleMeetCreateSpaceResult> {
373
+ const body =
374
+ params.config && Object.keys(params.config).length > 0
375
+ ? JSON.stringify({ config: params.config })
376
+ : "{}";
377
+ const { response, release } = await fetchWithSsrFGuard({
378
+ url: `${GOOGLE_MEET_API_BASE_URL}/spaces`,
379
+ init: {
380
+ method: "POST",
381
+ headers: {
382
+ Authorization: `Bearer ${params.accessToken}`,
383
+ Accept: "application/json",
384
+ "Content-Type": "application/json",
385
+ },
386
+ body,
387
+ },
388
+ policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
389
+ auditContext: "google-meet.spaces.create",
390
+ });
391
+ try {
392
+ if (!response.ok) {
393
+ const detail = await response.text();
394
+ throw await googleApiError({
395
+ response,
396
+ detail,
397
+ prefix: "Google Meet spaces.create",
398
+ scopes:
399
+ params.config && Object.keys(params.config).length > 0
400
+ ? [GOOGLE_MEET_SPACE_CREATED_SCOPE, GOOGLE_MEET_SPACE_SETTINGS_SCOPE]
401
+ : [GOOGLE_MEET_SPACE_CREATED_SCOPE],
402
+ });
403
+ }
404
+ const payload = (await response.json()) as GoogleMeetSpace;
405
+ if (!payload.name?.trim()) {
406
+ throw new Error("Google Meet spaces.create response was missing name");
407
+ }
408
+ const meetingUri = payload.meetingUri?.trim();
409
+ if (!meetingUri) {
410
+ throw new Error("Google Meet spaces.create response was missing meetingUri");
411
+ }
412
+ return { space: payload, meetingUri };
413
+ } finally {
414
+ await release();
415
+ }
416
+ }
417
+
418
+ export async function endGoogleMeetActiveConference(params: {
419
+ accessToken: string;
420
+ meeting: string;
421
+ }): Promise<GoogleMeetEndActiveConferenceResult> {
422
+ const resolved = await fetchGoogleMeetSpace({
423
+ accessToken: params.accessToken,
424
+ meeting: params.meeting,
425
+ });
426
+ const space = resolved.name;
427
+ const { response, release } = await fetchWithSsrFGuard({
428
+ url: `${GOOGLE_MEET_API_BASE_URL}/${encodeSpaceNameForPath(space)}:endActiveConference`,
429
+ init: {
430
+ method: "POST",
431
+ headers: {
432
+ Authorization: `Bearer ${params.accessToken}`,
433
+ Accept: "application/json",
434
+ "Content-Type": "application/json",
435
+ },
436
+ body: "{}",
437
+ },
438
+ policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
439
+ auditContext: "google-meet.spaces.endActiveConference",
440
+ });
441
+ try {
442
+ if (!response.ok) {
443
+ const detail = await response.text();
444
+ throw await googleApiError({
445
+ response,
446
+ detail,
447
+ prefix: "Google Meet spaces.endActiveConference",
448
+ scopes: [GOOGLE_MEET_SPACE_CREATED_SCOPE],
449
+ });
450
+ }
451
+ return { space, ended: true };
452
+ } finally {
453
+ await release();
454
+ }
455
+ }
456
+
457
+ async function fetchGoogleMeetConferenceRecord(params: {
458
+ accessToken: string;
459
+ conferenceRecord: string;
460
+ }): Promise<GoogleMeetConferenceRecord> {
461
+ const name = normalizeConferenceRecordName(params.conferenceRecord);
462
+ const payload = await fetchGoogleMeetJson<GoogleMeetConferenceRecord>({
463
+ accessToken: params.accessToken,
464
+ path: encodeResourceNameForPath(name),
465
+ auditContext: "google-meet.conferenceRecords.get",
466
+ errorPrefix: "Google Meet conferenceRecords.get",
467
+ });
468
+ if (!payload.name?.trim()) {
469
+ throw new Error("Google Meet conferenceRecords.get response was missing name");
470
+ }
471
+ return payload;
472
+ }
473
+
474
+ async function listGoogleMeetConferenceRecords(params: {
475
+ accessToken: string;
476
+ meeting?: string;
477
+ pageSize?: number;
478
+ maxItems?: number;
479
+ }): Promise<GoogleMeetConferenceRecord[]> {
480
+ const filter = params.meeting
481
+ ? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"`
482
+ : undefined;
483
+ return listGoogleMeetCollection<GoogleMeetConferenceRecord>({
484
+ accessToken: params.accessToken,
485
+ path: "conferenceRecords",
486
+ collectionKey: "conferenceRecords",
487
+ query: {
488
+ pageSize: params.pageSize,
489
+ filter,
490
+ },
491
+ maxItems: params.maxItems,
492
+ auditContext: "google-meet.conferenceRecords.list",
493
+ errorPrefix: "Google Meet conferenceRecords.list",
494
+ });
495
+ }
496
+
497
+ export async function fetchLatestGoogleMeetConferenceRecord(params: {
498
+ accessToken: string;
499
+ meeting: string;
500
+ }): Promise<GoogleMeetLatestConferenceRecordResult> {
501
+ const space = await fetchGoogleMeetSpace({
502
+ accessToken: params.accessToken,
503
+ meeting: params.meeting,
504
+ });
505
+ const [conferenceRecord] = await listGoogleMeetConferenceRecords({
506
+ accessToken: params.accessToken,
507
+ meeting: space.name,
508
+ pageSize: 1,
509
+ maxItems: 1,
510
+ });
511
+ return {
512
+ input: params.meeting,
513
+ space,
514
+ ...(conferenceRecord ? { conferenceRecord } : {}),
515
+ };
516
+ }
517
+
518
+ async function listGoogleMeetParticipants(params: {
519
+ accessToken: string;
520
+ conferenceRecord: string;
521
+ pageSize?: number;
522
+ }): Promise<GoogleMeetParticipant[]> {
523
+ const parent = normalizeConferenceRecordName(params.conferenceRecord);
524
+ return listGoogleMeetCollection<GoogleMeetParticipant>({
525
+ accessToken: params.accessToken,
526
+ path: `${encodeResourceNameForPath(parent)}/participants`,
527
+ collectionKey: "participants",
528
+ query: { pageSize: params.pageSize },
529
+ auditContext: "google-meet.conferenceRecords.participants.list",
530
+ errorPrefix: "Google Meet conferenceRecords.participants.list",
531
+ });
532
+ }
533
+
534
+ async function listGoogleMeetParticipantSessions(params: {
535
+ accessToken: string;
536
+ participant: string;
537
+ pageSize?: number;
538
+ }): Promise<GoogleMeetParticipantSession[]> {
539
+ return listGoogleMeetCollection<GoogleMeetParticipantSession>({
540
+ accessToken: params.accessToken,
541
+ path: `${encodeResourceNameForPath(params.participant)}/participantSessions`,
542
+ collectionKey: "participantSessions",
543
+ query: { pageSize: params.pageSize },
544
+ auditContext: "google-meet.conferenceRecords.participants.participantSessions.list",
545
+ errorPrefix: "Google Meet conferenceRecords.participants.participantSessions.list",
546
+ });
547
+ }
548
+
549
+ async function listGoogleMeetRecordings(params: {
550
+ accessToken: string;
551
+ conferenceRecord: string;
552
+ pageSize?: number;
553
+ }): Promise<GoogleMeetRecording[]> {
554
+ const parent = normalizeConferenceRecordName(params.conferenceRecord);
555
+ return listGoogleMeetCollection<GoogleMeetRecording>({
556
+ accessToken: params.accessToken,
557
+ path: `${encodeResourceNameForPath(parent)}/recordings`,
558
+ collectionKey: "recordings",
559
+ query: { pageSize: params.pageSize },
560
+ auditContext: "google-meet.conferenceRecords.recordings.list",
561
+ errorPrefix: "Google Meet conferenceRecords.recordings.list",
562
+ });
563
+ }
564
+
565
+ async function listGoogleMeetTranscripts(params: {
566
+ accessToken: string;
567
+ conferenceRecord: string;
568
+ pageSize?: number;
569
+ }): Promise<GoogleMeetTranscript[]> {
570
+ const parent = normalizeConferenceRecordName(params.conferenceRecord);
571
+ return listGoogleMeetCollection<GoogleMeetTranscript>({
572
+ accessToken: params.accessToken,
573
+ path: `${encodeResourceNameForPath(parent)}/transcripts`,
574
+ collectionKey: "transcripts",
575
+ query: { pageSize: params.pageSize },
576
+ auditContext: "google-meet.conferenceRecords.transcripts.list",
577
+ errorPrefix: "Google Meet conferenceRecords.transcripts.list",
578
+ });
579
+ }
580
+
581
+ async function listGoogleMeetTranscriptEntries(params: {
582
+ accessToken: string;
583
+ transcript: string;
584
+ pageSize?: number;
585
+ }): Promise<GoogleMeetTranscriptEntry[]> {
586
+ return listGoogleMeetCollection<GoogleMeetTranscriptEntry>({
587
+ accessToken: params.accessToken,
588
+ path: `${encodeResourceNameForPath(params.transcript)}/entries`,
589
+ collectionKey: "transcriptEntries",
590
+ query: { pageSize: params.pageSize },
591
+ auditContext: "google-meet.conferenceRecords.transcripts.entries.list",
592
+ errorPrefix: "Google Meet conferenceRecords.transcripts.entries.list",
593
+ });
594
+ }
595
+
596
+ async function listGoogleMeetSmartNotes(params: {
597
+ accessToken: string;
598
+ conferenceRecord: string;
599
+ pageSize?: number;
600
+ }): Promise<GoogleMeetSmartNote[]> {
601
+ const parent = normalizeConferenceRecordName(params.conferenceRecord);
602
+ return listGoogleMeetCollection<GoogleMeetSmartNote>({
603
+ accessToken: params.accessToken,
604
+ path: `${encodeResourceNameForPath(parent)}/smartNotes`,
605
+ collectionKey: "smartNotes",
606
+ query: { pageSize: params.pageSize },
607
+ auditContext: "google-meet.conferenceRecords.smartNotes.list",
608
+ errorPrefix: "Google Meet conferenceRecords.smartNotes.list",
609
+ });
610
+ }
611
+
612
+ function getParticipantDisplayName(participant: GoogleMeetParticipant): string | undefined {
613
+ return (
614
+ participant.signedinUser?.displayName ??
615
+ participant.anonymousUser?.displayName ??
616
+ participant.phoneUser?.displayName
617
+ );
618
+ }
619
+
620
+ function getParticipantUser(participant: GoogleMeetParticipant): string | undefined {
621
+ return participant.signedinUser?.user;
622
+ }
623
+
624
+ function getDocsDestinationDocumentId(
625
+ destination: Record<string, unknown> | undefined,
626
+ ): string | undefined {
627
+ return (
628
+ extractGoogleDriveDocumentId(destination?.document) ??
629
+ extractGoogleDriveDocumentId(destination?.documentId) ??
630
+ extractGoogleDriveDocumentId(destination?.file)
631
+ );
632
+ }
633
+
634
+ async function attachDocumentText<T extends { docsDestination?: Record<string, unknown> }>(params: {
635
+ accessToken: string;
636
+ resource: T;
637
+ }): Promise<T & { documentText?: string; documentTextError?: string }> {
638
+ const documentId = getDocsDestinationDocumentId(params.resource.docsDestination);
639
+ if (!documentId) {
640
+ return params.resource;
641
+ }
642
+ try {
643
+ return {
644
+ ...params.resource,
645
+ documentText: await exportGoogleDriveDocumentText({
646
+ accessToken: params.accessToken,
647
+ documentId,
648
+ }),
649
+ };
650
+ } catch (error) {
651
+ return {
652
+ ...params.resource,
653
+ documentTextError: getErrorMessage(error),
654
+ };
655
+ }
656
+ }
657
+
658
+ function parseGoogleMeetTimestamp(value: string | undefined): number | undefined {
659
+ if (!value?.trim()) {
660
+ return undefined;
661
+ }
662
+ const parsed = Date.parse(value);
663
+ return Number.isFinite(parsed) ? parsed : undefined;
664
+ }
665
+
666
+ function isoFromMs(value: number | undefined): string | undefined {
667
+ return typeof value === "number" && Number.isFinite(value)
668
+ ? new Date(value).toISOString()
669
+ : undefined;
670
+ }
671
+
672
+ function minTimestamp(values: Array<string | undefined>): string | undefined {
673
+ const parsed = values
674
+ .map(parseGoogleMeetTimestamp)
675
+ .filter((value): value is number => typeof value === "number");
676
+ return parsed.length > 0 ? isoFromMs(Math.min(...parsed)) : undefined;
677
+ }
678
+
679
+ function maxTimestamp(values: Array<string | undefined>): string | undefined {
680
+ const parsed = values
681
+ .map(parseGoogleMeetTimestamp)
682
+ .filter((value): value is number => typeof value === "number");
683
+ return parsed.length > 0 ? isoFromMs(Math.max(...parsed)) : undefined;
684
+ }
685
+
686
+ function sumSessionDurationMs(
687
+ sessions: GoogleMeetParticipantSession[],
688
+ fallbackStart?: string,
689
+ fallbackEnd?: string,
690
+ ): number | undefined {
691
+ const sessionTotal = sessions.reduce((total, session) => {
692
+ const startMs = parseGoogleMeetTimestamp(session.startTime);
693
+ const endMs = parseGoogleMeetTimestamp(session.endTime);
694
+ return startMs !== undefined && endMs !== undefined && endMs > startMs
695
+ ? total + (endMs - startMs)
696
+ : total;
697
+ }, 0);
698
+ if (sessionTotal > 0) {
699
+ return sessionTotal;
700
+ }
701
+ const startMs = parseGoogleMeetTimestamp(fallbackStart);
702
+ const endMs = parseGoogleMeetTimestamp(fallbackEnd);
703
+ return startMs !== undefined && endMs !== undefined && endMs > startMs
704
+ ? endMs - startMs
705
+ : undefined;
706
+ }
707
+
708
+ function attendanceMergeKey(row: GoogleMeetAttendanceRow): string {
709
+ return (row.user ?? row.displayName ?? row.participant).trim().toLocaleLowerCase();
710
+ }
711
+
712
+ function sortSessions(sessions: GoogleMeetParticipantSession[]): GoogleMeetParticipantSession[] {
713
+ return sessions.toSorted(
714
+ (left, right) =>
715
+ (parseGoogleMeetTimestamp(left.startTime) ?? 0) -
716
+ (parseGoogleMeetTimestamp(right.startTime) ?? 0),
717
+ );
718
+ }
719
+
720
+ function decorateAttendanceRow(
721
+ row: GoogleMeetAttendanceRow,
722
+ conferenceRecord: GoogleMeetConferenceRecord,
723
+ params: { lateAfterMinutes?: number; earlyBeforeMinutes?: number },
724
+ ): GoogleMeetAttendanceRow {
725
+ const sessions = sortSessions(row.sessions);
726
+ const firstJoinTime = minTimestamp([
727
+ row.earliestStartTime,
728
+ ...sessions.map((session) => session.startTime),
729
+ ]);
730
+ const lastLeaveTime = maxTimestamp([
731
+ row.latestEndTime,
732
+ ...sessions.map((session) => session.endTime),
733
+ ]);
734
+ const durationMs = sumSessionDurationMs(sessions, firstJoinTime, lastLeaveTime);
735
+ const conferenceStartMs = parseGoogleMeetTimestamp(conferenceRecord.startTime);
736
+ const conferenceEndMs = parseGoogleMeetTimestamp(conferenceRecord.endTime);
737
+ const firstJoinMs = parseGoogleMeetTimestamp(firstJoinTime);
738
+ const lastLeaveMs = parseGoogleMeetTimestamp(lastLeaveTime);
739
+ const lateGraceMs = (params.lateAfterMinutes ?? 5) * 60_000;
740
+ const earlyGraceMs = (params.earlyBeforeMinutes ?? 5) * 60_000;
741
+ const lateByMs =
742
+ conferenceStartMs !== undefined && firstJoinMs !== undefined
743
+ ? Math.max(firstJoinMs - conferenceStartMs, 0)
744
+ : undefined;
745
+ const earlyLeaveByMs =
746
+ conferenceEndMs !== undefined && lastLeaveMs !== undefined
747
+ ? Math.max(conferenceEndMs - lastLeaveMs, 0)
748
+ : undefined;
749
+ const decorated: GoogleMeetAttendanceRow = {
750
+ ...row,
751
+ sessions,
752
+ participants: row.participants ?? [row.participant],
753
+ };
754
+ decorated.earliestStartTime = firstJoinTime ?? row.earliestStartTime;
755
+ decorated.latestEndTime = lastLeaveTime ?? row.latestEndTime;
756
+ if (firstJoinTime) {
757
+ decorated.firstJoinTime = firstJoinTime;
758
+ }
759
+ if (lastLeaveTime) {
760
+ decorated.lastLeaveTime = lastLeaveTime;
761
+ }
762
+ if (durationMs !== undefined) {
763
+ decorated.durationMs = durationMs;
764
+ }
765
+ if (lateByMs !== undefined) {
766
+ decorated.late = lateByMs > lateGraceMs;
767
+ if (decorated.late) {
768
+ decorated.lateByMs = lateByMs;
769
+ }
770
+ }
771
+ if (earlyLeaveByMs !== undefined) {
772
+ decorated.earlyLeave = earlyLeaveByMs > earlyGraceMs;
773
+ if (decorated.earlyLeave) {
774
+ decorated.earlyLeaveByMs = earlyLeaveByMs;
775
+ }
776
+ }
777
+ return decorated;
778
+ }
779
+
780
+ function mergeAttendanceRows(
781
+ rows: GoogleMeetAttendanceRow[],
782
+ conferenceRecord: GoogleMeetConferenceRecord,
783
+ params: {
784
+ mergeDuplicateParticipants?: boolean;
785
+ lateAfterMinutes?: number;
786
+ earlyBeforeMinutes?: number;
787
+ },
788
+ ): GoogleMeetAttendanceRow[] {
789
+ if (params.mergeDuplicateParticipants === false) {
790
+ return rows.map((row) => decorateAttendanceRow(row, conferenceRecord, params));
791
+ }
792
+ const grouped = new Map<string, GoogleMeetAttendanceRow>();
793
+ for (const row of rows) {
794
+ const key = attendanceMergeKey(row);
795
+ const existing = grouped.get(key);
796
+ if (!existing) {
797
+ grouped.set(key, { ...row, participants: [row.participant] });
798
+ continue;
799
+ }
800
+ existing.participants = [
801
+ ...new Set([...(existing.participants ?? [existing.participant]), row.participant]),
802
+ ];
803
+ existing.sessions.push(...row.sessions);
804
+ existing.displayName ??= row.displayName;
805
+ existing.user ??= row.user;
806
+ existing.earliestStartTime = minTimestamp([existing.earliestStartTime, row.earliestStartTime]);
807
+ existing.latestEndTime = maxTimestamp([existing.latestEndTime, row.latestEndTime]);
808
+ }
809
+ return [...grouped.values()].map((row) => decorateAttendanceRow(row, conferenceRecord, params));
810
+ }
811
+
812
+ async function resolveConferenceRecordQuery(params: {
813
+ accessToken: string;
814
+ meeting?: string;
815
+ conferenceRecord?: string;
816
+ pageSize?: number;
817
+ allConferenceRecords?: boolean;
818
+ }): Promise<{
819
+ input?: string;
820
+ space?: GoogleMeetSpace;
821
+ conferenceRecords: GoogleMeetConferenceRecord[];
822
+ }> {
823
+ if (params.conferenceRecord?.trim()) {
824
+ const conferenceRecord = await fetchGoogleMeetConferenceRecord({
825
+ accessToken: params.accessToken,
826
+ conferenceRecord: params.conferenceRecord,
827
+ });
828
+ return {
829
+ input: params.conferenceRecord.trim(),
830
+ conferenceRecords: [conferenceRecord],
831
+ };
832
+ }
833
+ if (!params.meeting?.trim()) {
834
+ throw new Error("Meeting input or conference record is required");
835
+ }
836
+ const space = await fetchGoogleMeetSpace({
837
+ accessToken: params.accessToken,
838
+ meeting: params.meeting,
839
+ });
840
+ const conferenceRecords = await listGoogleMeetConferenceRecords({
841
+ accessToken: params.accessToken,
842
+ meeting: space.name,
843
+ pageSize: params.allConferenceRecords ? params.pageSize : 1,
844
+ maxItems: params.allConferenceRecords ? undefined : 1,
845
+ });
846
+ return {
847
+ input: params.meeting,
848
+ space,
849
+ conferenceRecords,
850
+ };
851
+ }
852
+
853
+ export async function fetchGoogleMeetArtifacts(params: {
854
+ accessToken: string;
855
+ meeting?: string;
856
+ conferenceRecord?: string;
857
+ pageSize?: number;
858
+ includeTranscriptEntries?: boolean;
859
+ allConferenceRecords?: boolean;
860
+ includeDocumentBodies?: boolean;
861
+ }): Promise<GoogleMeetArtifactsResult> {
862
+ const resolved = await resolveConferenceRecordQuery(params);
863
+ const artifacts = await Promise.all(
864
+ resolved.conferenceRecords.map(async (conferenceRecord) => {
865
+ const [participants, recordings, transcripts, smartNotesResult] = await Promise.all([
866
+ listGoogleMeetParticipants({
867
+ accessToken: params.accessToken,
868
+ conferenceRecord: conferenceRecord.name,
869
+ pageSize: params.pageSize,
870
+ }),
871
+ listGoogleMeetRecordings({
872
+ accessToken: params.accessToken,
873
+ conferenceRecord: conferenceRecord.name,
874
+ pageSize: params.pageSize,
875
+ }),
876
+ listGoogleMeetTranscripts({
877
+ accessToken: params.accessToken,
878
+ conferenceRecord: conferenceRecord.name,
879
+ pageSize: params.pageSize,
880
+ }),
881
+ listGoogleMeetSmartNotes({
882
+ accessToken: params.accessToken,
883
+ conferenceRecord: conferenceRecord.name,
884
+ pageSize: params.pageSize,
885
+ })
886
+ .then<GoogleMeetSmartNotesListResult>((smartNotes) => ({ smartNotes }))
887
+ .catch((error: unknown) => ({
888
+ smartNotes: [],
889
+ smartNotesError: getErrorMessage(error),
890
+ })),
891
+ ]);
892
+ const transcriptEntries =
893
+ params.includeTranscriptEntries === false
894
+ ? []
895
+ : await Promise.all(
896
+ transcripts.map(async (transcript) => {
897
+ try {
898
+ return {
899
+ transcript: transcript.name,
900
+ entries: await listGoogleMeetTranscriptEntries({
901
+ accessToken: params.accessToken,
902
+ transcript: transcript.name,
903
+ pageSize: params.pageSize,
904
+ }),
905
+ };
906
+ } catch (error) {
907
+ return {
908
+ transcript: transcript.name,
909
+ entries: [],
910
+ entriesError: getErrorMessage(error),
911
+ };
912
+ }
913
+ }),
914
+ );
915
+ const transcriptsWithText =
916
+ params.includeDocumentBodies === true
917
+ ? await Promise.all(
918
+ transcripts.map((transcript) =>
919
+ attachDocumentText({
920
+ accessToken: params.accessToken,
921
+ resource: transcript,
922
+ }),
923
+ ),
924
+ )
925
+ : transcripts;
926
+ const smartNotesWithText =
927
+ params.includeDocumentBodies === true
928
+ ? await Promise.all(
929
+ smartNotesResult.smartNotes.map((smartNote) =>
930
+ attachDocumentText({
931
+ accessToken: params.accessToken,
932
+ resource: smartNote,
933
+ }),
934
+ ),
935
+ )
936
+ : smartNotesResult.smartNotes;
937
+ return {
938
+ conferenceRecord,
939
+ participants,
940
+ recordings,
941
+ transcripts: transcriptsWithText,
942
+ transcriptEntries,
943
+ smartNotes: smartNotesWithText,
944
+ ...(smartNotesResult.smartNotesError
945
+ ? { smartNotesError: smartNotesResult.smartNotesError }
946
+ : {}),
947
+ };
948
+ }),
949
+ );
950
+ return {
951
+ input: resolved.input,
952
+ space: resolved.space,
953
+ conferenceRecords: resolved.conferenceRecords,
954
+ artifacts,
955
+ };
956
+ }
957
+
958
+ export async function fetchGoogleMeetAttendance(params: {
959
+ accessToken: string;
960
+ meeting?: string;
961
+ conferenceRecord?: string;
962
+ pageSize?: number;
963
+ allConferenceRecords?: boolean;
964
+ mergeDuplicateParticipants?: boolean;
965
+ lateAfterMinutes?: number;
966
+ earlyBeforeMinutes?: number;
967
+ }): Promise<GoogleMeetAttendanceResult> {
968
+ const resolved = await resolveConferenceRecordQuery(params);
969
+ const nestedRows = await Promise.all(
970
+ resolved.conferenceRecords.map(async (conferenceRecord) => {
971
+ const participants = await listGoogleMeetParticipants({
972
+ accessToken: params.accessToken,
973
+ conferenceRecord: conferenceRecord.name,
974
+ pageSize: params.pageSize,
975
+ });
976
+ const rows = await Promise.all(
977
+ participants.map(async (participant) => ({
978
+ conferenceRecord: conferenceRecord.name,
979
+ participant: participant.name,
980
+ displayName: getParticipantDisplayName(participant),
981
+ user: getParticipantUser(participant),
982
+ earliestStartTime: participant.earliestStartTime,
983
+ latestEndTime: participant.latestEndTime,
984
+ sessions: await listGoogleMeetParticipantSessions({
985
+ accessToken: params.accessToken,
986
+ participant: participant.name,
987
+ pageSize: params.pageSize,
988
+ }),
989
+ })),
990
+ );
991
+ return mergeAttendanceRows(rows, conferenceRecord, params);
992
+ }),
993
+ );
994
+ return {
995
+ input: resolved.input,
996
+ space: resolved.space,
997
+ conferenceRecords: resolved.conferenceRecords,
998
+ attendance: nestedRows.flat(),
999
+ };
1000
+ }
1001
+
1002
+ export function buildGoogleMeetPreflightReport(params: {
1003
+ input: string;
1004
+ space: GoogleMeetSpace;
1005
+ previewAcknowledged: boolean;
1006
+ tokenSource: "cached-access-token" | "refresh-token";
1007
+ }): GoogleMeetPreflightReport {
1008
+ const blockers: string[] = [];
1009
+ if (!params.previewAcknowledged) {
1010
+ blockers.push(
1011
+ "Set preview.enrollmentAcknowledged=true after confirming your Cloud project, OAuth principal, and meeting participants are enrolled in the Google Workspace Developer Preview Program.",
1012
+ );
1013
+ }
1014
+ return {
1015
+ input: params.input,
1016
+ resolvedSpaceName: params.space.name,
1017
+ meetingCode: params.space.meetingCode,
1018
+ meetingUri: params.space.meetingUri,
1019
+ hasActiveConference: Boolean(params.space.activeConference),
1020
+ previewAcknowledged: params.previewAcknowledged,
1021
+ tokenSource: params.tokenSource,
1022
+ blockers,
1023
+ };
1024
+ }