feat(google-meet): add export dry run manifests

This commit is contained in:
Peter Steinberger
2026-04-25 08:41:54 +01:00
parent 4a80e61680
commit d78cef1d71
5 changed files with 170 additions and 15 deletions

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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,
};
}

View File

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

View File

@@ -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 <n>", "Mark early leavers before this many minutes", "5")
.option("--output <dir>", "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,