mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
feat(google-meet): add calendar export attendance workflows
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
195
extensions/google-meet/src/calendar.ts
Normal file
195
extensions/google-meet/src/calendar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user