diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2c01ef11f..00d70621ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete. - 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 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 53c419c6b92..89c9e39945e 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -642,6 +642,15 @@ openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --jso openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json ``` +Write a readable report: + +```bash +openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 \ + --format markdown --output meet-artifacts.md +openclaw googlemeet attendance --conference-record conferenceRecords/abc123 \ + --format markdown --output meet-attendance.md +``` + `artifacts` returns conference record metadata plus participant, recording, transcript, structured transcript-entry, and smart-note resource metadata when Google exposes it for the meeting. Use `--no-transcript-entries` to skip diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 66ef97d6adb..2ca27049171 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 { Command } from "commander"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; @@ -915,6 +918,48 @@ describe("google-meet plugin", () => { } }); + it("CLI artifacts writes markdown output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); + const outputPath = path.join(tempDir, "artifacts.md"); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + "--output", + outputPath, + ], + { from: "user" }, + ); + const markdown = readFileSync(outputPath, "utf8"); + expect(stdout.output()).toContain(`wrote: ${outputPath}`); + expect(markdown).toContain("# Google Meet Artifacts"); + expect(markdown).toContain("## conferenceRecords/rec-1"); + expect(markdown).toContain("### Transcript Entries: conferenceRecords/rec-1/transcripts/t1"); + expect(markdown).toContain("Hello from the transcript."); + } finally { + stdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("CLI attendance prints participant sessions by default", async () => { stubMeetArtifactsApi(); const program = new Command(); @@ -949,6 +994,42 @@ describe("google-meet plugin", () => { } }); + it("CLI attendance prints markdown output", async () => { + stubMeetArtifactsApi(); + const program = new Command(); + const stdout = captureStdout(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig({}), + ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime, + }); + + try { + await program.parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("# Google Meet Attendance"); + expect(stdout.output()).toContain("## Alice"); + expect(stdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + stdout.restore(); + } + }); + it("CLI doctor prints human-readable session health", async () => { const program = new Command(); const stdout = captureStdout(); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index afc4b77f0a7..726b3bfa7f1 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -1,3 +1,4 @@ +import { writeFile } from "node:fs/promises"; import { createInterface } from "node:readline/promises"; import { format } from "node:util"; import type { Command } from "commander"; @@ -52,6 +53,8 @@ type MeetArtifactOptions = ResolveSpaceOptions & { conferenceRecord?: string; pageSize?: string; transcriptEntries?: boolean; + format?: "summary" | "markdown"; + output?: string; }; type SetupOptions = { @@ -98,6 +101,15 @@ function writeStdoutLine(...values: unknown[]): void { process.stdout.write(`${format(...values)}\n`); } +async function writeCliOutput(options: { output?: string }, text: string): Promise { + if (options.output?.trim()) { + await writeFile(options.output, text.endsWith("\n") ? text : `${text}\n`, "utf8"); + writeStdoutLine("wrote: %s", options.output); + return; + } + process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); +} + async function promptInput(message: string): Promise { const rl = createInterface({ input: process.stdin, @@ -535,6 +547,123 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { } } +function pushMarkdownLine(lines: string[], text = ""): void { + lines.push(text); +} + +function formatMarkdownOptional(value: unknown): string { + return typeof value === "string" && value.trim() ? value : "n/a"; +} + +function formatMarkdownIdentity(row: GoogleMeetAttendanceResult["attendance"][number]): string { + return row.displayName || row.user || row.participant; +} + +function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string { + const lines: string[] = ["# Google Meet Artifacts"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + if (result.space) { + pushMarkdownLine(lines, `Space: ${result.space.name}`); + } + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`); + for (const entry of result.artifacts) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`); + pushMarkdownLine(lines, `Started: ${formatMarkdownOptional(entry.conferenceRecord.startTime)}`); + pushMarkdownLine(lines, `Ended: ${formatMarkdownOptional(entry.conferenceRecord.endTime)}`); + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Participants: ${entry.participants.length}`); + pushMarkdownLine(lines, `Recordings: ${entry.recordings.length}`); + pushMarkdownLine(lines, `Transcripts: ${entry.transcripts.length}`); + pushMarkdownLine( + lines, + `Transcript entries: ${entry.transcriptEntries.reduce( + (count, transcript) => count + transcript.entries.length, + 0, + )}`, + ); + pushMarkdownLine(lines, `Smart notes: ${entry.smartNotes.length}`); + if (entry.recordings.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Recordings"); + for (const recording of entry.recordings) { + pushMarkdownLine(lines, `- ${recording.name}`); + } + } + if (entry.transcripts.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Transcripts"); + for (const transcript of entry.transcripts) { + pushMarkdownLine(lines, `- ${transcript.name}`); + } + } + for (const transcriptEntries of entry.transcriptEntries) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `### Transcript Entries: ${transcriptEntries.transcript}`); + if (transcriptEntries.entriesError) { + pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`); + continue; + } + if (transcriptEntries.entries.length === 0) { + pushMarkdownLine(lines, "_No transcript entries._"); + continue; + } + for (const transcriptEntry of transcriptEntries.entries) { + const times = + transcriptEntry.startTime || transcriptEntry.endTime + ? ` (${formatMarkdownOptional(transcriptEntry.startTime)} -> ${formatMarkdownOptional( + transcriptEntry.endTime, + )})` + : ""; + const speaker = transcriptEntry.participant ? `${transcriptEntry.participant}: ` : ""; + pushMarkdownLine(lines, `- ${speaker}${transcriptEntry.text ?? ""}${times}`); + } + } + if (entry.smartNotes.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Smart Notes"); + for (const smartNote of entry.smartNotes) { + pushMarkdownLine(lines, `- ${smartNote.name}`); + } + } + } + return `${lines.join("\n")}\n`; +} + +function renderAttendanceMarkdown(result: GoogleMeetAttendanceResult): string { + const lines: string[] = ["# Google Meet Attendance"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + if (result.space) { + pushMarkdownLine(lines, `Space: ${result.space.name}`); + } + pushMarkdownLine(lines); + pushMarkdownLine(lines, `Conference records: ${result.conferenceRecords.length}`); + pushMarkdownLine(lines, `Attendance rows: ${result.attendance.length}`); + for (const row of result.attendance) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${formatMarkdownIdentity(row)}`); + pushMarkdownLine(lines, `Record: ${row.conferenceRecord}`); + pushMarkdownLine(lines, `Resource: ${row.participant}`); + pushMarkdownLine(lines, `First joined: ${formatMarkdownOptional(row.earliestStartTime)}`); + pushMarkdownLine(lines, `Last left: ${formatMarkdownOptional(row.latestEndTime)}`); + pushMarkdownLine(lines, `Sessions: ${row.sessions.length}`); + for (const session of row.sessions) { + pushMarkdownLine( + lines, + `- ${session.name}: ${formatMarkdownOptional(session.startTime)} -> ${formatMarkdownOptional( + session.endTime, + )}`, + ); + } + } + return `${lines.join("\n")}\n`; +} + export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; @@ -857,6 +986,8 @@ export function registerGoogleMeetCli(params: { .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--format ", "Output format: summary or markdown", "summary") + .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -869,12 +1000,26 @@ export function registerGoogleMeetCli(params: { includeTranscriptEntries: resolved.includeTranscriptEntries, }); if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); + await writeCliOutput( + options, + JSON.stringify( + { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }, + null, + 2, + ), + ); return; } + if (options.format === "markdown") { + await writeCliOutput(options, renderArtifactsMarkdown(result)); + return; + } + if (options.format && options.format !== "summary") { + throw new Error("Unsupported format. Expected summary or markdown."); + } writeArtifactsSummary(result); writeStdoutLine( "token source: %s", @@ -893,6 +1038,8 @@ export function registerGoogleMeetCli(params: { .option("--client-secret ", "OAuth client secret override") .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") .option("--page-size ", "Max resources per Meet API page") + .option("--format ", "Output format: summary or markdown", "summary") + .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) .action(async (options: MeetArtifactOptions) => { const resolved = resolveArtifactTokenOptions(params.config, options); @@ -904,12 +1051,26 @@ export function registerGoogleMeetCli(params: { pageSize: resolved.pageSize, }); if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); + await writeCliOutput( + options, + JSON.stringify( + { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }, + null, + 2, + ), + ); return; } + if (options.format === "markdown") { + await writeCliOutput(options, renderAttendanceMarkdown(result)); + return; + } + if (options.format && options.format !== "summary") { + throw new Error("Unsupported format. Expected summary or markdown."); + } writeAttendanceSummary(result); writeStdoutLine( "token source: %s",