feat(google-meet): add calendar export attendance workflows

This commit is contained in:
Peter Steinberger
2026-04-25 08:14:23 +01:00
parent 9577de2da7
commit d3595d7c3f
8 changed files with 1075 additions and 40 deletions

View File

@@ -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(
{

View File

@@ -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<string, unknown>): boolean {
return raw.today === true || Boolean(normalizeOptionalString(raw.event));
}
async function resolveMeetingFromParams(params: {
config: GoogleMeetConfig;
raw: Record<string, unknown>;
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<string, unknown>) {
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,
}),
);
}

View File

@@ -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<string, string | number | boolean | undefined>) {
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<GoogleMeetCalendarLookupResult> {
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();
}
}

View File

@@ -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 {

View File

@@ -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<GoogleMeetRuntime["status"]>): 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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--today", "Find a Meet link on today's calendar")
.option("--event <query>", "Find a matching calendar event with a Meet link")
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
@@ -1002,8 +1213,15 @@ export function registerGoogleMeetCli(params: {
.option("--expires-at <ms>", "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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--conference-record <name>", "Conference record name or id")
.option("--today", "Find a Meet link on today's calendar")
.option("--event <query>", "Find a matching calendar event with a Meet link")
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--conference-record <name>", "Conference record name or id")
.option("--today", "Find a Meet link on today's calendar")
.option("--event <query>", "Find a matching calendar event with a Meet link")
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
@@ -1089,18 +1328,34 @@ export function registerGoogleMeetCli(params: {
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "Max resources per Meet API page")
.option("--all-conference-records", "Fetch every conference record for --meeting")
.option("--format <format>", "Output format: summary or markdown", "summary")
.option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows")
.option("--late-after-minutes <n>", "Mark participants late after this many minutes", "5")
.option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
.option("--format <format>", "Output format: summary, markdown, or csv", "summary")
.option("--output <path>", "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 <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--conference-record <name>", "Conference record name or id")
.option("--today", "Find a Meet link on today's calendar")
.option("--event <query>", "Find a matching calendar event with a Meet link")
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "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 <n>", "Mark participants late after this many minutes", "5")
.option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
.option("--output <dir>", "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")

View File

@@ -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>): 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>): 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<string, GoogleMeetAttendanceRow>();
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<GoogleMeetAttendanceResult> {
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 {

View File

@@ -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({

View File

@@ -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 = {