feat(google-meet): export artifacts reports

This commit is contained in:
Peter Steinberger
2026-04-25 07:53:25 +01:00
parent bb5e278f63
commit 417b1c5507
4 changed files with 260 additions and 8 deletions

View File

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

View File

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

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

View File

@@ -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<void> {
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<string> {
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 <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "Max resources per Meet API page")
.option("--no-transcript-entries", "Skip structured transcript entry lookup")
.option("--format <format>", "Output format: summary or markdown", "summary")
.option("--output <path>", "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 <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "Max resources per Meet API page")
.option("--format <format>", "Output format: summary or markdown", "summary")
.option("--output <path>", "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",