feat(google-meet): add export manifests and tool parity

This commit is contained in:
Peter Steinberger
2026-04-25 08:38:29 +01:00
parent 13f4657b88
commit 388e0eb605
4 changed files with 427 additions and 6 deletions

View File

@@ -1,4 +1,7 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough, Writable } from "node:stream";
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -363,6 +366,7 @@ describe("google-meet plugin", () => {
"calendar_events",
"artifacts",
"attendance",
"export",
"recover_current_tab",
"leave",
"speak",
@@ -890,6 +894,50 @@ describe("google-meet plugin", () => {
expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]);
});
it("writes export bundles through the tool", async () => {
stubMeetArtifactsApi();
const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-export-"));
const { tools } = setup();
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { files?: string[]; zipFile?: string } }>;
};
try {
const result = await tool.execute("id", {
action: "export",
accessToken: "token",
expiresAt: Date.now() + 120_000,
conferenceRecord: "rec-1",
includeDocumentBodies: true,
outputDir: tempDir,
zip: true,
});
expect(result.details.files).toEqual(
expect.arrayContaining([path.join(tempDir, "manifest.json")]),
);
expect(result.details.zipFile).toBe(`${tempDir}.zip`);
const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8"));
expect(manifest).toMatchObject({
request: {
conferenceRecord: "rec-1",
includeDocumentBodies: true,
},
counts: {
attendanceRows: 1,
warnings: 0,
},
files: expect.arrayContaining(["summary.md", "manifest.json"]),
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
rmSync(`${tempDir}.zip`, { force: true });
}
});
it("reports the latest conference record through the tool", async () => {
stubMeetArtifactsApi();
const { tools } = setup();

View File

@@ -154,6 +154,7 @@ const GoogleMeetToolSchema = Type.Object({
"calendar_events",
"artifacts",
"attendance",
"export",
"recover_current_tab",
"leave",
"speak",
@@ -205,13 +206,15 @@ const GoogleMeetToolSchema = Type.Object({
includeDocumentBodies: Type.Optional(
Type.Boolean({
description:
"For artifacts, export linked transcript and smart-note Google Docs text through Drive.",
"For artifacts/export, export linked transcript and smart-note Google Docs text through Drive.",
}),
),
outputDir: Type.Optional(Type.String({ description: "For export, output directory" })),
zip: Type.Optional(Type.Boolean({ description: "For export, also write a .zip archive" })),
includeAllConferenceRecords: Type.Optional(
Type.Boolean({
description:
"For artifacts or attendance with meeting input, fetch all conference records instead of only the latest.",
"For artifacts, attendance, or export with meeting input, fetch all conference records instead of only the latest.",
}),
),
mergeDuplicateParticipants: Type.Optional(
@@ -374,6 +377,73 @@ async function resolveArtifactQueryFromParams(
};
}
async function exportGoogleMeetBundleFromParams(
config: GoogleMeetConfig,
raw: Record<string, unknown>,
) {
const resolved = await resolveArtifactQueryFromParams(config, raw);
const [artifacts, attendance] = await Promise.all([
fetchGoogleMeetArtifacts({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
includeTranscriptEntries: resolved.includeTranscriptEntries,
includeDocumentBodies: resolved.includeDocumentBodies,
allConferenceRecords: resolved.allConferenceRecords,
}),
fetchGoogleMeetAttendance({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
allConferenceRecords: resolved.allConferenceRecords,
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
lateAfterMinutes: resolved.lateAfterMinutes,
earlyBeforeMinutes: resolved.earlyBeforeMinutes,
}),
]);
const { writeMeetExportBundle } = await import("./src/cli.js");
const calendarId = normalizeOptionalString(raw.calendarId);
const request = {
...(resolved.meeting ? { meeting: resolved.meeting } : {}),
...(resolved.conferenceRecord ? { conferenceRecord: resolved.conferenceRecord } : {}),
...(resolved.calendarEvent?.event.id
? { calendarEventId: resolved.calendarEvent.event.id }
: {}),
...(resolved.calendarEvent?.event.summary
? { calendarEventSummary: resolved.calendarEvent.event.summary }
: {}),
...(calendarId ? { calendarId } : {}),
...(resolved.pageSize !== undefined ? { pageSize: resolved.pageSize } : {}),
includeTranscriptEntries: resolved.includeTranscriptEntries,
includeDocumentBodies: resolved.includeDocumentBodies,
allConferenceRecords: resolved.allConferenceRecords,
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
...(resolved.lateAfterMinutes !== undefined
? { lateAfterMinutes: resolved.lateAfterMinutes }
: {}),
...(resolved.earlyBeforeMinutes !== undefined
? { earlyBeforeMinutes: resolved.earlyBeforeMinutes }
: {}),
};
const outputDir = normalizeOptionalString(raw.outputDir) ?? normalizeOptionalString(raw.output);
const bundle = await writeMeetExportBundle({
...(outputDir ? { outputDir } : {}),
artifacts,
attendance,
zip: raw.zip === true,
request,
tokenSource: resolved.token.refreshed ? "refresh-token" : "cached-access-token",
...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}),
});
return {
...bundle,
...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}),
tokenSource: resolved.token.refreshed ? "refresh-token" : "cached-access-token",
};
}
export default definePluginEntry({
id: "google-meet",
name: "Google Meet",
@@ -579,6 +649,17 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.export",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
respond(true, await exportGoogleMeetBundleFromParams(config, asParamRecord(params)));
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.leave",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -767,6 +848,9 @@ export default definePluginEntry({
}),
);
}
case "export": {
return json(await exportGoogleMeetBundleFromParams(config, raw));
}
case "leave": {
const rt = await ensureRuntime();
const sessionId = normalizeOptionalString(raw.sessionId);

View File

@@ -55,7 +55,7 @@ function requestUrl(input: RequestInfo | URL): URL {
return new URL(input.url);
}
function stubMeetArtifactsApi() {
function stubMeetArtifactsApi(options: { failSmartNoteDocumentBody?: boolean } = {}) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
@@ -173,6 +173,9 @@ function stubMeetArtifactsApi() {
});
}
if (url.pathname === "/drive/v3/files/notes-1/export") {
if (options.failSmartNoteDocumentBody) {
return new Response("insufficientPermissions", { status: 403 });
}
return new Response("Smart note document body.", {
status: 200,
headers: { "Content-Type": "text/plain" },
@@ -516,6 +519,26 @@ describe("google-meet CLI", () => {
expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain(
"Transcript document body.",
);
const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8"));
expect(manifest).toMatchObject({
request: {
conferenceRecord: "rec-1",
includeDocumentBodies: true,
},
tokenSource: "cached-access-token",
counts: {
attendanceRows: 1,
warnings: 0,
},
files: expect.arrayContaining([
"summary.md",
"attendance.csv",
"transcript.md",
"artifacts.json",
"attendance.json",
"manifest.json",
]),
});
expect(JSON.parse(readFileSync(path.join(tempDir, "artifacts.json"), "utf8"))).toMatchObject({
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
artifacts: [{ transcripts: [{ documentText: "Transcript document body." }] }],
@@ -528,6 +551,49 @@ describe("google-meet CLI", () => {
}
});
it("includes artifact warnings in export summaries and manifests", async () => {
stubMeetArtifactsApi({ failSmartNoteDocumentBody: true });
const stdout = captureStdout();
const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-warning-"));
try {
await setupCli({}).parseAsync(
[
"googlemeet",
"export",
"--access-token",
"token",
"--expires-at",
String(Date.now() + 120_000),
"--conference-record",
"rec-1",
"--include-doc-bodies",
"--output",
tempDir,
"--json",
],
{ from: "user" },
);
const summary = readFileSync(path.join(tempDir, "summary.md"), "utf8");
expect(summary).toContain("### Warnings");
expect(summary).toContain("Document body warning");
const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8"));
expect(manifest).toMatchObject({
counts: { warnings: 1 },
warnings: [
{
type: "smart_note_document_body",
conferenceRecord: "conferenceRecords/rec-1",
resource: "conferenceRecords/rec-1/smartNotes/sn1",
},
],
});
} finally {
stdout.restore();
rmSync(tempDir, { recursive: true, force: true });
}
});
it("prints human-readable session doctor output", async () => {
const stdout = captureStdout();
try {

View File

@@ -75,6 +75,57 @@ type MeetArtifactOptions = ResolveSpaceOptions & {
output?: string;
};
export type GoogleMeetExportRequest = {
meeting?: string;
conferenceRecord?: string;
calendarEventId?: string;
calendarEventSummary?: string;
calendarId?: string;
pageSize?: number;
includeTranscriptEntries?: boolean;
includeDocumentBodies?: boolean;
allConferenceRecords?: boolean;
mergeDuplicateParticipants?: boolean;
lateAfterMinutes?: number;
earlyBeforeMinutes?: number;
};
export type GoogleMeetExportWarning = {
type:
| "smart_notes"
| "transcript_entries"
| "transcript_document_body"
| "smart_note_document_body";
conferenceRecord: string;
resource?: string;
message: string;
};
export type GoogleMeetExportManifest = {
generatedAt: string;
request?: GoogleMeetExportRequest;
tokenSource?: "cached-access-token" | "refresh-token";
calendarEvent?: GoogleMeetCalendarLookupResult;
inputs: {
artifacts?: string;
attendance?: string;
};
counts: {
conferenceRecords: number;
artifacts: number;
attendanceRows: number;
recordings: number;
transcripts: number;
transcriptEntries: number;
smartNotes: number;
warnings: number;
};
conferenceRecords: string[];
files: string[];
zipFile?: string;
warnings: GoogleMeetExportWarning[];
};
type SetupOptions = {
json?: boolean;
};
@@ -601,6 +652,9 @@ function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void {
}
for (const transcript of entry.transcripts) {
writeStdoutLine("- transcript: %s", transcript.name);
if (transcript.documentTextError) {
writeStdoutLine("- transcript document body warning: %s", transcript.documentTextError);
}
}
for (const transcriptEntries of entry.transcriptEntries) {
if (transcriptEntries.entriesError) {
@@ -613,6 +667,9 @@ function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void {
}
for (const smartNote of entry.smartNotes) {
writeStdoutLine("- smart note: %s", smartNote.name);
if (smartNote.documentTextError) {
writeStdoutLine("- smart note document body warning: %s", smartNote.documentTextError);
}
}
}
}
@@ -735,6 +792,18 @@ function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
)}`,
);
pushMarkdownLine(lines, `Smart notes: ${entry.smartNotes.length}`);
const warnings = collectGoogleMeetArtifactWarnings({
conferenceRecords: [entry.conferenceRecord],
artifacts: [entry],
});
if (warnings.length > 0) {
pushMarkdownLine(lines);
pushMarkdownLine(lines, "### Warnings");
for (const warning of warnings) {
const resource = warning.resource ? `${warning.resource}: ` : "";
pushMarkdownLine(lines, `- ${resource}${warning.message}`);
}
}
if (entry.recordings.length > 0) {
pushMarkdownLine(lines);
pushMarkdownLine(lines, "### Recordings");
@@ -935,6 +1004,107 @@ function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string {
return `${lines.join("\n")}\n`;
}
export function collectGoogleMeetArtifactWarnings(
result: GoogleMeetArtifactsResult,
): GoogleMeetExportWarning[] {
const warnings: GoogleMeetExportWarning[] = [];
for (const entry of result.artifacts) {
const conferenceRecord = entry.conferenceRecord.name;
if (entry.smartNotesError) {
warnings.push({
type: "smart_notes",
conferenceRecord,
message: entry.smartNotesError,
});
}
for (const transcriptEntries of entry.transcriptEntries) {
if (transcriptEntries.entriesError) {
warnings.push({
type: "transcript_entries",
conferenceRecord,
resource: transcriptEntries.transcript,
message: transcriptEntries.entriesError,
});
}
}
for (const transcript of entry.transcripts) {
if (transcript.documentTextError) {
warnings.push({
type: "transcript_document_body",
conferenceRecord,
resource: transcript.name,
message: transcript.documentTextError,
});
}
}
for (const smartNote of entry.smartNotes) {
if (smartNote.documentTextError) {
warnings.push({
type: "smart_note_document_body",
conferenceRecord,
resource: smartNote.name,
message: smartNote.documentTextError,
});
}
}
}
return warnings;
}
function buildGoogleMeetExportManifest(params: {
artifacts: GoogleMeetArtifactsResult;
attendance: GoogleMeetAttendanceResult;
files: string[];
request?: GoogleMeetExportRequest;
tokenSource?: "cached-access-token" | "refresh-token";
calendarEvent?: GoogleMeetCalendarLookupResult;
zipFile?: string;
}): GoogleMeetExportManifest {
const transcriptEntryCount = params.artifacts.artifacts.reduce(
(count, entry) =>
count +
entry.transcriptEntries.reduce(
(entryCount, transcript) => entryCount + transcript.entries.length,
0,
),
0,
);
const warnings = collectGoogleMeetArtifactWarnings(params.artifacts);
return {
generatedAt: new Date().toISOString(),
...(params.request ? { request: params.request } : {}),
...(params.tokenSource ? { tokenSource: params.tokenSource } : {}),
...(params.calendarEvent ? { calendarEvent: params.calendarEvent } : {}),
inputs: {
...(params.artifacts.input ? { artifacts: params.artifacts.input } : {}),
...(params.attendance.input ? { attendance: params.attendance.input } : {}),
},
counts: {
conferenceRecords: params.artifacts.conferenceRecords.length,
artifacts: params.artifacts.artifacts.length,
attendanceRows: params.attendance.attendance.length,
recordings: params.artifacts.artifacts.reduce(
(count, entry) => count + entry.recordings.length,
0,
),
transcripts: params.artifacts.artifacts.reduce(
(count, entry) => count + entry.transcripts.length,
0,
),
transcriptEntries: transcriptEntryCount,
smartNotes: params.artifacts.artifacts.reduce(
(count, entry) => count + entry.smartNotes.length,
0,
),
warnings: warnings.length,
},
conferenceRecords: params.artifacts.conferenceRecords.map((record) => record.name),
files: params.files,
...(params.zipFile ? { zipFile: params.zipFile } : {}),
warnings,
};
}
function defaultExportDirectory(): string {
return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`;
}
@@ -1021,14 +1191,26 @@ function buildZipArchive(files: Array<{ name: string; content: string }>): Buffe
return Buffer.concat([...localParts, centralDirectory, end]);
}
async function writeMeetExportBundle(params: {
export async function writeMeetExportBundle(params: {
outputDir?: string;
artifacts: GoogleMeetArtifactsResult;
attendance: GoogleMeetAttendanceResult;
zip?: boolean;
request?: GoogleMeetExportRequest;
tokenSource?: "cached-access-token" | "refresh-token";
calendarEvent?: GoogleMeetCalendarLookupResult;
}): Promise<{ outputDir: string; files: string[]; zipFile?: string }> {
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 files = [
{
name: "summary.md",
@@ -1038,6 +1220,22 @@ async function writeMeetExportBundle(params: {
{ 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` },
{
name: "manifest.json",
content: `${JSON.stringify(
buildGoogleMeetExportManifest({
artifacts: params.artifacts,
attendance: params.attendance,
files: fileNames,
...(params.request ? { request: params.request } : {}),
...(params.tokenSource ? { tokenSource: params.tokenSource } : {}),
...(params.calendarEvent ? { calendarEvent: params.calendarEvent } : {}),
...(zipFile ? { zipFile } : {}),
}),
null,
2,
)}\n`,
},
];
for (const file of files) {
await writeFile(path.join(outputDir, file.name), file.content, "utf8");
@@ -1046,8 +1244,7 @@ async function writeMeetExportBundle(params: {
outputDir,
files: files.map((file) => path.join(outputDir, file.name)),
};
if (params.zip) {
const zipFile = `${outputDir.replace(/\/$/, "")}.zip`;
if (zipFile) {
await writeFile(zipFile, buildZipArchive(files));
result.zipFile = zipFile;
}
@@ -1648,11 +1845,37 @@ export function registerGoogleMeetCli(params: {
lateAfterMinutes: resolved.lateAfterMinutes,
earlyBeforeMinutes: resolved.earlyBeforeMinutes,
});
const resolvedMeeting = meetingResult.meeting ?? resolved.meeting;
const request: GoogleMeetExportRequest = {
...(resolvedMeeting ? { meeting: resolvedMeeting } : {}),
...(resolved.conferenceRecord ? { conferenceRecord: resolved.conferenceRecord } : {}),
...(meetingResult.calendarEvent?.event.id
? { calendarEventId: meetingResult.calendarEvent.event.id }
: {}),
...(meetingResult.calendarEvent?.event.summary
? { calendarEventSummary: meetingResult.calendarEvent.event.summary }
: {}),
...(options.calendar ? { calendarId: options.calendar } : {}),
...(resolved.pageSize !== undefined ? { pageSize: resolved.pageSize } : {}),
includeTranscriptEntries: resolved.includeTranscriptEntries,
includeDocumentBodies: resolved.includeDocumentBodies,
allConferenceRecords: resolved.allConferenceRecords,
mergeDuplicateParticipants: resolved.mergeDuplicateParticipants,
...(resolved.lateAfterMinutes !== undefined
? { lateAfterMinutes: resolved.lateAfterMinutes }
: {}),
...(resolved.earlyBeforeMinutes !== undefined
? { earlyBeforeMinutes: resolved.earlyBeforeMinutes }
: {}),
};
const bundle = await writeMeetExportBundle({
outputDir: options.output,
artifacts,
attendance,
zip: Boolean(options.zip),
request,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}),
});
const payload = {
...bundle,