From d3595d7c3f3f31b4911055b54e891eace638d5d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:14:23 +0100 Subject: [PATCH] feat(google-meet): add calendar export attendance workflows --- extensions/google-meet/index.test.ts | 161 ++++++++++ extensions/google-meet/index.ts | 115 +++++-- extensions/google-meet/src/calendar.ts | 195 ++++++++++++ extensions/google-meet/src/cli.test.ts | 106 ++++++- extensions/google-meet/src/cli.ts | 368 +++++++++++++++++++++-- extensions/google-meet/src/meet.ts | 168 ++++++++++- extensions/google-meet/src/oauth.test.ts | 1 + extensions/google-meet/src/oauth.ts | 1 + 8 files changed, 1075 insertions(+), 40 deletions(-) create mode 100644 extensions/google-meet/src/calendar.ts diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 3d054839787..b36f95a1c7d 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -3,6 +3,10 @@ import { PassThrough, Writable } from "node:stream"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; +import { + extractGoogleMeetUriFromCalendarEvent, + findGoogleMeetCalendarEvent, +} from "./src/calendar.js"; import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js"; import { buildGoogleMeetPreflightReport, @@ -82,6 +86,19 @@ function stubMeetArtifactsApi() { meetingUri: "https://meet.google.com/abc-defg-hij", }); } + if (url.pathname === "/calendar/v3/calendars/primary/events") { + return jsonResponse({ + items: [ + { + id: "event-1", + summary: "Project sync", + hangoutLink: "https://meet.google.com/abc-defg-hij", + start: { dateTime: "2026-04-25T10:00:00Z" }, + end: { dateTime: "2026-04-25T10:30:00Z" }, + }, + ], + }); + } if (url.pathname === "/v2/conferenceRecords") { return jsonResponse({ conferenceRecords: [ @@ -356,6 +373,51 @@ describe("google-meet plugin", () => { ); }); + it("finds Google Meet links from Calendar events", async () => { + const fetchMock = stubMeetArtifactsApi(); + + expect( + extractGoogleMeetUriFromCalendarEvent({ + conferenceData: { + entryPoints: [ + { + entryPointType: "video", + uri: "https://meet.google.com/abc-defg-hij", + }, + ], + }, + }), + ).toBe("https://meet.google.com/abc-defg-hij"); + await expect( + findGoogleMeetCalendarEvent({ + accessToken: "token", + now: new Date("2026-04-25T09:50:00Z"), + timeMin: "2026-04-25T00:00:00Z", + timeMax: "2026-04-26T00:00:00Z", + }), + ).resolves.toMatchObject({ + calendarId: "primary", + meetingUri: "https://meet.google.com/abc-defg-hij", + event: { summary: "Project sync" }, + }); + const calendarCall = fetchMock.mock.calls.find(([input]) => { + const url = requestUrl(input); + return url.pathname === "/calendar/v3/calendars/primary/events"; + }); + if (!calendarCall) { + throw new Error("Expected Calendar events.list fetch call"); + } + const url = requestUrl(calendarCall[0]); + expect(url.searchParams.get("singleEvents")).toBe("true"); + expect(url.searchParams.get("orderBy")).toBe("startTime"); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + policy: { allowedHostnames: ["www.googleapis.com"] }, + auditContext: "google-meet.calendar.events.list", + }), + ); + }); + it("fetches Meet spaces without percent-encoding the spaces path separator", async () => { const fetchMock = vi.fn(async () => { return new Response( @@ -565,6 +627,83 @@ describe("google-meet plugin", () => { ); }); + it("merges duplicate attendance participants and annotates timing", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T11:00:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + signedinUser: { user: "users/alice", displayName: "Alice" }, + }, + { + name: "conferenceRecords/rec-1/participants/p2", + signedinUser: { user: "users/alice", displayName: "Alice" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + startTime: "2026-04-25T10:10:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p2/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p2/participantSessions/s1", + startTime: "2026-04-25T10:40:00Z", + endTime: "2026-04-25T10:50:00Z", + }, + ], + }); + } + return new Response(`unexpected ${url.pathname}`, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchGoogleMeetAttendance({ + accessToken: "token", + conferenceRecord: "rec-1", + }), + ).resolves.toMatchObject({ + attendance: [ + { + displayName: "Alice", + participants: [ + "conferenceRecords/rec-1/participants/p1", + "conferenceRecords/rec-1/participants/p2", + ], + firstJoinTime: "2026-04-25T10:10:00.000Z", + lastLeaveTime: "2026-04-25T10:50:00.000Z", + durationMs: 1_800_000, + late: true, + earlyLeave: true, + sessions: [ + { name: expect.stringContaining("/p1/") }, + { name: expect.stringContaining("/p2/") }, + ], + }, + ], + }); + }); + it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => { expect( buildGoogleMeetPreflightReport({ @@ -693,6 +832,28 @@ describe("google-meet plugin", () => { expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" }); }); + it("reports the latest conference record from today's calendar through the tool", async () => { + stubMeetArtifactsApi(); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { calendarEvent?: { meetingUri?: string } } }>; + }; + + const result = await tool.execute("id", { + action: "latest", + accessToken: "token", + expiresAt: Date.now() + 120_000, + today: true, + }); + + expect(result.details.calendarEvent).toMatchObject({ + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + }); + it("fails setup status when the configured Chrome node is not connected", async () => { const { tools } = setup( { diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index c5e3db94e57..2343cb28a44 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -3,6 +3,12 @@ import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-r import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; +import { + buildGoogleMeetCalendarDayWindow, + findGoogleMeetCalendarEvent, + type GoogleMeetCalendarLookupResult, +} from "./src/calendar.js"; +import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig, type GoogleMeetConfig, @@ -177,6 +183,17 @@ const GoogleMeetToolSchema = Type.Object({ sessionId: Type.Optional(Type.String({ description: "Meet session ID" })), message: Type.Optional(Type.String({ description: "Realtime instructions to speak now" })), meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })), + today: Type.Optional( + Type.Boolean({ + description: "For latest, artifacts, or attendance, find a Meet link on today's calendar.", + }), + ), + event: Type.Optional( + Type.String({ + description: "For latest, artifacts, or attendance, find a matching Calendar event.", + }), + ), + calendarId: Type.Optional(Type.String({ description: "Calendar id for today/event lookup" })), conferenceRecord: Type.Optional( Type.String({ description: "Meet conferenceRecords/{id} resource name or id" }), ), @@ -190,6 +207,15 @@ const GoogleMeetToolSchema = Type.Object({ "For artifacts or attendance with meeting input, fetch all conference records instead of only the latest.", }), ), + mergeDuplicateParticipants: Type.Optional( + Type.Boolean({ description: "For attendance, merge duplicate participant resources." }), + ), + lateAfterMinutes: Type.Optional( + Type.Number({ description: "For attendance, mark participants late after this many minutes." }), + ), + earlyBeforeMinutes: Type.Optional( + Type.Number({ description: "For attendance, mark early leavers before this many minutes." }), + ), accessToken: Type.Optional(Type.String({ description: "Access token override" })), refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })), clientId: Type.Optional(Type.String({ description: "OAuth client id override" })), @@ -274,14 +300,40 @@ async function resolveGoogleMeetTokenFromParams( }); } +function wantsCalendarLookup(raw: Record): boolean { + return raw.today === true || Boolean(normalizeOptionalString(raw.event)); +} + +async function resolveMeetingFromParams(params: { + config: GoogleMeetConfig; + raw: Record; + accessToken: string; +}): Promise<{ meeting: string; calendarEvent?: GoogleMeetCalendarLookupResult }> { + if (wantsCalendarLookup(params.raw)) { + const window = params.raw.today === true ? buildGoogleMeetCalendarDayWindow() : {}; + const calendarEvent = await findGoogleMeetCalendarEvent({ + accessToken: params.accessToken, + calendarId: normalizeOptionalString(params.raw.calendarId), + eventQuery: normalizeOptionalString(params.raw.event), + ...window, + }); + return { meeting: calendarEvent.meetingUri, calendarEvent }; + } + return { meeting: resolveMeetingInput(params.config, params.raw.meeting) }; +} + async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { - const meeting = resolveMeetingInput(config, raw.meeting); const token = await resolveGoogleMeetTokenFromParams(config, raw); + const { meeting, calendarEvent } = await resolveMeetingFromParams({ + config, + raw, + accessToken: token.accessToken, + }); const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting, }); - return { meeting, token, space }; + return { meeting, token, space, calendarEvent }; } async function resolveArtifactQueryFromParams( @@ -290,17 +342,27 @@ async function resolveArtifactQueryFromParams( ) { const meeting = normalizeOptionalString(raw.meeting) ?? config.defaults.meeting; const conferenceRecord = normalizeOptionalString(raw.conferenceRecord); - if (!meeting && !conferenceRecord) { - throw new Error("Meeting input or conferenceRecord required"); - } const token = await resolveGoogleMeetTokenFromParams(config, raw); + const resolvedMeeting: { meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult } = + conferenceRecord + ? { meeting } + : wantsCalendarLookup(raw) + ? await resolveMeetingFromParams({ config, raw, accessToken: token.accessToken }) + : { meeting }; + if (!resolvedMeeting.meeting && !conferenceRecord) { + throw new Error("Meeting input, calendar lookup, or conferenceRecord required"); + } return { token, - meeting, + meeting: resolvedMeeting.meeting, + calendarEvent: resolvedMeeting.calendarEvent, conferenceRecord, pageSize: resolveOptionalPositiveInteger(raw.pageSize), includeTranscriptEntries: raw.includeTranscriptEntries !== false, allConferenceRecords: raw.includeAllConferenceRecords === true, + mergeDuplicateParticipants: raw.mergeDuplicateParticipants !== false, + lateAfterMinutes: resolveOptionalPositiveInteger(raw.lateAfterMinutes), + earlyBeforeMinutes: resolveOptionalPositiveInteger(raw.earlyBeforeMinutes), }; } @@ -419,15 +481,19 @@ export default definePluginEntry({ async ({ params, respond }: GatewayRequestHandlerOptions) => { try { const raw = asParamRecord(params); - const meeting = resolveMeetingInput(config, raw.meeting); const token = await resolveGoogleMeetTokenFromParams(config, raw); - respond( - true, - await fetchLatestGoogleMeetConferenceRecord({ + const resolved = await resolveMeetingFromParams({ + config, + raw, + accessToken: token.accessToken, + }); + respond(true, { + ...(await fetchLatestGoogleMeetConferenceRecord({ accessToken: token.accessToken, - meeting, - }), - ); + meeting: resolved.meeting, + })), + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), + }); } catch (err) { sendError(respond, err); } @@ -471,6 +537,9 @@ export default definePluginEntry({ conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + lateAfterMinutes: resolved.lateAfterMinutes, + earlyBeforeMinutes: resolved.earlyBeforeMinutes, }), ); } catch (err) { @@ -612,14 +681,19 @@ export default definePluginEntry({ ); } case "latest": { - const meeting = resolveMeetingInput(config, raw.meeting); const token = await resolveGoogleMeetTokenFromParams(config, raw); - return json( - await fetchLatestGoogleMeetConferenceRecord({ + const resolved = await resolveMeetingFromParams({ + config, + raw, + accessToken: token.accessToken, + }); + return json({ + ...(await fetchLatestGoogleMeetConferenceRecord({ accessToken: token.accessToken, - meeting, - }), - ); + meeting: resolved.meeting, + })), + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), + }); } case "artifacts": { const resolved = await resolveArtifactQueryFromParams(config, raw); @@ -643,6 +717,9 @@ export default definePluginEntry({ conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + lateAfterMinutes: resolved.lateAfterMinutes, + earlyBeforeMinutes: resolved.earlyBeforeMinutes, }), ); } diff --git a/extensions/google-meet/src/calendar.ts b/extensions/google-meet/src/calendar.ts new file mode 100644 index 00000000000..04b99d4bb2b --- /dev/null +++ b/extensions/google-meet/src/calendar.ts @@ -0,0 +1,195 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; + +const GOOGLE_CALENDAR_API_BASE_URL = "https://www.googleapis.com/calendar/v3"; +const GOOGLE_CALENDAR_API_HOST = "www.googleapis.com"; +const GOOGLE_MEET_URL_HOST = "meet.google.com"; + +type GoogleCalendarEventDate = { + date?: string; + dateTime?: string; + timeZone?: string; +}; + +type GoogleCalendarConferenceEntryPoint = { + entryPointType?: string; + uri?: string; + label?: string; +}; + +export type GoogleMeetCalendarEvent = { + id?: string; + summary?: string; + description?: string; + location?: string; + status?: string; + htmlLink?: string; + hangoutLink?: string; + start?: GoogleCalendarEventDate; + end?: GoogleCalendarEventDate; + conferenceData?: { + conferenceId?: string; + conferenceSolution?: { + key?: { type?: string }; + name?: string; + }; + entryPoints?: GoogleCalendarConferenceEntryPoint[]; + }; +}; + +export type GoogleMeetCalendarLookupResult = { + calendarId: string; + event: GoogleMeetCalendarEvent; + meetingUri: string; +}; + +function appendQuery(url: string, query: Record) { + const parsed = new URL(url); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + parsed.searchParams.set(key, String(value)); + } + } + return parsed.toString(); +} + +function isGoogleMeetUri(value: string | undefined): value is string { + if (!value?.trim()) { + return false; + } + try { + return new URL(value).hostname === GOOGLE_MEET_URL_HOST; + } catch { + return false; + } +} + +function extractGoogleMeetUriFromText(value: string | undefined): string | undefined { + const match = value?.match(/https:\/\/meet\.google\.com\/[a-z0-9-]+/i); + return match?.[0]; +} + +export function extractGoogleMeetUriFromCalendarEvent( + event: GoogleMeetCalendarEvent, +): string | undefined { + if (isGoogleMeetUri(event.hangoutLink)) { + return event.hangoutLink; + } + const entryPoints = event.conferenceData?.entryPoints ?? []; + const videoEntry = entryPoints.find( + (entry) => entry.entryPointType === "video" && isGoogleMeetUri(entry.uri), + ); + if (videoEntry?.uri) { + return videoEntry.uri; + } + const meetEntry = entryPoints.find((entry) => isGoogleMeetUri(entry.uri)); + if (meetEntry?.uri) { + return meetEntry.uri; + } + return ( + extractGoogleMeetUriFromText(event.location) ?? extractGoogleMeetUriFromText(event.description) + ); +} + +export function buildGoogleMeetCalendarDayWindow(now = new Date()): { + timeMin: string; + timeMax: string; +} { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(start.getDate() + 1); + return { timeMin: start.toISOString(), timeMax: end.toISOString() }; +} + +function parseCalendarEventTime(value: GoogleCalendarEventDate | undefined): number | undefined { + const raw = value?.dateTime ?? value?.date; + if (!raw) { + return undefined; + } + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function rankCalendarEvent(event: GoogleMeetCalendarEvent, nowMs: number): number { + const startMs = parseCalendarEventTime(event.start) ?? Number.POSITIVE_INFINITY; + const endMs = parseCalendarEventTime(event.end) ?? startMs; + if (startMs <= nowMs && endMs >= nowMs) { + return 0; + } + if (startMs > nowMs) { + return startMs - nowMs; + } + return nowMs - startMs + 30 * 24 * 60 * 60 * 1000; +} + +function chooseBestMeetCalendarEvent( + events: GoogleMeetCalendarEvent[], + now: Date, +): GoogleMeetCalendarLookupResult["event"] | undefined { + const nowMs = now.getTime(); + return events + .filter((event) => event.status !== "cancelled") + .filter((event) => extractGoogleMeetUriFromCalendarEvent(event)) + .toSorted((left, right) => rankCalendarEvent(left, nowMs) - rankCalendarEvent(right, nowMs))[0]; +} + +export async function findGoogleMeetCalendarEvent(params: { + accessToken: string; + calendarId?: string; + eventQuery?: string; + timeMin?: string; + timeMax?: string; + maxResults?: number; + now?: Date; +}): Promise { + const calendarId = params.calendarId?.trim() || "primary"; + const now = params.now ?? new Date(); + const defaultTimeMax = new Date(now); + defaultTimeMax.setDate(defaultTimeMax.getDate() + 7); + const { response, release } = await fetchWithSsrFGuard({ + url: appendQuery( + `${GOOGLE_CALENDAR_API_BASE_URL}/calendars/${encodeURIComponent(calendarId)}/events`, + { + maxResults: params.maxResults ?? 50, + orderBy: "startTime", + q: params.eventQuery?.trim() || undefined, + showDeleted: false, + singleEvents: true, + timeMin: params.timeMin ?? now.toISOString(), + timeMax: params.timeMax ?? defaultTimeMax.toISOString(), + }, + ), + init: { + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + }, + }, + policy: { allowedHostnames: [GOOGLE_CALENDAR_API_HOST] }, + auditContext: "google-meet.calendar.events.list", + }); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Google Calendar events.list failed (${response.status}): ${detail}`); + } + const payload = (await response.json()) as { items?: unknown }; + if (payload.items !== undefined && !Array.isArray(payload.items)) { + throw new Error("Google Calendar events.list response had non-array items"); + } + const event = chooseBestMeetCalendarEvent( + (payload.items ?? []) as GoogleMeetCalendarEvent[], + now, + ); + if (!event) { + throw new Error("No Google Calendar event with a Google Meet link matched the query"); + } + const meetingUri = extractGoogleMeetUriFromCalendarEvent(event); + if (!meetingUri) { + throw new Error("Matched Google Calendar event did not include a Google Meet link"); + } + return { calendarId, event, meetingUri }; + } finally { + await release(); + } +} diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index bf4b2390570..a2244c705ab 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -67,6 +67,19 @@ function stubMeetArtifactsApi() { meetingUri: "https://meet.google.com/abc-defg-hij", }); } + if (url.pathname === "/calendar/v3/calendars/primary/events") { + return jsonResponse({ + items: [ + { + id: "event-1", + summary: "Project sync", + hangoutLink: "https://meet.google.com/abc-defg-hij", + start: { dateTime: "2026-04-25T10:00:00Z" }, + end: { dateTime: "2026-04-25T10:30:00Z" }, + }, + ], + }); + } if (url.pathname === "/v2/conferenceRecords") { return jsonResponse({ conferenceRecords: [ @@ -92,7 +105,7 @@ function stubMeetArtifactsApi() { participants: [ { name: "conferenceRecords/rec-1/participants/p1", - signedinUser: { displayName: "Alice" }, + signedinUser: { user: "users/alice", displayName: "Alice" }, }, ], }); @@ -317,6 +330,30 @@ describe("google-meet CLI", () => { } }); + it("prints the latest conference record from today's calendar", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--today", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("calendar event: Project sync"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + it("prints markdown artifact and attendance output", async () => { stubMeetArtifactsApi(); const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); @@ -379,6 +416,73 @@ describe("google-meet CLI", () => { } }); + it("prints CSV attendance output", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "csv", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("conferenceRecord,displayName,user"); + expect(stdout.output()).toContain("conferenceRecords/rec-1,Alice,users/alice"); + } finally { + stdout.restore(); + } + }); + + it("writes an export bundle", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-")); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "export", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--output", + tempDir, + ], + { from: "user" }, + ); + expect(stdout.output()).toContain(`export: ${tempDir}`); + expect(readFileSync(path.join(tempDir, "summary.md"), "utf8")).toContain( + "# Google Meet Artifacts", + ); + expect(readFileSync(path.join(tempDir, "attendance.csv"), "utf8")).toContain( + "conferenceRecords/rec-1,Alice,users/alice", + ); + expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain( + "Hello from the transcript.", + ); + expect(JSON.parse(readFileSync(path.join(tempDir, "artifacts.json"), "utf8"))).toMatchObject({ + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + }); + } finally { + stdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("prints human-readable session doctor output", async () => { const stdout = captureStdout(); try { diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 0e752f1600d..d995e71cba5 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -1,7 +1,13 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { createInterface } from "node:readline/promises"; import { format } from "node:util"; import type { Command } from "commander"; +import { + buildGoogleMeetCalendarDayWindow, + findGoogleMeetCalendarEvent, + type GoogleMeetCalendarLookupResult, +} from "./calendar.js"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; import { buildGoogleMeetPreflightReport, @@ -43,6 +49,9 @@ type OAuthLoginOptions = { type ResolveSpaceOptions = { meeting?: string; + today?: boolean; + event?: string; + calendar?: string; accessToken?: string; refreshToken?: string; clientId?: string; @@ -56,7 +65,10 @@ type MeetArtifactOptions = ResolveSpaceOptions & { pageSize?: string; transcriptEntries?: boolean; allConferenceRecords?: boolean; - format?: "summary" | "markdown"; + mergeDuplicates?: boolean; + lateAfterMinutes?: string; + earlyBeforeMinutes?: string; + format?: "summary" | "markdown" | "csv"; output?: string; }; @@ -151,6 +163,19 @@ function formatOptional(value: unknown): string { return typeof value === "string" && value.trim() ? value : "n/a"; } +function formatDuration(value: number | undefined): string { + if (value === undefined) { + return "n/a"; + } + const totalSeconds = Math.round(value / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours > 0 + ? `${hours}h ${minutes.toString().padStart(2, "0")}m` + : `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +} + function writeDoctorStatus(status: ReturnType): void { if (!status.found) { writeStdoutLine("Google Meet session: not found"); @@ -393,6 +418,25 @@ function resolveMeetingInput(config: GoogleMeetConfig, value?: string): string { return meeting; } +function resolveOAuthTokenOptions( + config: GoogleMeetConfig, + options: ResolveSpaceOptions, +): { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; +} { + return { + clientId: options.clientId?.trim() || config.oauth.clientId, + clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret, + refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken, + accessToken: options.accessToken?.trim() || config.oauth.accessToken, + expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, + }; +} + function resolveTokenOptions( config: GoogleMeetConfig, options: ResolveSpaceOptions, @@ -406,14 +450,53 @@ function resolveTokenOptions( } { return { meeting: resolveMeetingInput(config, options.meeting), - clientId: options.clientId?.trim() || config.oauth.clientId, - clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret, - refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken, - accessToken: options.accessToken?.trim() || config.oauth.accessToken, - expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt, + ...resolveOAuthTokenOptions(config, options), }; } +function hasCalendarLookupOptions(options: ResolveSpaceOptions): boolean { + return Boolean(options.today || options.event?.trim()); +} + +async function resolveCalendarMeetingInput(params: { + accessToken: string; + options: ResolveSpaceOptions; +}): Promise<{ meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult }> { + if (!hasCalendarLookupOptions(params.options)) { + return {}; + } + const window = params.options.today ? buildGoogleMeetCalendarDayWindow() : {}; + const calendarEvent = await findGoogleMeetCalendarEvent({ + accessToken: params.accessToken, + calendarId: params.options.calendar, + eventQuery: params.options.event, + ...window, + }); + return { meeting: calendarEvent.meetingUri, calendarEvent }; +} + +async function resolveMeetingForToken(params: { + config: GoogleMeetConfig; + options: ResolveSpaceOptions; + accessToken: string; + configuredMeeting?: string; +}): Promise<{ meeting: string; calendarEvent?: GoogleMeetCalendarLookupResult }> { + const calendarMeeting = await resolveCalendarMeetingInput({ + accessToken: params.accessToken, + options: params.options, + }); + const meeting = + calendarMeeting.meeting ?? params.configuredMeeting ?? params.config.defaults.meeting; + if (!meeting) { + throw new Error( + "Meeting input is required. Pass --meeting, --today, --event, or configure defaults.meeting.", + ); + } + return calendarMeeting.calendarEvent + ? { meeting, calendarEvent: calendarMeeting.calendarEvent } + : { meeting }; +} + function resolveCreateTokenOptions( config: GoogleMeetConfig, options: CreateOptions, @@ -447,12 +530,15 @@ function resolveArtifactTokenOptions( pageSize?: number; includeTranscriptEntries?: boolean; allConferenceRecords?: boolean; + mergeDuplicateParticipants?: boolean; + lateAfterMinutes?: number; + earlyBeforeMinutes?: number; } { const meeting = options.meeting?.trim() || config.defaults.meeting; const conferenceRecord = options.conferenceRecord?.trim(); - if (!meeting && !conferenceRecord) { + if (!meeting && !conferenceRecord && !hasCalendarLookupOptions(options)) { throw new Error( - "Meeting input or conference record is required. Pass --meeting, --conference-record, or configure defaults.meeting.", + "Meeting input or conference record is required. Pass --meeting, --today, --event, --conference-record, or configure defaults.meeting.", ); } return { @@ -466,6 +552,9 @@ function resolveArtifactTokenOptions( pageSize: parseOptionalNumber(options.pageSize), includeTranscriptEntries: options.transcriptEntries !== false, allConferenceRecords: Boolean(options.allConferenceRecords), + mergeDuplicateParticipants: options.mergeDuplicates !== false, + lateAfterMinutes: parseOptionalNumber(options.lateAfterMinutes), + earlyBeforeMinutes: parseOptionalNumber(options.earlyBeforeMinutes), }; } @@ -538,8 +627,12 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { writeStdoutLine("participant: %s", identity); writeStdoutLine("record: %s", row.conferenceRecord); writeStdoutLine("resource: %s", row.participant); - writeStdoutLine("first joined: %s", formatOptional(row.earliestStartTime)); - writeStdoutLine("last left: %s", formatOptional(row.latestEndTime)); + writeStdoutLine("participants merged: %d", row.participants?.length ?? 1); + writeStdoutLine("first joined: %s", formatOptional(row.firstJoinTime ?? row.earliestStartTime)); + writeStdoutLine("last left: %s", formatOptional(row.lastLeaveTime ?? row.latestEndTime)); + writeStdoutLine("duration: %s", formatDuration(row.durationMs)); + writeStdoutLine("late: %s", row.late ? formatDuration(row.lateByMs) : "no"); + writeStdoutLine("early leave: %s", row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"); writeStdoutLine("sessions: %d", row.sessions.length); for (const session of row.sessions) { writeStdoutLine( @@ -666,8 +759,21 @@ function renderAttendanceMarkdown(result: GoogleMeetAttendanceResult): string { pushMarkdownLine(lines, `## ${formatMarkdownIdentity(row)}`); pushMarkdownLine(lines, `Record: ${row.conferenceRecord}`); pushMarkdownLine(lines, `Resource: ${row.participant}`); - pushMarkdownLine(lines, `First joined: ${formatMarkdownOptional(row.earliestStartTime)}`); - pushMarkdownLine(lines, `Last left: ${formatMarkdownOptional(row.latestEndTime)}`); + pushMarkdownLine(lines, `Participants merged: ${row.participants?.length ?? 1}`); + pushMarkdownLine( + lines, + `First joined: ${formatMarkdownOptional(row.firstJoinTime ?? row.earliestStartTime)}`, + ); + pushMarkdownLine( + lines, + `Last left: ${formatMarkdownOptional(row.lastLeaveTime ?? row.latestEndTime)}`, + ); + pushMarkdownLine(lines, `Duration: ${formatDuration(row.durationMs)}`); + pushMarkdownLine(lines, `Late: ${row.late ? formatDuration(row.lateByMs) : "no"}`); + pushMarkdownLine( + lines, + `Early leave: ${row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"}`, + ); pushMarkdownLine(lines, `Sessions: ${row.sessions.length}`); for (const session of row.sessions) { pushMarkdownLine( @@ -681,6 +787,108 @@ function renderAttendanceMarkdown(result: GoogleMeetAttendanceResult): string { return `${lines.join("\n")}\n`; } +function csvCell(value: unknown): string { + const text = + value === undefined || value === null + ? "" + : typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function renderAttendanceCsv(result: GoogleMeetAttendanceResult): string { + const rows: unknown[][] = [ + [ + "conferenceRecord", + "displayName", + "user", + "participants", + "firstJoined", + "lastLeft", + "durationMs", + "sessions", + "late", + "lateByMs", + "earlyLeave", + "earlyLeaveByMs", + ], + ]; + for (const row of result.attendance) { + rows.push([ + row.conferenceRecord, + row.displayName ?? "", + row.user ?? "", + (row.participants ?? [row.participant]).join(";"), + row.firstJoinTime ?? row.earliestStartTime ?? "", + row.lastLeaveTime ?? row.latestEndTime ?? "", + row.durationMs ?? "", + row.sessions.length, + row.late ?? "", + row.lateByMs ?? "", + row.earlyLeave ?? "", + row.earlyLeaveByMs ?? "", + ]); + } + return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`; +} + +function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string { + const lines: string[] = ["# Google Meet Transcript"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + for (const entry of result.artifacts) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`); + if (entry.transcriptEntries.length === 0) { + pushMarkdownLine(lines, "_No transcript entries._"); + continue; + } + for (const transcriptEntries of entry.transcriptEntries) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `### ${transcriptEntries.transcript}`); + if (transcriptEntries.entriesError) { + pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`); + continue; + } + for (const transcriptEntry of transcriptEntries.entries) { + const speaker = transcriptEntry.participant ?? "unknown"; + const time = transcriptEntry.startTime ? ` [${transcriptEntry.startTime}]` : ""; + pushMarkdownLine(lines, `- ${speaker}${time}: ${transcriptEntry.text ?? ""}`); + } + } + } + return `${lines.join("\n")}\n`; +} + +function defaultExportDirectory(): string { + return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`; +} + +async function writeMeetExportBundle(params: { + outputDir?: string; + artifacts: GoogleMeetArtifactsResult; + attendance: GoogleMeetAttendanceResult; +}): Promise<{ outputDir: string; files: string[] }> { + const outputDir = params.outputDir?.trim() || defaultExportDirectory(); + await mkdir(outputDir, { recursive: true }); + const files = [ + { + name: "summary.md", + content: `${renderArtifactsMarkdown(params.artifacts)}\n${renderAttendanceMarkdown(params.attendance)}`, + }, + { name: "attendance.csv", content: renderAttendanceCsv(params.attendance) }, + { name: "transcript.md", content: renderTranscriptMarkdown(params.artifacts) }, + { name: "artifacts.json", content: `${JSON.stringify(params.artifacts, null, 2)}\n` }, + { name: "attendance.json", content: `${JSON.stringify(params.attendance, null, 2)}\n` }, + ]; + for (const file of files) { + await writeFile(path.join(outputDir, file.name), file.content, "utf8"); + } + return { outputDir, files: files.map((file) => path.join(outputDir, file.name)) }; +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -995,6 +1203,9 @@ export function registerGoogleMeetCli(params: { .command("latest") .description("Find the latest Meet conference record for a meeting") .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") .option("--access-token ", "Access token override") .option("--refresh-token ", "Refresh token override") .option("--client-id ", "OAuth client id override") @@ -1002,8 +1213,15 @@ export function registerGoogleMeetCli(params: { .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--json", "Print JSON output", false) .action(async (options: ResolveSpaceOptions) => { - const resolved = resolveTokenOptions(params.config, options); - const token = await resolveGoogleMeetAccessToken(resolved); + const token = await resolveGoogleMeetAccessToken( + resolveOAuthTokenOptions(params.config, options), + ); + const resolved = await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: options.meeting?.trim(), + }); const result = await fetchLatestGoogleMeetConferenceRecord({ accessToken: token.accessToken, meeting: resolved.meeting, @@ -1011,10 +1229,15 @@ export function registerGoogleMeetCli(params: { if (options.json) { writeStdoutJson({ ...result, + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", }); return; } + if (resolved.calendarEvent) { + writeStdoutLine("calendar event: %s", resolved.calendarEvent.event.summary ?? "untitled"); + writeStdoutLine("calendar meet: %s", resolved.calendarEvent.meetingUri); + } writeLatestConferenceRecordSummary(result); writeStdoutLine( "token source: %s", @@ -1027,6 +1250,9 @@ export function registerGoogleMeetCli(params: { .description("List Meet conference records and available participant/artifact metadata") .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") .option("--access-token ", "Access token override") .option("--refresh-token ", "Refresh token override") .option("--client-id ", "OAuth client id override") @@ -1041,9 +1267,19 @@ export function registerGoogleMeetCli(params: { .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); const token = await resolveGoogleMeetAccessToken(resolved); + const meeting = resolved.conferenceRecord + ? resolved.meeting + : ( + await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }) + ).meeting; const result = await fetchGoogleMeetArtifacts({ accessToken: token.accessToken, - meeting: resolved.meeting, + meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, includeTranscriptEntries: resolved.includeTranscriptEntries, @@ -1082,6 +1318,9 @@ export function registerGoogleMeetCli(params: { .description("List Meet participants and participant sessions") .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") .option("--access-token ", "Access token override") .option("--refresh-token ", "Refresh token override") .option("--client-id ", "OAuth client id override") @@ -1089,18 +1328,34 @@ export function registerGoogleMeetCli(params: { .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") .option("--all-conference-records", "Fetch every conference record for --meeting") - .option("--format ", "Output format: summary or markdown", "summary") + .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") + .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") + .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") + .option("--format ", "Output format: summary, markdown, or csv", "summary") .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); const token = await resolveGoogleMeetAccessToken(resolved); + const meeting = resolved.conferenceRecord + ? resolved.meeting + : ( + await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }) + ).meeting; const result = await fetchGoogleMeetAttendance({ accessToken: token.accessToken, - meeting: resolved.meeting, + meeting, conferenceRecord: resolved.conferenceRecord, pageSize: resolved.pageSize, allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + lateAfterMinutes: resolved.lateAfterMinutes, + earlyBeforeMinutes: resolved.earlyBeforeMinutes, }); if (options.json) { await writeCliOutput( @@ -1120,8 +1375,12 @@ export function registerGoogleMeetCli(params: { await writeCliOutput(options, renderAttendanceMarkdown(result)); return; } + if (options.format === "csv") { + await writeCliOutput(options, renderAttendanceCsv(result)); + return; + } if (options.format && options.format !== "summary") { - throw new Error("Unsupported format. Expected summary or markdown."); + throw new Error("Unsupported format. Expected summary, markdown, or csv."); } writeAttendanceSummary(result); writeStdoutLine( @@ -1130,6 +1389,77 @@ export function registerGoogleMeetCli(params: { ); }); + root + .command("export") + .description("Write Meet artifacts, attendance, transcript, and raw JSON into a folder") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--page-size ", "Max resources per Meet API page") + .option("--all-conference-records", "Fetch every conference record for --meeting") + .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") + .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") + .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") + .option("--output ", "Output directory") + .option("--json", "Print JSON output", false) + .action(async (options: MeetArtifactOptions) => { + const resolved = resolveArtifactTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const meetingResult: { meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult } = + resolved.conferenceRecord + ? { meeting: resolved.meeting } + : await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }); + const artifacts = await fetchGoogleMeetArtifacts({ + accessToken: token.accessToken, + meeting: meetingResult.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, + }); + const attendance = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting: meetingResult.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + lateAfterMinutes: resolved.lateAfterMinutes, + earlyBeforeMinutes: resolved.earlyBeforeMinutes, + }); + const bundle = await writeMeetExportBundle({ + outputDir: options.output, + artifacts, + attendance, + }); + const payload = { + ...bundle, + ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeStdoutLine("export: %s", bundle.outputDir); + for (const file of bundle.files) { + writeStdoutLine("- %s", file); + } + }); + root .command("status") .argument("[session-id]", "Meet session ID") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index bea42bd33f0..6dfc4561dda 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -121,10 +121,18 @@ export type GoogleMeetLatestConferenceRecordResult = { export type GoogleMeetAttendanceRow = { conferenceRecord: string; participant: string; + participants?: string[]; displayName?: string; user?: string; earliestStartTime?: string; latestEndTime?: string; + firstJoinTime?: string; + lastLeaveTime?: string; + durationMs?: number; + late?: boolean; + lateByMs?: number; + earlyLeave?: boolean; + earlyLeaveByMs?: number; sessions: GoogleMeetParticipantSession[]; }; @@ -527,6 +535,160 @@ function getParticipantUser(participant: GoogleMeetParticipant): string | undefi return participant.signedinUser?.user; } +function parseGoogleMeetTimestamp(value: string | undefined): number | undefined { + if (!value?.trim()) { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function isoFromMs(value: number | undefined): string | undefined { + return typeof value === "number" && Number.isFinite(value) + ? new Date(value).toISOString() + : undefined; +} + +function minTimestamp(values: Array): string | undefined { + const parsed = values + .map(parseGoogleMeetTimestamp) + .filter((value): value is number => typeof value === "number"); + return parsed.length > 0 ? isoFromMs(Math.min(...parsed)) : undefined; +} + +function maxTimestamp(values: Array): string | undefined { + const parsed = values + .map(parseGoogleMeetTimestamp) + .filter((value): value is number => typeof value === "number"); + return parsed.length > 0 ? isoFromMs(Math.max(...parsed)) : undefined; +} + +function sumSessionDurationMs( + sessions: GoogleMeetParticipantSession[], + fallbackStart?: string, + fallbackEnd?: string, +): number | undefined { + const sessionTotal = sessions.reduce((total, session) => { + const startMs = parseGoogleMeetTimestamp(session.startTime); + const endMs = parseGoogleMeetTimestamp(session.endTime); + return startMs !== undefined && endMs !== undefined && endMs > startMs + ? total + (endMs - startMs) + : total; + }, 0); + if (sessionTotal > 0) { + return sessionTotal; + } + const startMs = parseGoogleMeetTimestamp(fallbackStart); + const endMs = parseGoogleMeetTimestamp(fallbackEnd); + return startMs !== undefined && endMs !== undefined && endMs > startMs + ? endMs - startMs + : undefined; +} + +function attendanceMergeKey(row: GoogleMeetAttendanceRow): string { + return (row.user ?? row.displayName ?? row.participant).trim().toLocaleLowerCase(); +} + +function sortSessions(sessions: GoogleMeetParticipantSession[]): GoogleMeetParticipantSession[] { + return sessions.toSorted( + (left, right) => + (parseGoogleMeetTimestamp(left.startTime) ?? 0) - + (parseGoogleMeetTimestamp(right.startTime) ?? 0), + ); +} + +function decorateAttendanceRow( + row: GoogleMeetAttendanceRow, + conferenceRecord: GoogleMeetConferenceRecord, + params: { lateAfterMinutes?: number; earlyBeforeMinutes?: number }, +): GoogleMeetAttendanceRow { + const sessions = sortSessions(row.sessions); + const firstJoinTime = minTimestamp([ + row.earliestStartTime, + ...sessions.map((session) => session.startTime), + ]); + const lastLeaveTime = maxTimestamp([ + row.latestEndTime, + ...sessions.map((session) => session.endTime), + ]); + const durationMs = sumSessionDurationMs(sessions, firstJoinTime, lastLeaveTime); + const conferenceStartMs = parseGoogleMeetTimestamp(conferenceRecord.startTime); + const conferenceEndMs = parseGoogleMeetTimestamp(conferenceRecord.endTime); + const firstJoinMs = parseGoogleMeetTimestamp(firstJoinTime); + const lastLeaveMs = parseGoogleMeetTimestamp(lastLeaveTime); + const lateGraceMs = (params.lateAfterMinutes ?? 5) * 60_000; + const earlyGraceMs = (params.earlyBeforeMinutes ?? 5) * 60_000; + const lateByMs = + conferenceStartMs !== undefined && firstJoinMs !== undefined + ? Math.max(firstJoinMs - conferenceStartMs, 0) + : undefined; + const earlyLeaveByMs = + conferenceEndMs !== undefined && lastLeaveMs !== undefined + ? Math.max(conferenceEndMs - lastLeaveMs, 0) + : undefined; + const decorated: GoogleMeetAttendanceRow = { + ...row, + sessions, + participants: row.participants ?? [row.participant], + }; + decorated.earliestStartTime = firstJoinTime ?? row.earliestStartTime; + decorated.latestEndTime = lastLeaveTime ?? row.latestEndTime; + if (firstJoinTime) { + decorated.firstJoinTime = firstJoinTime; + } + if (lastLeaveTime) { + decorated.lastLeaveTime = lastLeaveTime; + } + if (durationMs !== undefined) { + decorated.durationMs = durationMs; + } + if (lateByMs !== undefined) { + decorated.late = lateByMs > lateGraceMs; + if (decorated.late) { + decorated.lateByMs = lateByMs; + } + } + if (earlyLeaveByMs !== undefined) { + decorated.earlyLeave = earlyLeaveByMs > earlyGraceMs; + if (decorated.earlyLeave) { + decorated.earlyLeaveByMs = earlyLeaveByMs; + } + } + return decorated; +} + +function mergeAttendanceRows( + rows: GoogleMeetAttendanceRow[], + conferenceRecord: GoogleMeetConferenceRecord, + params: { + mergeDuplicateParticipants?: boolean; + lateAfterMinutes?: number; + earlyBeforeMinutes?: number; + }, +): GoogleMeetAttendanceRow[] { + if (params.mergeDuplicateParticipants === false) { + return rows.map((row) => decorateAttendanceRow(row, conferenceRecord, params)); + } + const grouped = new Map(); + for (const row of rows) { + const key = attendanceMergeKey(row); + const existing = grouped.get(key); + if (!existing) { + grouped.set(key, { ...row, participants: [row.participant] }); + continue; + } + existing.participants = [ + ...new Set([...(existing.participants ?? [existing.participant]), row.participant]), + ]; + existing.sessions.push(...row.sessions); + existing.displayName ??= row.displayName; + existing.user ??= row.user; + existing.earliestStartTime = minTimestamp([existing.earliestStartTime, row.earliestStartTime]); + existing.latestEndTime = maxTimestamp([existing.latestEndTime, row.latestEndTime]); + } + return [...grouped.values()].map((row) => decorateAttendanceRow(row, conferenceRecord, params)); +} + async function resolveConferenceRecordQuery(params: { accessToken: string; meeting?: string; @@ -656,6 +818,9 @@ export async function fetchGoogleMeetAttendance(params: { conferenceRecord?: string; pageSize?: number; allConferenceRecords?: boolean; + mergeDuplicateParticipants?: boolean; + lateAfterMinutes?: number; + earlyBeforeMinutes?: number; }): Promise { const resolved = await resolveConferenceRecordQuery(params); const nestedRows = await Promise.all( @@ -665,7 +830,7 @@ export async function fetchGoogleMeetAttendance(params: { conferenceRecord: conferenceRecord.name, pageSize: params.pageSize, }); - return Promise.all( + const rows = await Promise.all( participants.map(async (participant) => ({ conferenceRecord: conferenceRecord.name, participant: participant.name, @@ -680,6 +845,7 @@ export async function fetchGoogleMeetAttendance(params: { }), })), ); + return mergeAttendanceRows(rows, conferenceRecord, params); }), ); return { diff --git a/extensions/google-meet/src/oauth.test.ts b/extensions/google-meet/src/oauth.test.ts index debb989ef4c..85ddee52a08 100644 --- a/extensions/google-meet/src/oauth.test.ts +++ b/extensions/google-meet/src/oauth.test.ts @@ -24,6 +24,7 @@ describe("Google Meet OAuth", () => { expect(url.searchParams.get("access_type")).toBe("offline"); expect(url.searchParams.get("scope")).toContain("meetings.space.created"); expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly"); + expect(url.searchParams.get("scope")).toContain("calendar.events.readonly"); await expect( resolveGoogleMeetAccessToken({ diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index d5ece94f886..c3e1aa4e0d9 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -14,6 +14,7 @@ export const GOOGLE_MEET_SCOPES = [ "https://www.googleapis.com/auth/meetings.space.created", "https://www.googleapis.com/auth/meetings.space.readonly", "https://www.googleapis.com/auth/meetings.conference.media.readonly", + "https://www.googleapis.com/auth/calendar.events.readonly", ] as const; export type GoogleMeetOAuthTokens = {