From d78cef1d7193694f51685b00b04a12ceae7bb8d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:41:54 +0100 Subject: [PATCH] feat(google-meet): add export dry run manifests --- .../google-meet/google-meet.live.test.ts | 31 +++++++++++- extensions/google-meet/index.test.ts | 36 ++++++++++++- extensions/google-meet/index.ts | 28 +++++++++-- extensions/google-meet/src/cli.test.ts | 50 ++++++++++++++++++- extensions/google-meet/src/cli.ts | 40 +++++++++++---- 5 files changed, 170 insertions(+), 15 deletions(-) diff --git a/extensions/google-meet/google-meet.live.test.ts b/extensions/google-meet/google-meet.live.test.ts index 402b938db77..183c8f07b9d 100644 --- a/extensions/google-meet/google-meet.live.test.ts +++ b/extensions/google-meet/google-meet.live.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js"; -import { fetchGoogleMeetArtifacts, fetchLatestGoogleMeetConferenceRecord } from "./src/meet.js"; +import { buildGoogleMeetExportManifest, googleMeetExportFileNames } from "./src/cli.js"; +import { + fetchGoogleMeetArtifacts, + fetchGoogleMeetAttendance, + fetchLatestGoogleMeetConferenceRecord, +} from "./src/meet.js"; import { resolveGoogleMeetAccessToken } from "./src/oauth.js"; const LIVE_MEETING = process.env.OPENCLAW_GOOGLE_MEET_LIVE_MEETING?.trim() ?? ""; @@ -52,5 +57,29 @@ describeLive("google-meet live", () => { }); expect(artifacts.conferenceRecords.length).toBeLessThanOrEqual(1); expect(Array.isArray(artifacts.artifacts)).toBe(true); + + const attendance = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting: LIVE_MEETING, + pageSize: 5, + }); + expect(attendance.conferenceRecords.length).toBe(artifacts.conferenceRecords.length); + + const manifest = buildGoogleMeetExportManifest({ + artifacts, + attendance, + files: googleMeetExportFileNames(), + request: { + meeting: LIVE_MEETING, + pageSize: 5, + includeTranscriptEntries: true, + includeDocumentBodies: false, + allConferenceRecords: false, + mergeDuplicateParticipants: true, + }, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + expect(manifest.files).toContain("manifest.json"); + expect(manifest.counts.conferenceRecords).toBe(artifacts.conferenceRecords.length); }, 120_000); }); diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index cbfa058934f..7a689c88b91 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; @@ -938,6 +938,40 @@ describe("google-meet plugin", () => { } }); + it("dry-runs export bundles through the tool", async () => { + stubMeetArtifactsApi(); + const parentDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-dry-run-")); + const outputDir = path.join(parentDir, "bundle"); + const { tools } = setup(); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { dryRun?: boolean; manifest?: { files?: string[] } } }>; + }; + + try { + const result = await tool.execute("id", { + action: "export", + accessToken: "token", + expiresAt: Date.now() + 120_000, + conferenceRecord: "rec-1", + outputDir, + dryRun: true, + }); + + expect(result.details).toMatchObject({ + dryRun: true, + manifest: { + files: expect.arrayContaining(["summary.md", "manifest.json"]), + }, + }); + expect(existsSync(outputDir)).toBe(false); + } finally { + rmSync(parentDir, { recursive: true, force: true }); + } + }); + it("reports the latest conference record through the tool", async () => { stubMeetArtifactsApi(); const { tools } = setup(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 5ef518f5f71..53a0767d746 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -211,6 +211,11 @@ const GoogleMeetToolSchema = Type.Object({ ), outputDir: Type.Optional(Type.String({ description: "For export, output directory" })), zip: Type.Optional(Type.Boolean({ description: "For export, also write a .zip archive" })), + dryRun: Type.Optional( + Type.Boolean({ + description: "For export, return the manifest without writing files.", + }), + ), includeAllConferenceRecords: Type.Optional( Type.Boolean({ description: @@ -403,7 +408,8 @@ async function exportGoogleMeetBundleFromParams( earlyBeforeMinutes: resolved.earlyBeforeMinutes, }), ]); - const { writeMeetExportBundle } = await import("./src/cli.js"); + const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } = + await import("./src/cli.js"); const calendarId = normalizeOptionalString(raw.calendarId); const request = { ...(resolved.meeting ? { meeting: resolved.meeting } : {}), @@ -427,6 +433,22 @@ async function exportGoogleMeetBundleFromParams( ? { earlyBeforeMinutes: resolved.earlyBeforeMinutes } : {}), }; + const tokenSource = resolved.token.refreshed ? "refresh-token" : "cached-access-token"; + if (raw.dryRun === true) { + return { + dryRun: true, + manifest: buildGoogleMeetExportManifest({ + artifacts, + attendance, + files: googleMeetExportFileNames(), + request, + tokenSource, + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), + }), + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), + tokenSource, + }; + } const outputDir = normalizeOptionalString(raw.outputDir) ?? normalizeOptionalString(raw.output); const bundle = await writeMeetExportBundle({ ...(outputDir ? { outputDir } : {}), @@ -434,13 +456,13 @@ async function exportGoogleMeetBundleFromParams( attendance, zip: raw.zip === true, request, - tokenSource: resolved.token.refreshed ? "refresh-token" : "cached-access-token", + tokenSource, ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), }); return { ...bundle, ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), - tokenSource: resolved.token.refreshed ? "refresh-token" : "cached-access-token", + tokenSource, }; } diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 3cb0ebbbe86..673bf1bed40 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { Command } from "commander"; @@ -594,6 +594,54 @@ describe("google-meet CLI", () => { } }); + it("prints a dry-run export manifest without writing files", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + const parentDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-dry-run-")); + const outputDir = path.join(parentDir, "bundle"); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "export", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--include-doc-bodies", + "--output", + outputDir, + "--dry-run", + ], + { from: "user" }, + ); + const payload = JSON.parse(stdout.output()); + expect(payload).toMatchObject({ + dryRun: true, + manifest: { + request: { + conferenceRecord: "rec-1", + includeDocumentBodies: true, + }, + counts: { + attendanceRows: 1, + transcriptEntries: 1, + warnings: 0, + }, + files: expect.arrayContaining(["summary.md", "manifest.json"]), + }, + tokenSource: "cached-access-token", + }); + expect(existsSync(outputDir)).toBe(false); + } finally { + stdout.restore(); + rmSync(parentDir, { recursive: true, force: true }); + } + }); + it("prints human-readable session doctor output", async () => { const stdout = captureStdout(); try { diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index ebdd92d3498..d9fff749daf 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -71,6 +71,7 @@ type MeetArtifactOptions = ResolveSpaceOptions & { lateAfterMinutes?: string; earlyBeforeMinutes?: string; zip?: boolean; + dryRun?: boolean; format?: "summary" | "markdown" | "csv"; output?: string; }; @@ -1051,7 +1052,7 @@ export function collectGoogleMeetArtifactWarnings( return warnings; } -function buildGoogleMeetExportManifest(params: { +export function buildGoogleMeetExportManifest(params: { artifacts: GoogleMeetArtifactsResult; attendance: GoogleMeetAttendanceResult; files: string[]; @@ -1105,6 +1106,17 @@ function buildGoogleMeetExportManifest(params: { }; } +export function googleMeetExportFileNames(): string[] { + return [ + "summary.md", + "attendance.csv", + "transcript.md", + "artifacts.json", + "attendance.json", + "manifest.json", + ]; +} + function defaultExportDirectory(): string { return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`; } @@ -1203,14 +1215,7 @@ export async function writeMeetExportBundle(params: { const outputDir = params.outputDir?.trim() || defaultExportDirectory(); await mkdir(outputDir, { recursive: true }); const zipFile = params.zip ? `${outputDir.replace(/\/$/, "")}.zip` : undefined; - const fileNames = [ - "summary.md", - "attendance.csv", - "transcript.md", - "artifacts.json", - "attendance.json", - "manifest.json", - ]; + const fileNames = googleMeetExportFileNames(); const files = [ { name: "summary.md", @@ -1813,6 +1818,7 @@ export function registerGoogleMeetCli(params: { .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") .option("--output ", "Output directory") .option("--zip", "Also write a portable .zip archive") + .option("--dry-run", "Fetch export data and print the manifest without writing files", false) .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -1868,6 +1874,22 @@ export function registerGoogleMeetCli(params: { ? { earlyBeforeMinutes: resolved.earlyBeforeMinutes } : {}), }; + if (options.dryRun) { + writeStdoutJson({ + dryRun: true, + manifest: buildGoogleMeetExportManifest({ + artifacts, + attendance, + files: googleMeetExportFileNames(), + request, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), + }), + ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } const bundle = await writeMeetExportBundle({ outputDir: options.output, artifacts,