diff --git a/CHANGELOG.md b/CHANGELOG.md index f544749c59d..3447be3bc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete. - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete. - Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete. - Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 727c67606f1..930299d406e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -480,6 +480,27 @@ Run preflight before media work: openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij ``` +List meeting artifacts and attendance after Meet has created conference records: + +```bash +openclaw googlemeet artifacts --meeting https://meet.google.com/abc-defg-hij +openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij +``` + +If you already know the conference record id, address it directly: + +```bash +openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json +openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json +``` + +`artifacts` returns conference record metadata plus participant, recording, +transcript, and smart-note resource metadata when Google exposes it for the +meeting. `attendance` expands participants into participant-session rows with +join/leave timestamps. These commands use the Meet REST API only; transcript or +smart-note document body download is intentionally out of scope because that +requires separate Google Docs/Drive access. + Create a fresh Meet space: ```bash diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 8c2e7ca20c4..d5d97f5d90a 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -9,6 +9,8 @@ import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/c import { buildGoogleMeetPreflightReport, createGoogleMeetSpace, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; @@ -64,6 +66,112 @@ function setup( return setupGoogleMeetPlugin(plugin, config, options); } +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function requestUrl(input: RequestInfo | URL): URL { + if (typeof input === "string") { + return new URL(input); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} + +function stubMeetArtifactsApi() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/v2/conferenceRecords") { + return jsonResponse({ + conferenceRecords: [ + { + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + earliestStartTime: "2026-04-25T10:00:00Z", + latestEndTime: "2026-04-25T10:30:00Z", + 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:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { + return jsonResponse({ + recordings: [ + { + name: "conferenceRecords/rec-1/recordings/r1", + driveDestination: { file: "drive/file-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { + return jsonResponse({ + transcripts: [ + { + name: "conferenceRecords/rec-1/transcripts/t1", + docsDestination: { document: "docs/doc-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { + return jsonResponse({ + smartNotes: [ + { + name: "conferenceRecords/rec-1/smartNotes/sn1", + docsDestination: { document: "docs/doc-2" }, + }, + ], + }); + } + return new Response(`unexpected ${url.pathname}`, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + type TestBridgeProcess = { stdin?: { write(chunk: unknown): unknown } | null; stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; @@ -218,6 +326,8 @@ describe("google-meet plugin", () => { "setup_status", "resolve_space", "preflight", + "artifacts", + "attendance", "recover_current_tab", "leave", "speak", @@ -310,6 +420,82 @@ describe("google-meet plugin", () => { ); }); + it("lists Meet artifact metadata for conference records", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchGoogleMeetArtifacts({ + accessToken: "token", + meeting: "abc-defg-hij", + pageSize: 2, + }), + ).resolves.toMatchObject({ + input: "abc-defg-hij", + space: { name: "spaces/abc-defg-hij" }, + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + conferenceRecord: { name: "conferenceRecords/rec-1" }, + participants: [{ name: "conferenceRecords/rec-1/participants/p1" }], + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + }); + + const listCall = fetchMock.mock.calls.find(([input]) => { + const url = requestUrl(input); + return url.pathname === "/v2/conferenceRecords"; + }); + if (!listCall) { + throw new Error("Expected conferenceRecords.list fetch call"); + } + const listUrl = requestUrl(listCall[0]); + expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); + expect(listUrl.searchParams.get("pageSize")).toBe("2"); + expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2", + auditContext: "google-meet.conferenceRecords.smartNotes.list", + }), + ); + }); + + it("lists Meet attendance rows with participant sessions", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchGoogleMeetAttendance({ + accessToken: "token", + conferenceRecord: "rec-1", + pageSize: 3, + }), + ).resolves.toMatchObject({ + input: "rec-1", + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + attendance: [ + { + conferenceRecord: "conferenceRecords/rec-1", + participant: "conferenceRecords/rec-1/participants/p1", + displayName: "Alice", + user: "users/alice", + sessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + }, + ], + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://meet.googleapis.com/v2/conferenceRecords/rec-1", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token" }), + }), + ); + }); + it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => { expect( buildGoogleMeetPreflightReport({ @@ -454,6 +640,27 @@ describe("google-meet plugin", () => { expect(result.details.ok).toBe(true); }); + it("reports attendance through the tool", async () => { + stubMeetArtifactsApi(); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { attendance?: Array<{ displayName?: string }> } }>; + }; + + const result = await tool.execute("id", { + action: "attendance", + accessToken: "token", + expiresAt: Date.now() + 120_000, + conferenceRecord: "rec-1", + pageSize: 3, + }); + + expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]); + }); + it("fails setup status when the configured Chrome node is not connected", async () => { const { tools } = setup( { @@ -630,6 +837,81 @@ describe("google-meet plugin", () => { } }); + it("CLI artifacts prints JSON output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--json", + ], + { from: "user" }, + ); + expect(JSON.parse(stdout.output())).toMatchObject({ + conferenceRecords: [{ name: "conferenceRecords/rec-1" }], + artifacts: [ + { + recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }], + transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }], + smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }], + }, + ], + tokenSource: "cached-access-token", + }); + } finally { + stdout.restore(); + } + }); + + it("CLI attendance prints participant sessions by default", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("attendance rows: 1"); + expect(stdout.output()).toContain("participant: Alice"); + expect(stdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + stdout.restore(); + } + }); + it("CLI doctor prints human-readable session health", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 3d9c6495c6e..10a668ae0f5 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -15,7 +15,12 @@ import { createMeetFromParams, shouldJoinCreatedMeet, } from "./src/create.js"; -import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js"; +import { + buildGoogleMeetPreflightReport, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, + fetchGoogleMeetSpace, +} from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; import { GoogleMeetRuntime } from "./src/runtime.js"; @@ -145,6 +150,8 @@ const GoogleMeetToolSchema = Type.Object({ "setup_status", "resolve_space", "preflight", + "artifacts", + "attendance", "recover_current_tab", "leave", "speak", @@ -175,6 +182,10 @@ 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}" })), + conferenceRecord: Type.Optional( + Type.String({ description: "Meet conferenceRecords/{id} resource name or id" }), + ), + pageSize: Type.Optional(Type.Number({ description: "Meet API page size for list actions" })), 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" })), @@ -211,15 +222,33 @@ function resolveMeetingInput(config: GoogleMeetConfig, value: unknown): string { return meeting; } -async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { - const meeting = resolveMeetingInput(config, raw.meeting); - const token = await resolveGoogleMeetAccessToken({ +function resolveOptionalPositiveInteger(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = typeof value === "number" ? value : Number(normalizeOptionalString(value)); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error("Expected pageSize to be a positive integer"); + } + return parsed; +} + +async function resolveGoogleMeetTokenFromParams( + config: GoogleMeetConfig, + raw: Record, +) { + return resolveGoogleMeetAccessToken({ clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId, clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret, refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken, accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken, expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt, }); +} + +async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record) { + const meeting = resolveMeetingInput(config, raw.meeting); + const token = await resolveGoogleMeetTokenFromParams(config, raw); const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting, @@ -227,6 +256,24 @@ async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record, +) { + 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); + return { + token, + meeting, + conferenceRecord, + pageSize: resolveOptionalPositiveInteger(raw.pageSize), + }; +} + export default definePluginEntry({ id: "google-meet", name: "Google Meet", @@ -337,6 +384,48 @@ export default definePluginEntry({ }, ); + api.registerGatewayMethod( + "googlemeet.artifacts", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const resolved = await resolveArtifactQueryFromParams(config, raw); + respond( + true, + await fetchGoogleMeetArtifacts({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "googlemeet.attendance", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const raw = asParamRecord(params); + const resolved = await resolveArtifactQueryFromParams(config, raw); + respond( + true, + await fetchGoogleMeetAttendance({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + api.registerGatewayMethod( "googlemeet.leave", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -469,6 +558,28 @@ export default definePluginEntry({ }), ); } + case "artifacts": { + const resolved = await resolveArtifactQueryFromParams(config, raw); + return json( + await fetchGoogleMeetArtifacts({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } + case "attendance": { + const resolved = await resolveArtifactQueryFromParams(config, raw); + return json( + await fetchGoogleMeetAttendance({ + accessToken: resolved.token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }), + ); + } case "leave": { const rt = await ensureRuntime(); const sessionId = normalizeOptionalString(raw.sessionId); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index d015304b753..c2ce43744da 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -5,7 +5,11 @@ import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./co import { buildGoogleMeetPreflightReport, createGoogleMeetSpace, + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, fetchGoogleMeetSpace, + type GoogleMeetArtifactsResult, + type GoogleMeetAttendanceResult, } from "./meet.js"; import { buildGoogleMeetAuthUrl, @@ -44,6 +48,11 @@ type ResolveSpaceOptions = { json?: boolean; }; +type MeetArtifactOptions = ResolveSpaceOptions & { + conferenceRecord?: string; + pageSize?: string; +}; + type SetupOptions = { json?: boolean; }; @@ -251,6 +260,38 @@ function resolveCreateTokenOptions( }; } +function resolveArtifactTokenOptions( + config: GoogleMeetConfig, + options: MeetArtifactOptions, +): { + meeting?: string; + conferenceRecord?: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; + pageSize?: number; +} { + const meeting = options.meeting?.trim() || config.defaults.meeting; + const conferenceRecord = options.conferenceRecord?.trim(); + if (!meeting && !conferenceRecord) { + throw new Error( + "Meeting input or conference record is required. Pass --meeting, --conference-record, or configure defaults.meeting.", + ); + } + return { + meeting, + conferenceRecord, + 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, + pageSize: parseOptionalNumber(options.pageSize), + }; +} + function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boolean { return Boolean( options.accessToken?.trim() || @@ -260,6 +301,67 @@ function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boole ); } +function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + for (const entry of result.artifacts) { + writeStdoutLine(""); + writeStdoutLine("record: %s", entry.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime)); + writeStdoutLine("participants: %d", entry.participants.length); + writeStdoutLine("recordings: %d", entry.recordings.length); + writeStdoutLine("transcripts: %d", entry.transcripts.length); + writeStdoutLine("smart notes: %d", entry.smartNotes.length); + if (entry.smartNotesError) { + writeStdoutLine("smart notes warning: %s", entry.smartNotesError); + } + for (const recording of entry.recordings) { + writeStdoutLine("- recording: %s", recording.name); + } + for (const transcript of entry.transcripts) { + writeStdoutLine("- transcript: %s", transcript.name); + } + for (const smartNote of entry.smartNotes) { + writeStdoutLine("- smart note: %s", smartNote.name); + } + } +} + +function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + writeStdoutLine("attendance rows: %d", result.attendance.length); + for (const row of result.attendance) { + const identity = row.displayName || row.user || row.participant; + writeStdoutLine(""); + 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("sessions: %d", row.sessions.length); + for (const session of row.sessions) { + writeStdoutLine( + "- %s: %s -> %s", + session.name, + formatOptional(session.startTime), + formatOptional(session.endTime), + ); + } + } +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -570,6 +672,76 @@ export function registerGoogleMeetCli(params: { } }); + root + .command("artifacts") + .description("List Meet conference records and available participant/artifact metadata") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--page-size ", "Max resources per Meet API page") + .option("--json", "Print JSON output", false) + .action(async (options: MeetArtifactOptions) => { + const resolved = resolveArtifactTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchGoogleMeetArtifacts({ + accessToken: token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeArtifactsSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("attendance") + .description("List Meet participants and participant sessions") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--page-size ", "Max resources per Meet API page") + .option("--json", "Print JSON output", false) + .action(async (options: MeetArtifactOptions) => { + const resolved = resolveArtifactTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting: resolved.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeAttendanceSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("status") .argument("[session-id]", "Meet session ID") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index fac7bc94c9c..acf1ba9af88 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -1,6 +1,7 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -const GOOGLE_MEET_API_BASE_URL = "https://meet.googleapis.com/v2"; +const GOOGLE_MEET_API_ORIGIN = "https://meet.googleapis.com"; +const GOOGLE_MEET_API_BASE_URL = `${GOOGLE_MEET_API_ORIGIN}/v2`; const GOOGLE_MEET_URL_HOST = "meet.google.com"; const GOOGLE_MEET_API_HOST = "meet.googleapis.com"; @@ -28,6 +29,95 @@ export type GoogleMeetCreateSpaceResult = { meetingUri: string; }; +export type GoogleMeetConferenceRecord = { + name: string; + space?: string; + startTime?: string; + endTime?: string; + expireTime?: string; +}; + +export type GoogleMeetParticipant = { + name: string; + earliestStartTime?: string; + latestEndTime?: string; + signedinUser?: { + user?: string; + displayName?: string; + }; + anonymousUser?: { + displayName?: string; + }; + phoneUser?: { + displayName?: string; + }; +}; + +export type GoogleMeetParticipantSession = { + name: string; + startTime?: string; + endTime?: string; +}; + +export type GoogleMeetRecording = { + name: string; + startTime?: string; + endTime?: string; + driveDestination?: Record; +}; + +export type GoogleMeetTranscript = { + name: string; + startTime?: string; + endTime?: string; + docsDestination?: Record; +}; + +export type GoogleMeetSmartNote = { + name: string; + startTime?: string; + endTime?: string; + docsDestination?: Record; +}; + +export type GoogleMeetArtifactsEntry = { + conferenceRecord: GoogleMeetConferenceRecord; + participants: GoogleMeetParticipant[]; + recordings: GoogleMeetRecording[]; + transcripts: GoogleMeetTranscript[]; + smartNotes: GoogleMeetSmartNote[]; + smartNotesError?: string; +}; + +export type GoogleMeetArtifactsResult = { + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; + artifacts: GoogleMeetArtifactsEntry[]; +}; + +export type GoogleMeetAttendanceRow = { + conferenceRecord: string; + participant: string; + displayName?: string; + user?: string; + earliestStartTime?: string; + latestEndTime?: string; + sessions: GoogleMeetParticipantSession[]; +}; + +export type GoogleMeetAttendanceResult = { + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; + attendance: GoogleMeetAttendanceRow[]; +}; + +type GoogleMeetSmartNotesListResult = { + smartNotes: GoogleMeetSmartNote[]; + smartNotesError?: string; +}; + export function normalizeGoogleMeetSpaceName(input: string): string { const trimmed = input.trim(); if (!trimmed) { @@ -61,6 +151,121 @@ function encodeSpaceNameForPath(name: string): string { return name.split("/").map(encodeURIComponent).join("/"); } +function encodeResourceNameForPath(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Google Meet resource name is required"); + } + return trimmed.split("/").map(encodeURIComponent).join("/"); +} + +function normalizeConferenceRecordName(input: string): string { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Conference record is required"); + } + return trimmed.startsWith("conferenceRecords/") ? trimmed : `conferenceRecords/${trimmed}`; +} + +function appendQuery( + url: string, + query?: Record, +): string { + if (!query) { + return url; + } + 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 assertResourceArray( + value: unknown, + key: string, + context: string, +): T[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error(`Google Meet ${context} response had non-array ${key}`); + } + const resources = value as T[]; + for (const resource of resources) { + if (!resource.name?.trim()) { + throw new Error(`Google Meet ${context} response included a resource without name`); + } + } + return resources; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function fetchGoogleMeetJson(params: { + accessToken: string; + path: string; + query?: Record; + auditContext: string; + errorPrefix: string; +}): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query), + init: { + headers: { + Authorization: `Bearer ${params.accessToken}`, + Accept: "application/json", + }, + }, + policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] }, + auditContext: params.auditContext, + }); + try { + if (!response.ok) { + const detail = await response.text(); + throw new Error(`${params.errorPrefix} failed (${response.status}): ${detail}`); + } + return (await response.json()) as T; + } finally { + await release(); + } +} + +async function listGoogleMeetCollection(params: { + accessToken: string; + path: string; + collectionKey: string; + query?: Record; + auditContext: string; + errorPrefix: string; +}): Promise { + const items: T[] = []; + let pageToken: string | undefined; + do { + const payload = await fetchGoogleMeetJson>({ + accessToken: params.accessToken, + path: params.path, + query: { ...params.query, pageToken }, + auditContext: params.auditContext, + errorPrefix: params.errorPrefix, + }); + items.push( + ...assertResourceArray( + payload[params.collectionKey], + params.collectionKey, + params.errorPrefix, + ), + ); + pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined; + } while (pageToken); + return items; +} + export async function fetchGoogleMeetSpace(params: { accessToken: string; meeting: string; @@ -128,6 +333,269 @@ export async function createGoogleMeetSpace(params: { } } +export async function fetchGoogleMeetConferenceRecord(params: { + accessToken: string; + conferenceRecord: string; +}): Promise { + const name = normalizeConferenceRecordName(params.conferenceRecord); + const payload = await fetchGoogleMeetJson({ + accessToken: params.accessToken, + path: encodeResourceNameForPath(name), + auditContext: "google-meet.conferenceRecords.get", + errorPrefix: "Google Meet conferenceRecords.get", + }); + if (!payload.name?.trim()) { + throw new Error("Google Meet conferenceRecords.get response was missing name"); + } + return payload; +} + +export async function listGoogleMeetConferenceRecords(params: { + accessToken: string; + meeting?: string; + pageSize?: number; +}): Promise { + const filter = params.meeting + ? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"` + : undefined; + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: "conferenceRecords", + collectionKey: "conferenceRecords", + query: { + pageSize: params.pageSize, + filter, + }, + auditContext: "google-meet.conferenceRecords.list", + errorPrefix: "Google Meet conferenceRecords.list", + }); +} + +export async function listGoogleMeetParticipants(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/participants`, + collectionKey: "participants", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.participants.list", + errorPrefix: "Google Meet conferenceRecords.participants.list", + }); +} + +export async function listGoogleMeetParticipantSessions(params: { + accessToken: string; + participant: string; + pageSize?: number; +}): Promise { + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(params.participant)}/participantSessions`, + collectionKey: "participantSessions", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.participants.participantSessions.list", + errorPrefix: "Google Meet conferenceRecords.participants.participantSessions.list", + }); +} + +export async function listGoogleMeetRecordings(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/recordings`, + collectionKey: "recordings", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.recordings.list", + errorPrefix: "Google Meet conferenceRecords.recordings.list", + }); +} + +export async function listGoogleMeetTranscripts(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/transcripts`, + collectionKey: "transcripts", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.transcripts.list", + errorPrefix: "Google Meet conferenceRecords.transcripts.list", + }); +} + +export async function listGoogleMeetSmartNotes(params: { + accessToken: string; + conferenceRecord: string; + pageSize?: number; +}): Promise { + const parent = normalizeConferenceRecordName(params.conferenceRecord); + return listGoogleMeetCollection({ + accessToken: params.accessToken, + path: `${encodeResourceNameForPath(parent)}/smartNotes`, + collectionKey: "smartNotes", + query: { pageSize: params.pageSize }, + auditContext: "google-meet.conferenceRecords.smartNotes.list", + errorPrefix: "Google Meet conferenceRecords.smartNotes.list", + }); +} + +function getParticipantDisplayName(participant: GoogleMeetParticipant): string | undefined { + return ( + participant.signedinUser?.displayName ?? + participant.anonymousUser?.displayName ?? + participant.phoneUser?.displayName + ); +} + +function getParticipantUser(participant: GoogleMeetParticipant): string | undefined { + return participant.signedinUser?.user; +} + +async function resolveConferenceRecordQuery(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise<{ + input?: string; + space?: GoogleMeetSpace; + conferenceRecords: GoogleMeetConferenceRecord[]; +}> { + if (params.conferenceRecord?.trim()) { + const conferenceRecord = await fetchGoogleMeetConferenceRecord({ + accessToken: params.accessToken, + conferenceRecord: params.conferenceRecord, + }); + return { + input: params.conferenceRecord.trim(), + conferenceRecords: [conferenceRecord], + }; + } + if (!params.meeting?.trim()) { + throw new Error("Meeting input or conference record is required"); + } + const space = await fetchGoogleMeetSpace({ + accessToken: params.accessToken, + meeting: params.meeting, + }); + const conferenceRecords = await listGoogleMeetConferenceRecords({ + accessToken: params.accessToken, + meeting: space.name, + pageSize: params.pageSize, + }); + return { + input: params.meeting, + space, + conferenceRecords, + }; +} + +export async function fetchGoogleMeetArtifacts(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise { + const resolved = await resolveConferenceRecordQuery(params); + const artifacts = await Promise.all( + resolved.conferenceRecords.map(async (conferenceRecord) => { + const [participants, recordings, transcripts, smartNotesResult] = await Promise.all([ + listGoogleMeetParticipants({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetRecordings({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetTranscripts({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }), + listGoogleMeetSmartNotes({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }) + .then((smartNotes) => ({ smartNotes })) + .catch((error: unknown) => ({ + smartNotes: [], + smartNotesError: getErrorMessage(error), + })), + ]); + return { + conferenceRecord, + participants, + recordings, + transcripts, + smartNotes: smartNotesResult.smartNotes, + ...(smartNotesResult.smartNotesError + ? { smartNotesError: smartNotesResult.smartNotesError } + : {}), + }; + }), + ); + return { + input: resolved.input, + space: resolved.space, + conferenceRecords: resolved.conferenceRecords, + artifacts, + }; +} + +export async function fetchGoogleMeetAttendance(params: { + accessToken: string; + meeting?: string; + conferenceRecord?: string; + pageSize?: number; +}): Promise { + const resolved = await resolveConferenceRecordQuery(params); + const nestedRows = await Promise.all( + resolved.conferenceRecords.map(async (conferenceRecord) => { + const participants = await listGoogleMeetParticipants({ + accessToken: params.accessToken, + conferenceRecord: conferenceRecord.name, + pageSize: params.pageSize, + }); + return Promise.all( + participants.map(async (participant) => ({ + conferenceRecord: conferenceRecord.name, + participant: participant.name, + displayName: getParticipantDisplayName(participant), + user: getParticipantUser(participant), + earliestStartTime: participant.earliestStartTime, + latestEndTime: participant.latestEndTime, + sessions: await listGoogleMeetParticipantSessions({ + accessToken: params.accessToken, + participant: participant.name, + pageSize: params.pageSize, + }), + })), + ); + }), + ); + return { + input: resolved.input, + space: resolved.space, + conferenceRecords: resolved.conferenceRecords, + attendance: nestedRows.flat(), + }; +} + export function buildGoogleMeetPreflightReport(params: { input: string; space: GoogleMeetSpace;