diff --git a/CHANGELOG.md b/CHANGELOG.md index 003216ab968..4d5efd3a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - 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 and transcript entries, smart notes, and participant sessions. Thanks @steipete. - Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete. +- Plugins/Google Meet: add `googlemeet latest` plus matching tool/gateway actions to find the newest conference record for a meeting. Thanks @steipete. - Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. 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. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 89c9e39945e..0225f07634e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -638,6 +638,7 @@ 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 latest --meeting https://meet.google.com/abc-defg-hij openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json ``` diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 2ca27049171..e8288c23ed8 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -14,6 +14,7 @@ import { createGoogleMeetSpace, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, normalizeGoogleMeetSpaceName, } from "./src/meet.js"; @@ -343,6 +344,7 @@ describe("google-meet plugin", () => { "setup_status", "resolve_space", "preflight", + "latest", "artifacts", "attendance", "recover_current_tab", @@ -496,6 +498,32 @@ describe("google-meet plugin", () => { ); }); + it("fetches only the latest Meet conference record for a meeting", async () => { + const fetchMock = stubMeetArtifactsApi(); + + await expect( + fetchLatestGoogleMeetConferenceRecord({ + accessToken: "token", + meeting: "abc-defg-hij", + }), + ).resolves.toMatchObject({ + input: "abc-defg-hij", + space: { name: "spaces/abc-defg-hij" }, + conferenceRecord: { name: "conferenceRecords/rec-1" }, + }); + + 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("pageSize")).toBe("1"); + expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"'); + }); + it("lists Meet attendance rows with participant sessions", async () => { const fetchMock = stubMeetArtifactsApi(); @@ -695,6 +723,26 @@ describe("google-meet plugin", () => { expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]); }); + it("reports the latest conference record through the tool", async () => { + stubMeetArtifactsApi(); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { conferenceRecord?: { name?: string } } }>; + }; + + const result = await tool.execute("id", { + action: "latest", + accessToken: "token", + expiresAt: Date.now() + 120_000, + meeting: "abc-defg-hij", + }); + + expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" }); + }); + it("fails setup status when the configured Chrome node is not connected", async () => { const { tools } = setup( { @@ -918,6 +966,37 @@ describe("google-meet plugin", () => { } }); + it("CLI latest prints the latest conference record", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--meeting", + "abc-defg-hij", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + it("CLI artifacts writes markdown output", async () => { stubMeetArtifactsApi(); const program = new Command(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index d875dccfaa0..8190d305113 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -19,6 +19,7 @@ import { buildGoogleMeetPreflightReport, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, } from "./src/meet.js"; import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; @@ -150,6 +151,7 @@ const GoogleMeetToolSchema = Type.Object({ "setup_status", "resolve_space", "preflight", + "latest", "artifacts", "attendance", "recover_current_tab", @@ -388,6 +390,26 @@ export default definePluginEntry({ }, ); + api.registerGatewayMethod( + "googlemeet.latest", + 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({ + accessToken: token.accessToken, + meeting, + }), + ); + } catch (err) { + sendError(respond, err); + } + }, + ); + api.registerGatewayMethod( "googlemeet.artifacts", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -563,6 +585,16 @@ export default definePluginEntry({ }), ); } + case "latest": { + const meeting = resolveMeetingInput(config, raw.meeting); + const token = await resolveGoogleMeetTokenFromParams(config, raw); + return json( + await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting, + }), + ); + } case "artifacts": { const resolved = await resolveArtifactQueryFromParams(config, raw); return json( diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 726b3bfa7f1..41d1802e857 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -8,9 +8,11 @@ import { createGoogleMeetSpace, fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, fetchGoogleMeetSpace, type GoogleMeetArtifactsResult, type GoogleMeetAttendanceResult, + type GoogleMeetLatestConferenceRecordResult, } from "./meet.js"; import { buildGoogleMeetAuthUrl, @@ -547,6 +549,18 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { } } +function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRecordResult): void { + writeStdoutLine("input: %s", result.input); + writeStdoutLine("space: %s", result.space.name); + if (!result.conferenceRecord) { + writeStdoutLine("conference record: none"); + return; + } + writeStdoutLine("conference record: %s", result.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime)); +} + function pushMarkdownLine(lines: string[], text = ""): void { lines.push(text); } @@ -974,6 +988,37 @@ export function registerGoogleMeetCli(params: { } }); + root + .command("latest") + .description("Find the latest Meet conference record for a meeting") + .option("--meeting ", "Meet URL, meeting code, or spaces/{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("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const result = await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeLatestConferenceRecordSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("artifacts") .description("List Meet conference records and available participant/artifact metadata") diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 8cace830bfd..ffebce9c0fc 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -112,6 +112,12 @@ export type GoogleMeetArtifactsResult = { artifacts: GoogleMeetArtifactsEntry[]; }; +export type GoogleMeetLatestConferenceRecordResult = { + input: string; + space: GoogleMeetSpace; + conferenceRecord?: GoogleMeetConferenceRecord; +}; + export type GoogleMeetAttendanceRow = { conferenceRecord: string; participant: string; @@ -257,6 +263,7 @@ async function listGoogleMeetCollection(params: { path: string; collectionKey: string; query?: Record; + maxItems?: number; auditContext: string; errorPrefix: string; }): Promise { @@ -270,13 +277,17 @@ async function listGoogleMeetCollection(params: { auditContext: params.auditContext, errorPrefix: params.errorPrefix, }); - items.push( - ...assertResourceArray( - payload[params.collectionKey], - params.collectionKey, - params.errorPrefix, - ), + const pageItems = assertResourceArray( + payload[params.collectionKey], + params.collectionKey, + params.errorPrefix, ); + const remaining = + typeof params.maxItems === "number" ? Math.max(params.maxItems - items.length, 0) : undefined; + items.push(...(remaining === undefined ? pageItems : pageItems.slice(0, remaining))); + if (typeof params.maxItems === "number" && items.length >= params.maxItems) { + break; + } pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined; } while (pageToken); return items; @@ -370,6 +381,7 @@ export async function listGoogleMeetConferenceRecords(params: { accessToken: string; meeting?: string; pageSize?: number; + maxItems?: number; }): Promise { const filter = params.meeting ? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"` @@ -382,11 +394,33 @@ export async function listGoogleMeetConferenceRecords(params: { pageSize: params.pageSize, filter, }, + maxItems: params.maxItems, auditContext: "google-meet.conferenceRecords.list", errorPrefix: "Google Meet conferenceRecords.list", }); } +export async function fetchLatestGoogleMeetConferenceRecord(params: { + accessToken: string; + meeting: string; +}): Promise { + const space = await fetchGoogleMeetSpace({ + accessToken: params.accessToken, + meeting: params.meeting, + }); + const [conferenceRecord] = await listGoogleMeetConferenceRecords({ + accessToken: params.accessToken, + meeting: space.name, + pageSize: 1, + maxItems: 1, + }); + return { + input: params.meeting, + space, + ...(conferenceRecord ? { conferenceRecord } : {}), + }; +} + export async function listGoogleMeetParticipants(params: { accessToken: string; conferenceRecord: string;