From 388e0eb6058bd2720f8d7d8d1e9f9e3d1f7e01fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:38:29 +0100 Subject: [PATCH] feat(google-meet): add export manifests and tool parity --- extensions/google-meet/index.test.ts | 48 ++++++ extensions/google-meet/index.ts | 88 +++++++++- extensions/google-meet/src/cli.test.ts | 68 +++++++- extensions/google-meet/src/cli.ts | 229 ++++++++++++++++++++++++- 4 files changed, 427 insertions(+), 6 deletions(-) diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 78b111a7f6b..cbfa058934f 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -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(); diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 563cc9e86a7..5ef518f5f71 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -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, +) { + 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); diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 5df945e787a..3cb0ebbbe86 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -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 { diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index 144e9fd8a5e..ebdd92d3498 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -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,