From bac2bbd243950993fe8b77c28d59f367ef09b6ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 03:00:34 -0400 Subject: [PATCH] refactor(google-meet): split CLI modules (#112937) * refactor(google-meet): split CLI modules * test(google-meet): align CLI split routing * refactor(google-meet): isolate CLI command contract --- config/max-lines-baseline.txt | 2 - .../google-meet/src/cli-artifact-commands.ts | 293 +++ .../google-meet/src/cli-artifacts.test.ts | 586 +++++ .../google-meet/src/cli-command-context.ts | 58 + extensions/google-meet/src/cli-doctor.ts | 209 ++ extensions/google-meet/src/cli-export.ts | 580 +++++ .../google-meet/src/cli-runtime-commands.ts | 278 +++ .../google-meet/src/cli-runtime.test.ts | 467 ++++ extensions/google-meet/src/cli-shared.ts | 474 ++++ .../google-meet/src/cli-space-commands.ts | 401 +++ extensions/google-meet/src/cli.test.ts | 1255 +--------- extensions/google-meet/src/cli.ts | 2165 +---------------- .../src/test-support/cli-harness.ts | 238 ++ scripts/test-projects.test-support.mjs | 42 +- test/scripts/test-projects.test.ts | 6 +- 15 files changed, 3690 insertions(+), 3364 deletions(-) create mode 100644 extensions/google-meet/src/cli-artifact-commands.ts create mode 100644 extensions/google-meet/src/cli-artifacts.test.ts create mode 100644 extensions/google-meet/src/cli-command-context.ts create mode 100644 extensions/google-meet/src/cli-doctor.ts create mode 100644 extensions/google-meet/src/cli-export.ts create mode 100644 extensions/google-meet/src/cli-runtime-commands.ts create mode 100644 extensions/google-meet/src/cli-runtime.test.ts create mode 100644 extensions/google-meet/src/cli-shared.ts create mode 100644 extensions/google-meet/src/cli-space-commands.ts create mode 100644 extensions/google-meet/src/test-support/cli-harness.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e488e1cd55cf..3ebcc074f106 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -125,8 +125,6 @@ extensions/file-transfer/src/shared/node-invoke-policy.ts extensions/firecrawl/src/firecrawl-tools.test.ts extensions/github-copilot/index.test.ts extensions/google-meet/index.test.ts -extensions/google-meet/src/cli.test.ts -extensions/google-meet/src/cli.ts extensions/google/oauth.test.ts extensions/google/realtime-voice-provider.test.ts extensions/google/realtime-voice-provider.ts diff --git a/extensions/google-meet/src/cli-artifact-commands.ts b/extensions/google-meet/src/cli-artifact-commands.ts new file mode 100644 index 000000000000..619c4837fa99 --- /dev/null +++ b/extensions/google-meet/src/cli-artifact-commands.ts @@ -0,0 +1,293 @@ +import type { GoogleMeetCalendarLookupResult } from "./calendar.js"; +import type { GoogleMeetCliCommandContext } from "./cli-command-context.js"; +import { + buildGoogleMeetExportManifest, + googleMeetExportFileNames, + renderArtifactsMarkdown, + renderAttendanceCsv, + renderAttendanceMarkdown, + writeArtifactsSummary, + writeAttendanceSummary, + writeMeetExportBundle, +} from "./cli-export.js"; +import { + type GoogleMeetExportRequest, + type MeetArtifactOptions, + writeCliOutput, + writeStdoutJson, + writeStdoutLine, +} from "./cli-shared.js"; +import { fetchGoogleMeetArtifacts, fetchGoogleMeetAttendance } from "./meet.js"; +import { resolveGoogleMeetAccessToken } from "./oauth.js"; + +export function registerGoogleMeetArtifactCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, resolveArtifactTokenOptions, resolveMeetingForToken } = context; + + root + .command("artifacts") + .description("List Meet conference records and available participant/artifact metadata") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .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("--all-conference-records", "Fetch every conference record for --meeting") + .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") + .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); + const token = await resolveGoogleMeetAccessToken(resolved); + const meeting = resolved.conferenceRecord + ? resolved.meeting + : ( + await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }) + ).meeting; + const result = await fetchGoogleMeetArtifacts({ + accessToken: token.accessToken, + meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, + includeDocumentBodies: resolved.includeDocumentBodies, + }); + if (options.json) { + 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", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("attendance") + .description("List Meet participants and participant sessions") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .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("--all-conference-records", "Fetch every conference record for --meeting") + .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") + .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") + .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") + .option("--format ", "Output format: summary, markdown, or csv", "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); + const token = await resolveGoogleMeetAccessToken(resolved); + const meeting = resolved.conferenceRecord + ? resolved.meeting + : ( + await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }) + ).meeting; + const result = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + lateAfterMinutes: resolved.lateAfterMinutes, + earlyBeforeMinutes: resolved.earlyBeforeMinutes, + }); + if (options.json) { + 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 === "csv") { + await writeCliOutput(options, renderAttendanceCsv(result)); + return; + } + if (options.format && options.format !== "summary") { + throw new Error("Unsupported format. Expected summary, markdown, or csv."); + } + writeAttendanceSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("export") + .description("Write Meet artifacts, attendance, transcript, and raw JSON into a folder") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--conference-record ", "Conference record name or id") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .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("--all-conference-records", "Fetch every conference record for --meeting") + .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") + .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") + .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") + .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") + .option("--output ", "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); + const token = await resolveGoogleMeetAccessToken(resolved); + const meetingResult: { meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult } = + resolved.conferenceRecord + ? { meeting: resolved.meeting } + : await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: resolved.meeting, + }); + const artifacts = await fetchGoogleMeetArtifacts({ + accessToken: token.accessToken, + meeting: meetingResult.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + includeTranscriptEntries: resolved.includeTranscriptEntries, + allConferenceRecords: resolved.allConferenceRecords, + includeDocumentBodies: resolved.includeDocumentBodies, + }); + const attendance = await fetchGoogleMeetAttendance({ + accessToken: token.accessToken, + meeting: meetingResult.meeting, + conferenceRecord: resolved.conferenceRecord, + pageSize: resolved.pageSize, + allConferenceRecords: resolved.allConferenceRecords, + mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, + 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 } + : {}), + }; + 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, + attendance, + zip: Boolean(options.zip), + request, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), + }); + const payload = { + ...bundle, + ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeStdoutLine("export: %s", bundle.outputDir); + for (const file of bundle.files) { + writeStdoutLine("- %s", file); + } + if (bundle.zipFile) { + writeStdoutLine("zip: %s", bundle.zipFile); + } + }); +} diff --git a/extensions/google-meet/src/cli-artifacts.test.ts b/extensions/google-meet/src/cli-artifacts.test.ts new file mode 100644 index 000000000000..ee476d92c35d --- /dev/null +++ b/extensions/google-meet/src/cli-artifacts.test.ts @@ -0,0 +1,586 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import JSZip from "jszip"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { + captureStdout, + expectFields, + firstRecord, + jsonResponse, + parseStdoutJson, + requestUrl, + setupCli, + stubMeetArtifactsApi, +} from "./test-support/cli-harness.js"; + +describe("google-meet CLI", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(() => { + vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); + vi.resetModules(); + }); + + it("prints artifacts and attendance output", async () => { + stubMeetArtifactsApi(); + + const artifactsStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--json", + ], + { from: "user" }, + ); + const payload = parseStdoutJson(artifactsStdout); + expectFields(payload, { tokenSource: "cached-access-token" }); + expectFields(firstRecord(payload.conferenceRecords), { name: "conferenceRecords/rec-1" }); + const artifact = firstRecord(payload.artifacts); + expectFields(firstRecord(artifact.recordings), { + name: "conferenceRecords/rec-1/recordings/r1", + }); + expectFields(firstRecord(artifact.transcripts), { + name: "conferenceRecords/rec-1/transcripts/t1", + }); + const transcriptEntries = firstRecord(artifact.transcriptEntries); + expectFields(transcriptEntries, { transcript: "conferenceRecords/rec-1/transcripts/t1" }); + expectFields(firstRecord(transcriptEntries.entries), { text: "Hello from the transcript." }); + expectFields(firstRecord(artifact.smartNotes), { + name: "conferenceRecords/rec-1/smartNotes/sn1", + }); + } finally { + artifactsStdout.restore(); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("attendance rows: 1"); + expect(attendanceStdout.output()).toContain("participant: Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("ends an active conference for a Meet space", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/space-resource-123", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/v2/spaces/space-resource-123:endActiveConference") { + return jsonResponse({}); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const stdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "end-active-conference", + "https://meet.google.com/abc-defg-hij", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--json", + ], + { from: "user" }, + ); + expectFields(parseStdoutJson(stdout), { + space: "spaces/space-resource-123", + ended: true, + tokenSource: "cached-access-token", + }); + const endCall = fetchMock.mock.calls.find( + ([input]) => + input === "https://meet.googleapis.com/v2/spaces/space-resource-123:endActiveConference", + ); + expect(endCall?.[1]).toEqual({ + method: "POST", + body: "{}", + headers: { + Accept: "application/json", + Authorization: "Bearer token", + "Content-Type": "application/json", + }, + }); + } finally { + stdout.restore(); + } + }); + + it("rejects access policy flags when create would use browser fallback", async () => { + await expect( + setupCli({ + runtime: { + createViaBrowser: vi.fn(async () => { + throw new Error("browser fallback should not run"); + }), + }, + }).parseAsync(["googlemeet", "create", "--access-type", "OPEN"], { from: "user" }), + ).rejects.toThrow("access policy options require OAuth/API room creation"); + }); + + it("prints the latest conference record", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--meeting", + "abc-defg-hij", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + + it("prints the latest conference record from today's calendar", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "latest", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--today", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("calendar event: Project sync"); + expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); + } finally { + stdout.restore(); + } + }); + + it("prints calendar event previews", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "calendar-events", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--today", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("meet events: 1"); + expect(stdout.output()).toContain("* Project sync"); + expect(stdout.output()).toContain("https://meet.google.com/abc-defg-hij"); + } finally { + stdout.restore(); + } + }); + + it.each(["0", "1.5", "9007199254740993"])( + "rejects invalid Meet API page sizes: %s", + async (pageSize) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + setupCli({}).parseAsync( + [ + "googlemeet", + "artifacts", + "--access-token", + "token", + "--conference-record", + "rec-1", + "--page-size", + pageSize, + ], + { from: "user" }, + ), + ).rejects.toThrow("page-size must be a positive integer"); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("prints markdown artifact and attendance output", async () => { + stubMeetArtifactsApi(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); + const outputPath = path.join(tempDir, "artifacts.md"); + const artifactsStdout = captureStdout(); + + try { + await setupCli({}).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(artifactsStdout.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 { + artifactsStdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + + const attendanceStdout = captureStdout(); + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "markdown", + ], + { from: "user" }, + ); + expect(attendanceStdout.output()).toContain("# Google Meet Attendance"); + expect(attendanceStdout.output()).toContain("## Alice"); + expect(attendanceStdout.output()).toContain( + "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + ); + } finally { + attendanceStdout.restore(); + } + }); + + it("prints CSV attendance output", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "csv", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("conferenceRecord,displayName,user"); + expect(stdout.output()).toContain("conferenceRecords/rec-1,Alice,users/alice"); + } finally { + stdout.restore(); + } + }); + + it("neutralizes spreadsheet formulas in CSV attendance output", async () => { + stubMeetArtifactsApi({ participantDisplayName: " \t=1+1" }); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "csv", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain("conferenceRecords/rec-1,' \t=1+1,users/alice"); + } finally { + stdout.restore(); + } + }); + + it("quotes carriage returns in formula-neutralized CSV cells", async () => { + stubMeetArtifactsApi({ participantDisplayName: "\r=1+1" }); + const stdout = captureStdout(); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "attendance", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--format", + "csv", + ], + { from: "user" }, + ); + expect(stdout.output()).toContain('conferenceRecords/rec-1,"\'\r=1+1",users/alice'); + } finally { + stdout.restore(); + } + }); + + it("writes an export bundle", async () => { + stubMeetArtifactsApi(); + const stdout = captureStdout(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-")); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "export", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--include-doc-bodies", + "--zip", + "--output", + tempDir, + ], + { from: "user" }, + ); + expect(stdout.output()).toContain(`export: ${tempDir}`); + expect(readFileSync(path.join(tempDir, "summary.md"), "utf8")).toContain( + "# Google Meet Artifacts", + ); + expect(readFileSync(path.join(tempDir, "attendance.csv"), "utf8")).toContain( + "conferenceRecords/rec-1,Alice,users/alice", + ); + expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain( + "Hello from the transcript.", + ); + expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain( + "Transcript document body.", + ); + const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8")); + expectFields(manifest.request, { + conferenceRecord: "rec-1", + includeDocumentBodies: true, + }); + expectFields(manifest, { + tokenSource: "cached-access-token", + }); + expectFields(manifest.counts, { attendanceRows: 1, warnings: 0 }); + expect(manifest.files).toEqual([ + "summary.md", + "attendance.csv", + "transcript.md", + "artifacts.json", + "attendance.json", + "manifest.json", + ]); + const artifacts = JSON.parse(readFileSync(path.join(tempDir, "artifacts.json"), "utf8")); + expectFields(firstRecord(artifacts.conferenceRecords), { name: "conferenceRecords/rec-1" }); + expectFields(firstRecord(firstRecord(artifacts.artifacts).transcripts), { + documentText: "Transcript document body.", + }); + const zip = await JSZip.loadAsync(readFileSync(`${tempDir}.zip`)); + expect(await zip.file("summary.md")?.async("string")).toContain("# Google Meet Artifacts"); + } finally { + stdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + rmSync(`${tempDir}.zip`, { force: true }); + } + }); + + it("neutralizes spreadsheet formulas in exported attendance CSV files", async () => { + stubMeetArtifactsApi({ participantDisplayName: "\uFF1D1+1" }); + const stdout = captureStdout(); + const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-csv-")); + + try { + await setupCli({}).parseAsync( + [ + "googlemeet", + "export", + "--access-token", + "token", + "--expires-at", + String(Date.now() + 120_000), + "--conference-record", + "rec-1", + "--output", + tempDir, + ], + { from: "user" }, + ); + expect(readFileSync(path.join(tempDir, "attendance.csv"), "utf8")).toContain( + "conferenceRecords/rec-1,'\uFF1D1+1,users/alice", + ); + } finally { + stdout.restore(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + 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")); + expectFields(manifest.counts, { warnings: 1 }); + expectFields(firstRecord(manifest.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 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()); + expectFields(payload, { + dryRun: true, + tokenSource: "cached-access-token", + }); + expectFields(payload.manifest.request, { + conferenceRecord: "rec-1", + includeDocumentBodies: true, + }); + expectFields(payload.manifest.counts, { + attendanceRows: 1, + transcriptEntries: 1, + warnings: 0, + }); + expect(payload.manifest.files).toEqual([ + "summary.md", + "attendance.csv", + "transcript.md", + "artifacts.json", + "attendance.json", + "manifest.json", + ]); + expect(existsSync(outputDir)).toBe(false); + } finally { + stdout.restore(); + rmSync(parentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/google-meet/src/cli-command-context.ts b/extensions/google-meet/src/cli-command-context.ts new file mode 100644 index 000000000000..7ace8660881a --- /dev/null +++ b/extensions/google-meet/src/cli-command-context.ts @@ -0,0 +1,58 @@ +import type { Command } from "commander"; +import type { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import type { GoogleMeetCalendarLookupResult } from "./calendar.js"; +import type { CreateOptions, MeetArtifactOptions, ResolveSpaceOptions } from "./cli-shared.js"; +import type { GoogleMeetConfig } from "./config.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +type GoogleMeetOAuthTokenOptions = { + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresAt?: number; +}; + +type GoogleMeetArtifactTokenOptions = GoogleMeetOAuthTokenOptions & { + meeting?: string; + conferenceRecord?: string; + pageSize?: number; + includeTranscriptEntries?: boolean; + allConferenceRecords?: boolean; + includeDocumentBodies?: boolean; + mergeDuplicateParticipants?: boolean; + lateAfterMinutes?: number; + earlyBeforeMinutes?: number; +}; + +export type GoogleMeetCliCommandContext = { + root: Command; + config: GoogleMeetConfig; + ensureRuntime: () => Promise; + callGateway: typeof callGatewayFromCli; + operationTimeoutMs: number; + resolveMeetingInput: (config: GoogleMeetConfig, value?: string) => string; + resolveOAuthTokenOptions: ( + config: GoogleMeetConfig, + options: ResolveSpaceOptions, + ) => GoogleMeetOAuthTokenOptions; + resolveTokenOptions: ( + config: GoogleMeetConfig, + options: ResolveSpaceOptions, + ) => GoogleMeetOAuthTokenOptions & { meeting: string }; + resolveMeetingForToken: (params: { + config: GoogleMeetConfig; + options: ResolveSpaceOptions; + accessToken: string; + configuredMeeting?: string; + }) => Promise<{ meeting: string; calendarEvent?: GoogleMeetCalendarLookupResult }>; + resolveCreateTokenOptions: ( + config: GoogleMeetConfig, + options: CreateOptions, + ) => GoogleMeetOAuthTokenOptions; + resolveArtifactTokenOptions: ( + config: GoogleMeetConfig, + options: MeetArtifactOptions, + ) => GoogleMeetArtifactTokenOptions; + hasCreateOAuth: (config: GoogleMeetConfig, options: CreateOptions) => boolean; +}; diff --git a/extensions/google-meet/src/cli-doctor.ts b/extensions/google-meet/src/cli-doctor.ts new file mode 100644 index 000000000000..c327be5a2c22 --- /dev/null +++ b/extensions/google-meet/src/cli-doctor.ts @@ -0,0 +1,209 @@ +import type { GoogleMeetCliCommandContext } from "./cli-command-context.js"; +import { + callGoogleMeetGateway, + parseOptionalNumber, + type DoctorOptions, + writeDoctorStatus, + writeStdoutJson, + writeStdoutLine, +} from "./cli-shared.js"; +import type { GoogleMeetConfig } from "./config.js"; +import { createGoogleMeetSpace, fetchGoogleMeetSpace } from "./meet.js"; +import { resolveGoogleMeetAccessToken } from "./oauth.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +type OAuthDoctorCheck = { + id: string; + ok: boolean; + message: string; +}; + +type OAuthDoctorReport = { + ok: boolean; + configured: boolean; + tokenSource?: "cached-access-token" | "refresh-token"; + expiresAt?: number; + scope?: string; + meetingUri?: string; + createdSpace?: string; + checks: OAuthDoctorCheck[]; +}; + +function sanitizeOAuthErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") + .replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]"); +} + +async function buildOAuthDoctorReport( + config: GoogleMeetConfig, + options: DoctorOptions, +): Promise { + const clientId = options.clientId?.trim() || config.oauth.clientId; + const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret; + const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken; + const accessToken = options.accessToken?.trim() || config.oauth.accessToken; + const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt; + const checks: OAuthDoctorCheck[] = []; + + const hasRefreshConfig = Boolean(clientId && refreshToken); + const hasAccessConfig = Boolean(accessToken); + if (!hasRefreshConfig && !hasAccessConfig) { + checks.push({ + id: "oauth-config", + ok: false, + message: + "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.", + }); + return { ok: false, configured: false, checks }; + } + + checks.push({ + id: "oauth-config", + ok: true, + message: hasRefreshConfig + ? "Google Meet OAuth refresh credentials are configured" + : "Google Meet cached access token is configured", + }); + + let token: Awaited>; + try { + token = await resolveGoogleMeetAccessToken({ + clientId, + clientSecret, + refreshToken, + accessToken, + expiresAt, + }); + checks.push({ + id: "oauth-token", + ok: true, + message: token.refreshed + ? "Refresh token minted an access token" + : "Cached access token is still valid", + }); + } catch (error) { + checks.push({ + id: "oauth-token", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + return { ok: false, configured: true, checks }; + } + + const report: OAuthDoctorReport = { + ok: true, + configured: true, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + expiresAt: token.expiresAt, + checks, + }; + + const meeting = options.meeting?.trim(); + if (meeting) { + try { + const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting }); + checks.push({ + id: "meet-spaces-get", + ok: true, + message: `Resolved ${space.name}`, + }); + report.meetingUri = space.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-get", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + if (options.createSpace) { + try { + const created = await createGoogleMeetSpace({ accessToken: token.accessToken }); + checks.push({ + id: "meet-spaces-create", + ok: true, + message: `Created ${created.space.name}`, + }); + report.createdSpace = created.space.name; + report.meetingUri = created.meetingUri; + } catch (error) { + checks.push({ + id: "meet-spaces-create", + ok: false, + message: sanitizeOAuthErrorMessage(error), + }); + } + } + + report.ok = checks.every((check) => check.ok); + return report; +} + +function writeOAuthDoctorReport(report: OAuthDoctorReport): void { + writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention"); + writeStdoutLine("configured: %s", report.configured ? "yes" : "no"); + if (report.tokenSource) { + writeStdoutLine("token source: %s", report.tokenSource); + } + if (report.meetingUri) { + writeStdoutLine("meeting uri: %s", report.meetingUri); + } + for (const check of report.checks) { + writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); + } +} + +export function registerGoogleMeetDoctorCommand(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, callGateway } = context; + + root + .command("doctor") + .description("Show human-readable Meet session/browser/realtime health") + .argument("[session-id]", "Meet session ID") + .option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false) + .option("--meeting ", "Also verify spaces.get for a Meet URL, code, or spaces/{id}") + .option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false) + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (sessionId: string | undefined, options: DoctorOptions) => { + if (options.oauth) { + const report = await buildOAuthDoctorReport(params.config, options); + if (options.json) { + writeStdoutJson(report); + return; + } + writeOAuthDoctorReport(report); + return; + } + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.status", + payload: { sessionId }, + }); + if (delegated.ok) { + const status = delegated.payload as Awaited>; + if (options.json) { + writeStdoutJson(status); + return; + } + writeDoctorStatus(status); + return; + } + const rt = await params.ensureRuntime(); + const status = await rt.status(sessionId); + if (options.json) { + writeStdoutJson(status); + return; + } + writeDoctorStatus(status); + }); +} diff --git a/extensions/google-meet/src/cli-export.ts b/extensions/google-meet/src/cli-export.ts new file mode 100644 index 000000000000..3fdd17245ee6 --- /dev/null +++ b/extensions/google-meet/src/cli-export.ts @@ -0,0 +1,580 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import JSZip from "jszip"; +import { listGoogleMeetCalendarEvents, type GoogleMeetCalendarLookupResult } from "./calendar.js"; +import { + formatDuration, + formatOptional, + type GoogleMeetExportManifest, + type GoogleMeetExportRequest, + type GoogleMeetExportWarning, + writeStdoutLine, +} from "./cli-shared.js"; +import type { + GoogleMeetArtifactsResult, + GoogleMeetAttendanceResult, + GoogleMeetLatestConferenceRecordResult, +} from "./meet.js"; + +export function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + for (const entry of result.artifacts) { + writeStdoutLine(""); + writeStdoutLine("record: %s", entry.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime)); + writeStdoutLine("participants: %d", entry.participants.length); + writeStdoutLine("recordings: %d", entry.recordings.length); + writeStdoutLine("transcripts: %d", entry.transcripts.length); + writeStdoutLine( + "transcript entries: %d", + entry.transcriptEntries.reduce((count, transcript) => count + transcript.entries.length, 0), + ); + writeStdoutLine("smart notes: %d", entry.smartNotes.length); + if (entry.smartNotesError) { + writeStdoutLine("smart notes warning: %s", entry.smartNotesError); + } + for (const recording of entry.recordings) { + writeStdoutLine("- recording: %s", recording.name); + } + 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) { + writeStdoutLine( + "- transcript entries warning: %s: %s", + transcriptEntries.transcript, + transcriptEntries.entriesError, + ); + } + } + for (const smartNote of entry.smartNotes) { + writeStdoutLine("- smart note: %s", smartNote.name); + if (smartNote.documentTextError) { + writeStdoutLine("- smart note document body warning: %s", smartNote.documentTextError); + } + } + } +} + +export function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { + if (result.input) { + writeStdoutLine("input: %s", result.input); + } + if (result.space) { + writeStdoutLine("space: %s", result.space.name); + } + writeStdoutLine("conference records: %d", result.conferenceRecords.length); + writeStdoutLine("attendance rows: %d", result.attendance.length); + for (const row of result.attendance) { + const identity = row.displayName || row.user || row.participant; + writeStdoutLine(""); + writeStdoutLine("participant: %s", identity); + writeStdoutLine("record: %s", row.conferenceRecord); + writeStdoutLine("resource: %s", row.participant); + writeStdoutLine("participants merged: %d", row.participants?.length ?? 1); + writeStdoutLine("first joined: %s", formatOptional(row.firstJoinTime ?? row.earliestStartTime)); + writeStdoutLine("last left: %s", formatOptional(row.lastLeaveTime ?? row.latestEndTime)); + writeStdoutLine("duration: %s", formatDuration(row.durationMs)); + writeStdoutLine("late: %s", row.late ? formatDuration(row.lateByMs) : "no"); + writeStdoutLine("early leave: %s", row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"); + writeStdoutLine("sessions: %d", row.sessions.length); + for (const session of row.sessions) { + writeStdoutLine( + "- %s: %s -> %s", + session.name, + formatOptional(session.startTime), + formatOptional(session.endTime), + ); + } + } +} + +export function writeLatestConferenceRecordSummary( + result: GoogleMeetLatestConferenceRecordResult, +): void { + writeStdoutLine("input: %s", result.input); + writeStdoutLine("space: %s", result.space.name); + if (!result.conferenceRecord) { + writeStdoutLine("conference record: none"); + return; + } + writeStdoutLine("conference record: %s", result.conferenceRecord.name); + writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime)); + writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime)); +} + +export function writeCalendarEventsSummary( + result: Awaited>, +): void { + writeStdoutLine("calendar: %s", result.calendarId); + writeStdoutLine("meet events: %d", result.events.length); + for (const entry of result.events) { + writeStdoutLine(""); + writeStdoutLine("%s%s", entry.selected ? "* " : "- ", entry.event.summary ?? "untitled"); + writeStdoutLine("meeting uri: %s", entry.meetingUri); + writeStdoutLine( + "starts: %s", + formatOptional(entry.event.start?.dateTime ?? entry.event.start?.date), + ); + writeStdoutLine("ends: %s", formatOptional(entry.event.end?.dateTime ?? entry.event.end?.date)); + } +} + +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 participantDisplayName( + entry: GoogleMeetArtifactsResult["artifacts"][number], + name: string, +): string { + const participant = entry.participants.find((candidate) => candidate.name === name); + if (!participant) { + return name; + } + return ( + participant.signedinUser?.displayName ?? + participant.anonymousUser?.displayName ?? + participant.phoneUser?.displayName ?? + participant.signedinUser?.user ?? + name + ); +} + +export 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}`); + 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"); + 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}`); + if (transcript.documentTextError) { + pushMarkdownLine(lines, ` - Document body warning: ${transcript.documentTextError}`); + } else if (transcript.documentText) { + pushMarkdownLine(lines, ` - Document body: ${transcript.documentText.length} chars`); + } + } + } + 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 + ? `${participantDisplayName(entry, 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}`); + if (smartNote.documentTextError) { + pushMarkdownLine(lines, ` - Document body warning: ${smartNote.documentTextError}`); + } else if (smartNote.documentText) { + pushMarkdownLine(lines, ` - Document body: ${smartNote.documentText.length} chars`); + } + } + } + } + return `${lines.join("\n")}\n`; +} + +export 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, `Participants merged: ${row.participants?.length ?? 1}`); + pushMarkdownLine( + lines, + `First joined: ${formatMarkdownOptional(row.firstJoinTime ?? row.earliestStartTime)}`, + ); + pushMarkdownLine( + lines, + `Last left: ${formatMarkdownOptional(row.lastLeaveTime ?? row.latestEndTime)}`, + ); + pushMarkdownLine(lines, `Duration: ${formatDuration(row.durationMs)}`); + pushMarkdownLine(lines, `Late: ${row.late ? formatDuration(row.lateByMs) : "no"}`); + pushMarkdownLine( + lines, + `Early leave: ${row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"}`, + ); + 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`; +} + +function neutralizeSpreadsheetFormulaCell(text: string): string { + return /^[ \t\r\n]*[=+\-@\uFF0B\uFF0D\uFF1D\uFF20]/u.test(text) || /^[\t\r\n]/.test(text) + ? `'${text}` + : text; +} + +function csvCell(value: unknown): string { + const text = + value === undefined || value === null + ? "" + : typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? String(value) + : JSON.stringify(value); + const safeText = neutralizeSpreadsheetFormulaCell(text); + return /[",\r\n]/.test(safeText) ? `"${safeText.replaceAll('"', '""')}"` : safeText; +} + +export function renderAttendanceCsv(result: GoogleMeetAttendanceResult): string { + const rows: unknown[][] = [ + [ + "conferenceRecord", + "displayName", + "user", + "participants", + "firstJoined", + "lastLeft", + "durationMs", + "sessions", + "late", + "lateByMs", + "earlyLeave", + "earlyLeaveByMs", + ], + ]; + for (const row of result.attendance) { + rows.push([ + row.conferenceRecord, + row.displayName ?? "", + row.user ?? "", + (row.participants ?? [row.participant]).join(";"), + row.firstJoinTime ?? row.earliestStartTime ?? "", + row.lastLeaveTime ?? row.latestEndTime ?? "", + row.durationMs ?? "", + row.sessions.length, + row.late ?? "", + row.lateByMs ?? "", + row.earlyLeave ?? "", + row.earlyLeaveByMs ?? "", + ]); + } + return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`; +} + +function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string { + const lines: string[] = ["# Google Meet Transcript"]; + if (result.input) { + pushMarkdownLine(lines, `Input: ${result.input}`); + } + for (const entry of result.artifacts) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`); + if (entry.transcriptEntries.length === 0) { + pushMarkdownLine(lines, "_No transcript entries._"); + continue; + } + for (const transcriptEntries of entry.transcriptEntries) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `### ${transcriptEntries.transcript}`); + if (transcriptEntries.entriesError) { + pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`); + continue; + } + for (const transcriptEntry of transcriptEntries.entries) { + const speaker = transcriptEntry.participant + ? participantDisplayName(entry, transcriptEntry.participant) + : "unknown"; + const time = transcriptEntry.startTime ? ` [${transcriptEntry.startTime}]` : ""; + pushMarkdownLine(lines, `- ${speaker}${time}: ${transcriptEntry.text ?? ""}`); + } + } + const docsTranscripts = entry.transcripts.filter((transcript) => transcript.documentText); + if (docsTranscripts.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Transcript Document Bodies"); + for (const transcript of docsTranscripts) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `#### ${transcript.name}`); + pushMarkdownLine(lines, transcript.documentText?.trim() || "_Empty document body._"); + } + } + const smartNotes = entry.smartNotes.filter((smartNote) => smartNote.documentText); + if (smartNotes.length > 0) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, "### Smart Note Document Bodies"); + for (const smartNote of smartNotes) { + pushMarkdownLine(lines); + pushMarkdownLine(lines, `#### ${smartNote.name}`); + pushMarkdownLine(lines, smartNote.documentText?.trim() || "_Empty document body._"); + } + } + } + return `${lines.join("\n")}\n`; +} + +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; +} + +export 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, + }; +} + +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, "-")}`; +} + +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 = googleMeetExportFileNames(); + const files = [ + { + name: "summary.md", + content: `${renderArtifactsMarkdown(params.artifacts)}\n${renderAttendanceMarkdown(params.attendance)}`, + }, + { name: "attendance.csv", content: renderAttendanceCsv(params.attendance) }, + { 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"); + } + const result: { outputDir: string; files: string[]; zipFile?: string } = { + outputDir, + files: files.map((file) => path.join(outputDir, file.name)), + }; + if (zipFile) { + const zip = new JSZip(); + for (const file of files) { + zip.file(file.name, file.content); + } + await writeFile(zipFile, await zip.generateAsync({ type: "nodebuffer" })); + result.zipFile = zipFile; + } + return result; +} diff --git a/extensions/google-meet/src/cli-runtime-commands.ts b/extensions/google-meet/src/cli-runtime-commands.ts new file mode 100644 index 000000000000..8fabe677715e --- /dev/null +++ b/extensions/google-meet/src/cli-runtime-commands.ts @@ -0,0 +1,278 @@ +import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; +import type { GoogleMeetCliCommandContext } from "./cli-command-context.js"; +import { + callGoogleMeetGateway, + parsePositiveNumber, + type JoinOptions, + type RecoverTabOptions, + type SetupOptions, + writeLeaveResult, + writeRecoverCurrentTabResult, + writeSetupStatus, + writeStdoutJson, + writeStdoutLine, +} from "./cli-shared.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +export function registerGoogleMeetProbeCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, callGateway, operationTimeoutMs, resolveMeetingInput } = context; + + root + .command("join") + .argument("[url]", "Explicit https://meet.google.com/... URL") + .option("--transport ", "Transport: chrome, chrome-node, or twilio") + .option("--mode ", "Mode: agent, bidi, or transcribe") + .option("--message ", "Realtime speech to trigger after join") + .option("--dial-in-number ", "Meet dial-in number for Twilio transport") + .option("--pin ", "Meet phone PIN; # is appended if omitted") + .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") + .action(async (url: string | undefined, options: JoinOptions) => { + const payload = { + url: resolveMeetingInput(params.config, url), + transport: options.transport, + mode: options.mode, + message: options.message, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.join", + payload, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + const result = delegated.payload as { session?: unknown }; + writeStdoutJson(result.session ?? delegated.payload); + return; + } + const rt = await params.ensureRuntime(); + const result = await rt.join(payload); + writeStdoutJson(result.session); + }); + + root + .command("test-speech") + .argument("[url]", "Explicit https://meet.google.com/... URL") + .option("--transport ", "Transport: chrome, chrome-node, or twilio") + .option("--mode ", "Mode: agent, bidi, or transcribe") + .option( + "--message ", + "Realtime speech to trigger", + "Say exactly: Google Meet speech test complete.", + ) + .action(async (url: string | undefined, options: JoinOptions) => { + const payload = { + url: resolveMeetingInput(params.config, url), + transport: options.transport, + mode: options.mode, + message: options.message, + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.testSpeech", + payload, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } + const rt = await params.ensureRuntime(); + writeStdoutJson(await rt.testSpeech(payload)); + }); + + root + .command("test-listen") + .argument("[url]", "Explicit https://meet.google.com/... URL") + .option("--transport ", "Transport: chrome or chrome-node") + .option("--timeout-ms ", "How long to wait for fresh captions/transcript movement") + .action(async (url: string | undefined, options: JoinOptions) => { + const payload = { + url: resolveMeetingInput(params.config, url), + transport: options.transport, + timeoutMs: parsePositiveNumber(options.timeoutMs, "timeout-ms"), + }; + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.testListen", + payload, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } + const rt = await params.ensureRuntime(); + writeStdoutJson(await rt.testListen(payload)); + }); +} + +export function registerGoogleMeetSessionCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, callGateway } = context; + + root + .command("status") + .argument("[session-id]", "Meet session ID") + .option("--json", "Print JSON output", false) + .action(async (sessionId?: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.status", + payload: { sessionId }, + }); + if (delegated.ok) { + writeStdoutJson(delegated.payload); + return; + } + const rt = await params.ensureRuntime(); + writeStdoutJson(await rt.status(sessionId)); + }); + + root + .command("transcript") + .description("Print the bounded caption transcript for a Meet session") + .argument("", "Meet session ID") + .option("--since ", "Resume from the previous response's nextIndex") + .option("--json", "Print JSON output", false) + .action(async (sessionId: string, options: { since?: string; json?: boolean }) => { + const sinceIndex = parseStrictNonNegativeInteger(options.since); + if (options.since !== undefined && sinceIndex === undefined) { + throw new Error("--since must be a non-negative safe integer"); + } + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.transcript", + payload: { sessionId, ...(sinceIndex === undefined ? {} : { sinceIndex }) }, + }); + const result = delegated.ok + ? (delegated.payload as Awaited>) + : await ( + await params.ensureRuntime() + ).transcript(sessionId, sinceIndex === undefined ? {} : { sinceIndex }); + if (!result.found) { + throw new Error("session not found"); + } + if (options.json) { + writeStdoutJson(result); + return; + } + if (result.evicted) { + writeStdoutLine("# transcript evicted from runtime memory"); + } else if (result.droppedLines) { + writeStdoutLine("# %d earlier lines dropped by the transcript cap", result.droppedLines); + } + for (const line of result.lines ?? []) { + writeStdoutLine( + "%s%s%s", + line.at ? `[${line.at}] ` : "", + line.speaker ? `${line.speaker}: ` : "", + line.text, + ); + } + writeStdoutLine("# nextIndex: %d", result.nextIndex ?? 0); + }); +} + +export function registerGoogleMeetLifecycleCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, callGateway } = context; + + root + .command("recover-tab") + .description("Focus and inspect an existing Google Meet tab") + .argument("[url]", "Optional Meet URL to match") + .option("--transport ", "Transport to inspect: chrome or chrome-node") + .option("--json", "Print JSON output", false) + .action(async (url: string | undefined, options: RecoverTabOptions) => { + const rt = await params.ensureRuntime(); + const result = await rt.recoverCurrentTab({ url, transport: options.transport }); + if (options.json) { + writeStdoutJson(result); + return; + } + writeRecoverCurrentTabResult(result); + }); + + root + .command("setup") + .description("Show Google Meet transport setup status") + .option("--transport ", "Transport to check: chrome, chrome-node, or twilio") + .option("--mode ", "Mode to check: agent, bidi, or transcribe") + .option("--json", "Print JSON output", false) + .action(async (options: SetupOptions) => { + const rt = await params.ensureRuntime(); + const status = await rt.setupStatus({ transport: options.transport, mode: options.mode }); + if (options.json) { + writeStdoutJson(status); + return; + } + writeSetupStatus(status); + }); + + root + .command("leave") + .argument("", "Meet session ID") + .action(async (sessionId: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.leave", + payload: { sessionId }, + }); + if (delegated.ok) { + const result = delegated.payload as { found?: boolean; browserLeft?: boolean }; + if (!result.found) { + throw new Error("session not found"); + } + writeLeaveResult(sessionId, result); + return; + } + const rt = await params.ensureRuntime(); + const result = await rt.leave(sessionId); + if (!result.found) { + throw new Error("session not found"); + } + writeLeaveResult(sessionId, result); + }); + + root + .command("speak") + .argument("", "Meet session ID") + .argument("[message]", "Realtime instructions to speak now") + .action(async (sessionId: string, message?: string) => { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.speak", + payload: { sessionId, message }, + }); + if (delegated.ok) { + const result = delegated.payload as Awaited>; + if (!result.found) { + throw new Error("session not found"); + } + if (!result.spoken) { + throw new Error( + result.session?.chrome?.health?.speechBlockedMessage ?? + "session has no active realtime audio bridge", + ); + } + writeStdoutLine("speaking on %s", sessionId); + return; + } + const rt = await params.ensureRuntime(); + const result = await rt.speak(sessionId, message); + if (!result.found) { + throw new Error("session not found"); + } + if (!result.spoken) { + throw new Error( + result.session?.chrome?.health?.speechBlockedMessage ?? + "session has no active realtime audio bridge", + ); + } + writeStdoutLine("speaking on %s", sessionId); + }); +} diff --git a/extensions/google-meet/src/cli-runtime.test.ts b/extensions/google-meet/src/cli-runtime.test.ts new file mode 100644 index 000000000000..9854139612a0 --- /dev/null +++ b/extensions/google-meet/src/cli-runtime.test.ts @@ -0,0 +1,467 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { testing } from "./cli-shared.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; +import { + captureStdout, + expectFields, + firstRecord, + parseStdoutJson, + setupCli, +} from "./test-support/cli-harness.js"; + +describe("google-meet CLI", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(() => { + vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); + vi.resetModules(); + }); + + it("prints setup checks as text and JSON", async () => { + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: true, + checks: [ + { + id: "audio-bridge", + ok: true, + message: "Chrome command-pair talk-back audio bridge configured (pcm16-24khz)", + }, + ], + }), + }, + }).parseAsync(["googlemeet", "setup"], { from: "user" }); + expect(stdout.output()).toContain("Google Meet setup: OK"); + expect(stdout.output()).toContain( + "[ok] audio-bridge: Chrome command-pair talk-back audio bridge configured (pcm16-24khz)", + ); + expect(stdout.output()).not.toContain('"checks"'); + } finally { + stdout.restore(); + } + } + + { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + setupStatus: async () => ({ + ok: false, + checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], + }), + }, + }).parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); + const payload = parseStdoutJson(stdout); + expectFields(payload, { ok: false }); + expectFields(firstRecord(payload.checks), { + id: "twilio-voice-call-plugin", + ok: false, + }); + } finally { + stdout.restore(); + } + } + }); + + it("accepts --json on session status", async () => { + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { + status: async () => ({ + found: true, + sessions: [ + { + id: "meet_1", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "twilio", + mode: "agent", + agentId: "main", + participantIdentity: "Twilio PSTN participant", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + ], + }), + }, + }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); + const payload = parseStdoutJson(stdout); + expectFields(payload, { found: true }); + expectFields(firstRecord(payload.sessions), { + id: "meet_1", + transport: "twilio", + }); + } finally { + stdout.restore(); + } + }); + + it("delegates session status to the gateway-owned runtime when available", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + found: true, + sessions: [ + { + id: "meet_gateway", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "agent", + agentId: "main", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + ], + })); + const ensureRuntime = vi.fn(async () => { + throw new Error("local runtime should not be loaded"); + }); + const stdout = captureStdout(); + try { + await setupCli({ + callGatewayFromCli, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.status", + { json: true, timeout: "5000" }, + { sessionId: undefined }, + { progress: false }, + ); + expect(ensureRuntime).not.toHaveBeenCalled(); + const payload = parseStdoutJson(stdout); + expectFields(payload, { found: true }); + expectFields(firstRecord(payload.sessions), { + id: "meet_gateway", + transport: "chrome-node", + }); + } finally { + stdout.restore(); + } + }); + + it("prints cursor-based transcripts from the gateway-owned runtime", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + found: true, + sessionId: "meet_gateway", + startIndex: 3, + nextIndex: 4, + droppedLines: 2, + lines: [{ at: "2026-07-12T06:00:00.000Z", speaker: "Alice", text: "fourth line" }], + })); + const stdout = captureStdout(); + try { + await setupCli({ callGatewayFromCli }).parseAsync( + ["googlemeet", "transcript", "meet_gateway", "--since", "3"], + { from: "user" }, + ); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.transcript", + { json: true, timeout: "5000" }, + { sessionId: "meet_gateway", sinceIndex: 3 }, + { progress: false }, + ); + expect(stdout.output()).toContain("# 2 earlier lines dropped by the transcript cap"); + expect(stdout.output()).toContain("Alice: fourth line"); + expect(stdout.output()).toContain("# nextIndex: 4"); + } finally { + stdout.restore(); + } + }); + + it.each([ + ["0", 0], + ["3", 3], + ["+3", 3], + [" 3 ", 3], + [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], + ] as const)("accepts base-10 safe transcript cursors: %s", async (since, expected) => { + const callGatewayFromCli = vi.fn(async () => ({ + found: true, + sessionId: "meet_gateway", + startIndex: expected, + nextIndex: expected, + lines: [], + })); + + await setupCli({ callGatewayFromCli }).parseAsync( + ["googlemeet", "transcript", "meet_gateway", "--since", since], + { from: "user" }, + ); + + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.transcript", + { json: true, timeout: "5000" }, + { sessionId: "meet_gateway", sinceIndex: expected }, + { progress: false }, + ); + }); + + it.each(["", " ", "-1", "0x10", "0o10", "0b10", "1e0", "1.5", "9007199254740992"])( + "rejects non-decimal transcript cursors before gateway delegation: %s", + async (since) => { + const callGatewayFromCli = vi.fn(); + + await expect( + setupCli({ callGatewayFromCli }).parseAsync( + ["googlemeet", "transcript", "meet_gateway", "--since", since], + { from: "user" }, + ), + ).rejects.toThrow("--since must be a non-negative safe integer"); + + expect(callGatewayFromCli).not.toHaveBeenCalled(); + }, + ); + + it("delegates join to the gateway-owned runtime when available", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + session: { + id: "meet_gateway", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome-node", + mode: "realtime", + agentId: "main", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + })); + const ensureRuntime = vi.fn(async () => { + throw new Error("local runtime should not be loaded"); + }); + const stdout = captureStdout(); + try { + await setupCli({ + callGatewayFromCli, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync( + [ + "googlemeet", + "join", + "https://meet.google.com/abc-defg-hij", + "--transport", + "chrome-node", + "--mode", + "realtime", + "--message", + "Hello meeting", + ], + { from: "user" }, + ); + const gatewayCall = callGatewayFromCli.mock.calls.at(0) as unknown as + | [ + string, + { json?: boolean; timeout?: unknown }, + Record, + { progress?: boolean }, + ] + | undefined; + expect(gatewayCall?.[0]).toBe("googlemeet.join"); + expect(gatewayCall?.[1]?.json).toBe(true); + expect(typeof gatewayCall?.[1]?.timeout).toBe("string"); + expect(gatewayCall?.[1]?.timeout).not.toBe(""); + expect(gatewayCall?.[2]).toEqual({ + url: "https://meet.google.com/abc-defg-hij", + transport: "chrome-node", + mode: "realtime", + message: "Hello meeting", + dialInNumber: undefined, + pin: undefined, + dtmfSequence: undefined, + }); + expect(gatewayCall?.[3]).toEqual({ progress: false }); + expect(ensureRuntime).not.toHaveBeenCalled(); + expectFields(parseStdoutJson(stdout), { + id: "meet_gateway", + transport: "chrome-node", + }); + } finally { + stdout.restore(); + } + }); + + it("delegates test speech mode to the gateway-owned runtime", async () => { + const callGatewayFromCli = vi.fn(async () => ({ + createdSession: true, + spoken: true, + speechOutputVerified: true, + speechOutputTimedOut: false, + session: { + id: "meet_gateway", + url: "https://meet.google.com/abc-defg-hij", + state: "active", + transport: "chrome", + mode: "bidi", + agentId: "main", + participantIdentity: "signed-in Google Chrome profile", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: true, strategy: "bidi", provider: "openai" }, + notes: [], + }, + })); + const ensureRuntime = vi.fn(async () => { + throw new Error("local runtime should not be loaded"); + }); + const stdout = captureStdout(); + try { + await setupCli({ + callGatewayFromCli, + ensureRuntime: ensureRuntime as unknown as () => Promise, + }).parseAsync( + [ + "googlemeet", + "test-speech", + "https://meet.google.com/abc-defg-hij", + "--transport", + "chrome", + "--mode", + "bidi", + "--message", + "Hello meeting", + ], + { from: "user" }, + ); + + expect(callGatewayFromCli).toHaveBeenCalledWith( + "googlemeet.testSpeech", + { json: true, timeout: "60000" }, + { + url: "https://meet.google.com/abc-defg-hij", + transport: "chrome", + mode: "bidi", + message: "Hello meeting", + }, + { progress: false }, + ); + expect(ensureRuntime).not.toHaveBeenCalled(); + const payload = parseStdoutJson(stdout); + expectFields(payload, { createdSession: true }); + expectFields(payload.session, { mode: "bidi" }); + } finally { + stdout.restore(); + } + }); + + it("runs a listen-first health probe", async () => { + const testListen = vi.fn(async () => ({ + createdSession: true, + inCall: true, + manualActionRequired: false, + manualActionReason: undefined, + manualActionMessage: undefined, + listenVerified: true, + listenTimedOut: false, + captioning: true, + captionsEnabledAttempted: true, + transcriptLines: 1, + lastCaptionAt: undefined, + lastCaptionSpeaker: undefined, + lastCaptionText: undefined, + recentTranscript: [], + session: { + id: "meet_1", + url: "https://meet.google.com/abc-defg-hij", + state: "active" as const, + transport: "chrome-node" as const, + mode: "transcribe" as const, + agentId: "main", + participantIdentity: "signed-in Google Chrome profile on a paired node", + createdAt: "2026-04-25T00:00:00.000Z", + updatedAt: "2026-04-25T00:00:01.000Z", + realtime: { enabled: false, provider: "openai", toolPolicy: "safe-read-only" }, + notes: [], + }, + })); + const stdout = captureStdout(); + try { + await setupCli({ + runtime: { testListen }, + }).parseAsync( + [ + "googlemeet", + "test-listen", + "https://meet.google.com/abc-defg-hij", + "--transport", + "chrome-node", + "--timeout-ms", + "30000", + ], + { from: "user" }, + ); + expect(testListen).toHaveBeenCalledWith({ + url: "https://meet.google.com/abc-defg-hij", + transport: "chrome-node", + timeoutMs: 30000, + }); + expectFields(parseStdoutJson(stdout), { + listenVerified: true, + transcriptLines: 1, + }); + } finally { + stdout.restore(); + } + }); + + it.each(["0x10", "1e3"])("rejects non-decimal listen timeouts: %s", async (timeoutMs) => { + const testListen = vi.fn(); + + await expect( + setupCli({ + runtime: { testListen }, + }).parseAsync( + [ + "googlemeet", + "test-listen", + "https://meet.google.com/abc-defg-hij", + "--timeout-ms", + timeoutMs, + ], + { from: "user" }, + ), + ).rejects.toThrow("timeout-ms must be a positive number"); + + expect(testListen).not.toHaveBeenCalled(); + }); + + it.each(["0", "-1", "1e3"])("rejects invalid auth callback timeouts: %s", async (timeoutSec) => { + await expect( + setupCli({}).parseAsync( + ["googlemeet", "auth", "login", "--client-id", "client-id", "--timeout-sec", timeoutSec], + { from: "user" }, + ), + ).rejects.toThrow("timeout-sec must be a positive number"); + }); + + it("caps auth callback timeout seconds", () => { + expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs(undefined)).toBe(300_000); + expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs("1.5")).toBe(1_500); + expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs(String(Number.MAX_SAFE_INTEGER))).toBe( + MAX_TIMER_TIMEOUT_MS, + ); + }); + + it("caps gateway command timeout milliseconds", () => { + expect(testing.resolveGoogleMeetGatewayTimeoutMs(undefined)).toBe(5_000); + expect(testing.resolveGoogleMeetGatewayTimeoutMs(1.5)).toBe(2); + expect(testing.resolveGoogleMeetGatewayTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe( + MAX_TIMER_TIMEOUT_MS, + ); + }); +}); diff --git a/extensions/google-meet/src/cli-shared.ts b/extensions/google-meet/src/cli-shared.ts new file mode 100644 index 000000000000..a449bb0bcd10 --- /dev/null +++ b/extensions/google-meet/src/cli-shared.ts @@ -0,0 +1,474 @@ +import { writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import { format } from "node:util"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import { + clampTimerTimeoutMs, + parseStrictPositiveInteger, +} from "openclaw/plugin-sdk/number-runtime"; +import prettyMilliseconds from "pretty-ms"; +import type { GoogleMeetCalendarLookupResult } from "./calendar.js"; +import { + resolveGoogleMeetGatewayOperationTimeoutMs, + type GoogleMeetModeInput, + type GoogleMeetTransport, +} from "./config.js"; +import type { GoogleMeetRuntime } from "./runtime.js"; + +export type JoinOptions = { + transport?: GoogleMeetTransport; + mode?: GoogleMeetModeInput; + message?: string; + timeoutMs?: string; + dialInNumber?: string; + pin?: string; + dtmfSequence?: string; +}; + +export type OAuthLoginOptions = { + clientId?: string; + clientSecret?: string; + manual?: boolean; + json?: boolean; + timeoutSec?: string; +}; + +export const testing = { + parsePositiveNumber, + resolveGoogleMeetGatewayOperationTimeoutMs, + resolveGoogleMeetGatewayTimeoutMs, + resolveGoogleMeetOAuthCallbackTimeoutMs, +}; + +export type ResolveSpaceOptions = { + meeting?: string; + today?: boolean; + event?: string; + calendar?: string; + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; + json?: boolean; +}; + +export type MeetArtifactOptions = ResolveSpaceOptions & { + conferenceRecord?: string; + pageSize?: string; + transcriptEntries?: boolean; + allConferenceRecords?: boolean; + includeDocBodies?: boolean; + mergeDuplicates?: boolean; + lateAfterMinutes?: string; + earlyBeforeMinutes?: string; + zip?: boolean; + dryRun?: boolean; + format?: "summary" | "markdown" | "csv"; + 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[]; +}; + +export type SetupOptions = { + json?: boolean; + mode?: GoogleMeetModeInput; + transport?: GoogleMeetTransport; +}; + +type GoogleMeetGatewayMethod = + | "googlemeet.create" + | "googlemeet.join" + | "googlemeet.leave" + | "googlemeet.speak" + | "googlemeet.status" + | "googlemeet.transcript" + | "googlemeet.testListen" + | "googlemeet.testSpeech"; + +type GoogleMeetGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown }; + +const GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS = 5000; +const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/; + +export type DoctorOptions = { + json?: boolean; + oauth?: boolean; + meeting?: string; + createSpace?: boolean; + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; +}; + +export type JsonOptions = { + json?: boolean; +}; + +export type RecoverTabOptions = JsonOptions & { + transport?: GoogleMeetTransport; +}; + +export type CreateOptions = { + accessToken?: string; + refreshToken?: string; + clientId?: string; + clientSecret?: string; + expiresAt?: string; + accessType?: string; + entryPointAccess?: string; + join?: boolean; + transport?: GoogleMeetTransport; + mode?: GoogleMeetModeInput; + message?: string; + dialInNumber?: string; + pin?: string; + dtmfSequence?: string; + json?: boolean; +}; + +export function writeStdoutJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function isGatewayUnavailableForLocalFallback( + err: unknown, + method: GoogleMeetGatewayMethod, +): boolean { + const message = formatErrorMessage(err); + return ( + message.includes("ECONNREFUSED") || + message.includes("ECONNRESET") || + message.includes("EHOSTUNREACH") || + message.includes("ENOTFOUND") || + message.includes("gateway not connected") || + message.includes(`unknown method: ${method}`) + ); +} + +export function writeStdoutLine(...values: unknown[]): void { + process.stdout.write(`${format(...values)}\n`); +} + +export 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`); +} + +export async function promptInput(message: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + return await rl.question(message); + } finally { + rl.close(); + } +} + +export function parseOptionalNumber(value: string | undefined): number | undefined { + if (!value?.trim()) { + return undefined; + } + const trimmed = value.trim(); + const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN; + if (!Number.isFinite(parsed)) { + throw new Error(`Expected a numeric value, received ${value}`); + } + return parsed; +} + +export function writeSetupStatus( + status: Awaited>, +): void { + writeStdoutLine("Google Meet setup: %s", status.ok ? "OK" : "needs attention"); + for (const check of status.checks) { + writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); + } +} + +function formatBoolean(value: boolean | undefined): string { + return typeof value === "boolean" ? (value ? "yes" : "no") : "unknown"; +} + +export function formatOptional(value: unknown): string { + return typeof value === "string" && value.trim() ? value : "n/a"; +} + +export function parsePositiveNumber(value: string | undefined, label: string): number | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN; + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive number`); + } + return parsed; +} + +function resolveGoogleMeetGatewayTimeoutMs(timeoutMs: unknown): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) + ? (clampTimerTimeoutMs(Math.ceil(timeoutMs)) ?? 1) + : GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS; +} + +export function resolveGoogleMeetOAuthCallbackTimeoutMs(timeoutSec: string | undefined): number { + return ( + clampTimerTimeoutMs((parsePositiveNumber(timeoutSec, "timeout-sec") ?? 300) * 1000) ?? 300_000 + ); +} + +export function parsePositiveIntegerOption( + value: string | undefined, + label: string, +): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = parseStrictPositiveInteger(value); + if (parsed === undefined) { + throw new Error(`${label} must be a positive integer`); + } + return parsed; +} + +export async function callGoogleMeetGateway(params: { + callGateway: typeof callGatewayFromCli; + method: GoogleMeetGatewayMethod; + payload?: Record; + timeoutMs?: number; +}): Promise { + try { + const timeoutMs = resolveGoogleMeetGatewayTimeoutMs(params.timeoutMs); + return { + ok: true, + payload: await params.callGateway( + params.method, + { json: true, timeout: String(timeoutMs) }, + params.payload, + { progress: false }, + ), + }; + } catch (err) { + if (isGatewayUnavailableForLocalFallback(err, params.method)) { + return { ok: false, error: err }; + } + throw err; + } +} + +export function formatDuration(value: number | undefined): string { + if (value === undefined) { + return "n/a"; + } + return prettyMilliseconds(Math.max(0, Math.round(value / 1000) * 1000), { + unitCount: 2, + }); +} + +export function writeDoctorStatus(status: Awaited>): void { + if (!status.found) { + writeStdoutLine("Google Meet session: not found"); + return; + } + const sessions = status.session ? [status.session] : (status.sessions ?? []); + if (sessions.length === 0) { + writeStdoutLine("Google Meet sessions: none"); + return; + } + writeStdoutLine("Google Meet sessions: %d", sessions.length); + for (const session of sessions) { + const health = session.chrome?.health; + writeStdoutLine(""); + writeStdoutLine("session: %s", session.id); + writeStdoutLine("url: %s", session.url); + writeStdoutLine("state: %s", session.state); + writeStdoutLine("transport: %s", session.transport); + writeStdoutLine("mode: %s", session.mode); + if (session.twilio) { + writeStdoutLine("twilio dial-in: %s", session.twilio.dialInNumber); + writeStdoutLine("voice call id: %s", formatOptional(session.twilio.voiceCallId)); + writeStdoutLine("dtmf sent: %s", formatBoolean(session.twilio.dtmfSent)); + writeStdoutLine("intro sent: %s", formatBoolean(session.twilio.introSent)); + } + if (!session.chrome) { + continue; + } + writeStdoutLine("node: %s", session.chrome?.nodeId ?? "local/none"); + writeStdoutLine("audio bridge: %s", session.chrome?.audioBridge?.type ?? "none"); + const bridgeProvider = + session.chrome?.audioBridge?.provider ?? + session.realtime.transcriptionProvider ?? + session.realtime.provider ?? + "n/a"; + writeStdoutLine( + session.mode === "agent" ? "transcription provider: %s" : "provider: %s", + bridgeProvider, + ); + if (session.realtime.enabled) { + writeStdoutLine("talk-back mode: %s", session.realtime.strategy ?? session.mode); + } + writeStdoutLine("in call: %s", formatBoolean(health?.inCall)); + writeStdoutLine("lobby waiting: %s", formatBoolean(health?.lobbyWaiting)); + writeStdoutLine("captioning: %s", formatBoolean(health?.captioning)); + writeStdoutLine("transcript lines: %s", health?.transcriptLines ?? 0); + writeStdoutLine("last caption: %s", formatOptional(health?.lastCaptionAt)); + writeStdoutLine("manual action: %s", formatBoolean(health?.manualActionRequired)); + if (health?.manualActionRequired) { + writeStdoutLine("manual reason: %s", formatOptional(health.manualActionReason)); + writeStdoutLine("manual message: %s", formatOptional(health.manualActionMessage)); + } + writeStdoutLine("speech ready: %s", formatBoolean(health?.speechReady)); + if (health?.speechReady === false) { + writeStdoutLine("speech blocked reason: %s", formatOptional(health.speechBlockedReason)); + writeStdoutLine("speech blocked message: %s", formatOptional(health.speechBlockedMessage)); + } + writeStdoutLine("provider connected: %s", formatBoolean(health?.providerConnected)); + writeStdoutLine("realtime ready: %s", formatBoolean(health?.realtimeReady)); + writeStdoutLine("audio input active: %s", formatBoolean(health?.audioInputActive)); + writeStdoutLine("audio output active: %s", formatBoolean(health?.audioOutputActive)); + writeStdoutLine("meet output routed: %s", formatBoolean(health?.audioOutputRouted)); + if (health?.audioOutputDeviceLabel || health?.audioOutputRouteError) { + writeStdoutLine("meet output device: %s", formatOptional(health.audioOutputDeviceLabel)); + writeStdoutLine("meet output route error: %s", formatOptional(health.audioOutputRouteError)); + } + writeStdoutLine( + "last input: %s (%s bytes)", + formatOptional(health?.lastInputAt), + health?.lastInputBytes ?? 0, + ); + writeStdoutLine( + "last output: %s (%s bytes)", + formatOptional(health?.lastOutputAt), + health?.lastOutputBytes ?? 0, + ); + writeStdoutLine("bridge closed: %s", formatBoolean(health?.bridgeClosed)); + writeStdoutLine("browser url: %s", formatOptional(health?.browserUrl)); + if (health?.lastCaptionText) { + const speaker = health.lastCaptionSpeaker ? `${health.lastCaptionSpeaker}: ` : ""; + writeStdoutLine("last caption text: %s%s", speaker, health.lastCaptionText); + } + writeStdoutLine("realtime transcript lines: %s", health?.realtimeTranscriptLines ?? 0); + if (health?.lastRealtimeTranscriptText) { + const role = health.lastRealtimeTranscriptRole + ? `${health.lastRealtimeTranscriptRole}: ` + : ""; + writeStdoutLine("last realtime transcript: %s%s", role, health.lastRealtimeTranscriptText); + } + if (health?.lastRealtimeEventType) { + const detail = health.lastRealtimeEventDetail ? ` ${health.lastRealtimeEventDetail}` : ""; + writeStdoutLine("last realtime event: %s%s", health.lastRealtimeEventType, detail); + } + } +} + +export function writeRecoverCurrentTabResult( + result: Awaited>, +): void { + writeStdoutLine("Google Meet current tab: %s", result.found ? "found" : "not found"); + writeStdoutLine("transport: %s", result.transport); + writeStdoutLine("node: %s", result.nodeId ?? "local/none"); + if (result.targetId) { + writeStdoutLine("target: %s", result.targetId); + } + if (result.tab?.url) { + writeStdoutLine("tab url: %s", result.tab.url); + } + writeStdoutLine("message: %s", result.message); + if (result.browser) { + writeDoctorStatus({ + found: true, + session: { + id: "current-tab", + url: result.browser.browserUrl ?? result.tab?.url ?? "unknown", + transport: result.transport, + mode: "transcribe", + agentId: "main", + state: "active", + createdAt: "", + updatedAt: "", + participantIdentity: + result.transport === "chrome-node" + ? "signed-in Google Chrome profile on a paired node" + : "signed-in Google Chrome profile", + realtime: { enabled: false, toolPolicy: "safe-read-only" }, + chrome: { + audioBackend: "blackhole-2ch", + launched: true, + nodeId: result.nodeId, + health: result.browser, + }, + notes: [], + }, + }); + } +} + +export function writeLeaveResult(sessionId: string, result: { browserLeft?: boolean }): void { + if (result.browserLeft === false) { + writeStdoutLine( + "left %s, but the browser participant may still be in the call; check session notes", + sessionId, + ); + return; + } + writeStdoutLine("left %s", sessionId); +} diff --git a/extensions/google-meet/src/cli-space-commands.ts b/extensions/google-meet/src/cli-space-commands.ts new file mode 100644 index 000000000000..54068279f174 --- /dev/null +++ b/extensions/google-meet/src/cli-space-commands.ts @@ -0,0 +1,401 @@ +import { buildGoogleMeetCalendarDayWindow, listGoogleMeetCalendarEvents } from "./calendar.js"; +import type { GoogleMeetCliCommandContext } from "./cli-command-context.js"; +import { writeCalendarEventsSummary, writeLatestConferenceRecordSummary } from "./cli-export.js"; +import { + callGoogleMeetGateway, + type CreateOptions, + type JsonOptions, + type ResolveSpaceOptions, + writeStdoutJson, + writeStdoutLine, +} from "./cli-shared.js"; +import { hasCreateSpaceConfigInput, resolveCreateSpaceConfig } from "./create.js"; +import { + buildGoogleMeetPreflightReport, + createGoogleMeetSpace, + endGoogleMeetActiveConference, + fetchGoogleMeetSpace, + fetchLatestGoogleMeetConferenceRecord, +} from "./meet.js"; +import { resolveGoogleMeetAccessToken } from "./oauth.js"; + +export function registerGoogleMeetCreateCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { + root, + callGateway, + operationTimeoutMs, + hasCreateOAuth, + resolveCreateTokenOptions, + resolveMeetingInput, + resolveOAuthTokenOptions, + } = context; + + root + .command("create") + .description("Create a new Google Meet space and print its meeting URL") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option( + "--access-type ", + "Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED", + ) + .option( + "--entry-point-access ", + "Google Meet SpaceConfig entryPointAccess for API create: ALL or CREATOR_APP_ONLY", + ) + .option("--no-join", "Only create the meeting URL; do not join it") + .option("--transport ", "Join transport: chrome, chrome-node, or twilio") + .option("--mode ", "Join mode: agent, bidi, or transcribe") + .option("--message ", "Realtime speech to trigger after join") + .option("--dial-in-number ", "Meet dial-in number for Twilio transport") + .option("--pin ", "Meet phone PIN; # is appended if omitted") + .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") + .option("--json", "Print JSON output", false) + .action(async (options: CreateOptions) => { + if (options.join !== false) { + const delegated = await callGoogleMeetGateway({ + callGateway, + method: "googlemeet.create", + payload: { ...options }, + timeoutMs: operationTimeoutMs, + }); + if (delegated.ok) { + const payload = delegated.payload as { + browser?: { nodeId?: string }; + joined?: boolean; + join?: { session?: { id?: string } }; + meetingUri?: string; + source?: string; + space?: { name?: string; meetingCode?: string }; + tokenSource?: string; + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeStdoutLine("meeting uri: %s", payload.meetingUri); + if (payload.space?.name) { + writeStdoutLine("space: %s", payload.space.name); + } + if (payload.space?.meetingCode) { + writeStdoutLine("meeting code: %s", payload.space.meetingCode); + } + if (payload.source) { + writeStdoutLine("source: %s", payload.source); + } + if (payload.browser?.nodeId) { + writeStdoutLine("node: %s", payload.browser.nodeId); + } + if (payload.tokenSource) { + writeStdoutLine("token source: %s", payload.tokenSource); + } + if (payload.joined && payload.join?.session?.id) { + writeStdoutLine("joined: %s", payload.join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", payload.meetingUri); + } + return; + } + } + if (!hasCreateOAuth(params.config, options)) { + if (hasCreateSpaceConfigInput(options as Record)) { + throw new Error( + "Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove --access-type/--entry-point-access.", + ); + } + const rt = await params.ensureRuntime(); + const result = await rt.createViaBrowser(); + const join = + options.join !== false + ? await rt.join({ + url: result.meetingUri, + transport: options.transport, + mode: options.mode, + message: options.message, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }) + : undefined; + const payload = { + source: result.source, + meetingUri: result.meetingUri, + joined: Boolean(join), + ...(join ? { join } : {}), + browser: { + nodeId: result.nodeId, + targetId: result.targetId, + browserUrl: result.browserUrl, + browserTitle: result.browserTitle, + }, + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeStdoutLine("meeting uri: %s", result.meetingUri); + writeStdoutLine("source: browser"); + writeStdoutLine("node: %s", result.nodeId); + if (join) { + writeStdoutLine("joined: %s", join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); + } + return; + } + const token = await resolveGoogleMeetAccessToken( + resolveCreateTokenOptions(params.config, options), + ); + const result = await createGoogleMeetSpace({ + accessToken: token.accessToken, + config: resolveCreateSpaceConfig(options as Record), + }); + const join = + options.join !== false + ? await ( + await params.ensureRuntime() + ).join({ + url: result.meetingUri, + transport: options.transport, + mode: options.mode, + message: options.message, + dialInNumber: options.dialInNumber, + pin: options.pin, + dtmfSequence: options.dtmfSequence, + }) + : undefined; + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + joined: Boolean(join), + ...(join ? { join } : {}), + }); + return; + } + writeStdoutLine("meeting uri: %s", result.meetingUri); + writeStdoutLine("space: %s", result.space.name); + if (result.space.meetingCode) { + writeStdoutLine("meeting code: %s", result.space.meetingCode); + } + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + if (join) { + writeStdoutLine("joined: %s", join.session.id); + } else { + writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); + } + }); + + root + .command("end-active-conference") + .description("End the active conference for a Google Meet space") + .argument("[meeting]", "Meet URL, meeting code, or spaces/{id}") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (meeting: string | undefined, options: ResolveSpaceOptions & JsonOptions) => { + const token = await resolveGoogleMeetAccessToken( + resolveOAuthTokenOptions(params.config, options), + ); + const result = await endGoogleMeetActiveConference({ + accessToken: token.accessToken, + meeting: resolveMeetingInput(params.config, meeting ?? options.meeting), + }); + if (options.json) { + writeStdoutJson({ + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + writeStdoutLine("space: %s", result.space); + writeStdoutLine("ended: yes"); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); +} + +export function registerGoogleMeetApiCommands(context: GoogleMeetCliCommandContext): void { + const params = context; + const { root, resolveMeetingForToken, resolveOAuthTokenOptions, resolveTokenOptions } = context; + + root + .command("resolve-space") + .description("Resolve a Meet URL, meeting code, or spaces/{id} to its canonical space") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const space = await fetchGoogleMeetSpace({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + if (options.json) { + writeStdoutJson(space); + return; + } + writeStdoutLine("input: %s", resolved.meeting); + writeStdoutLine("space: %s", space.name); + if (space.meetingCode) { + writeStdoutLine("meeting code: %s", space.meetingCode); + } + if (space.meetingUri) { + writeStdoutLine("meeting uri: %s", space.meetingUri); + } + writeStdoutLine("active conference: %s", space.activeConference ? "yes" : "no"); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("preflight") + .description("Validate OAuth + meeting resolution prerequisites for Meet media work") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const resolved = resolveTokenOptions(params.config, options); + const token = await resolveGoogleMeetAccessToken(resolved); + const space = await fetchGoogleMeetSpace({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + const report = buildGoogleMeetPreflightReport({ + input: resolved.meeting, + space, + previewAcknowledged: params.config.preview.enrollmentAcknowledged, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + if (options.json) { + writeStdoutJson(report); + return; + } + writeStdoutLine("input: %s", report.input); + writeStdoutLine("resolved space: %s", report.resolvedSpaceName); + if (report.meetingCode) { + writeStdoutLine("meeting code: %s", report.meetingCode); + } + if (report.meetingUri) { + writeStdoutLine("meeting uri: %s", report.meetingUri); + } + writeStdoutLine("active conference: %s", report.hasActiveConference ? "yes" : "no"); + writeStdoutLine("preview acknowledged: %s", report.previewAcknowledged ? "yes" : "no"); + writeStdoutLine("token source: %s", report.tokenSource); + if (report.blockers.length === 0) { + writeStdoutLine("blockers: none"); + return; + } + writeStdoutLine("blockers:"); + for (const blocker of report.blockers) { + writeStdoutLine("- %s", blocker); + } + }); + + root + .command("latest") + .description("Find the latest Meet conference record for a meeting") + .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") + .option("--today", "Find a Meet link on today's calendar") + .option("--event ", "Find a matching calendar event with a Meet link") + .option("--calendar ", "Calendar id for --today or --event", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const token = await resolveGoogleMeetAccessToken( + resolveOAuthTokenOptions(params.config, options), + ); + const resolved = await resolveMeetingForToken({ + config: params.config, + options, + accessToken: token.accessToken, + configuredMeeting: options.meeting?.trim(), + }); + const result = await fetchLatestGoogleMeetConferenceRecord({ + accessToken: token.accessToken, + meeting: resolved.meeting, + }); + if (options.json) { + writeStdoutJson({ + ...result, + ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }); + return; + } + if (resolved.calendarEvent) { + writeStdoutLine("calendar event: %s", resolved.calendarEvent.event.summary ?? "untitled"); + writeStdoutLine("calendar meet: %s", resolved.calendarEvent.meetingUri); + } + writeLatestConferenceRecordSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + + root + .command("calendar-events") + .description("Preview Calendar events with Google Meet links") + .option("--today", "Find Meet links on today's calendar") + .option("--event ", "Find matching calendar events with Meet links") + .option("--calendar ", "Calendar id for lookup", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const token = await resolveGoogleMeetAccessToken( + resolveOAuthTokenOptions(params.config, options), + ); + const window = options.today ? buildGoogleMeetCalendarDayWindow() : {}; + const result = await listGoogleMeetCalendarEvents({ + accessToken: token.accessToken, + calendarId: options.calendar, + eventQuery: options.event, + ...window, + }); + const payload = { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeCalendarEventsSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); +} diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 2a7c522da7b4..f518e6c54fa7 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -1,246 +1,13 @@ -// Google Meet tests cover cli plugin behavior. -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Command } from "commander"; -import JSZip from "jszip"; -import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; -import { registerGoogleMeetCli, testing } from "./cli.js"; -import { resolveGoogleMeetConfig } from "./config.js"; import type { GoogleMeetRuntime } from "./runtime.js"; - -const fetchGuardMocks = vi.hoisted(() => ({ - fetchWithSsrFGuard: vi.fn( - async (params: { - url: string; - init?: RequestInit; - }): Promise<{ - response: Response; - release: () => Promise; - }> => ({ - response: await fetch(params.url, params.init), - release: vi.fn(async () => {}), - }), - ), -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, - }; -}); - -function captureStdout() { - let output = ""; - const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - output += String(chunk); - return true; - }) as typeof process.stdout.write); - return { - output: () => output, - restore: () => writeSpy.mockRestore(), - }; -} - -function expectFields(value: unknown, expected: Record): void { - if (!value || typeof value !== "object") { - throw new Error("expected fields object"); - } - const record = value as Record; - for (const [key, expectedValue] of Object.entries(expected)) { - expect(record[key], key).toEqual(expectedValue); - } -} - -function firstRecord(value: unknown): Record { - expect(Array.isArray(value)).toBe(true); - const [record] = value as unknown[]; - if (!record || typeof record !== "object") { - throw new Error("expected first record"); - } - return record as Record; -} - -function parseStdoutJson(stdout: { output: () => string }): Record { - return JSON.parse(stdout.output()) as Record; -} - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -function requestUrl(input: RequestInfo | URL): URL { - if (typeof input === "string") { - return new URL(input); - } - if (input instanceof URL) { - return input; - } - return new URL(input.url); -} - -function stubMeetArtifactsApi( - options: { failSmartNoteDocumentBody?: boolean; participantDisplayName?: string } = {}, -) { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = requestUrl(input); - if (url.pathname === "/v2/spaces/abc-defg-hij") { - return jsonResponse({ - name: "spaces/abc-defg-hij", - meetingCode: "abc-defg-hij", - meetingUri: "https://meet.google.com/abc-defg-hij", - }); - } - if (url.pathname === "/calendar/v3/calendars/primary/events") { - return jsonResponse({ - items: [ - { - id: "event-1", - summary: "Project sync", - hangoutLink: "https://meet.google.com/abc-defg-hij", - start: { dateTime: "2026-04-25T10:00:00Z" }, - end: { dateTime: "2026-04-25T10:30:00Z" }, - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords") { - return jsonResponse({ - conferenceRecords: [ - { - name: "conferenceRecords/rec-1", - space: "spaces/abc-defg-hij", - startTime: "2026-04-25T10:00:00Z", - endTime: "2026-04-25T10:30:00Z", - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1") { - return jsonResponse({ - name: "conferenceRecords/rec-1", - space: "spaces/abc-defg-hij", - startTime: "2026-04-25T10:00:00Z", - endTime: "2026-04-25T10:30:00Z", - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { - return jsonResponse({ - participants: [ - { - name: "conferenceRecords/rec-1/participants/p1", - signedinUser: { - user: "users/alice", - displayName: options.participantDisplayName ?? "Alice", - }, - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { - return jsonResponse({ - participantSessions: [ - { - name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - startTime: "2026-04-25T10:00:00Z", - endTime: "2026-04-25T10:10:00Z", - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { - return jsonResponse({ - recordings: [ - { - name: "conferenceRecords/rec-1/recordings/r1", - state: "FILE_GENERATED", - driveDestination: { file: "drive-file-1" }, - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { - return jsonResponse({ - transcripts: [ - { - name: "conferenceRecords/rec-1/transcripts/t1", - state: "FILE_GENERATED", - docsDestination: { document: "doc-1" }, - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") { - return jsonResponse({ - transcriptEntries: [ - { - name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", - text: "Hello from the transcript.", - startTime: "2026-04-25T10:01:00Z", - participant: "conferenceRecords/rec-1/participants/p1", - }, - ], - }); - } - if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { - return jsonResponse({ - smartNotes: [ - { - name: "conferenceRecords/rec-1/smartNotes/sn1", - state: "FILE_GENERATED", - docsDestination: { document: "notes-1" }, - }, - ], - }); - } - if (url.pathname === "/drive/v3/files/doc-1/export") { - return new Response("Transcript document body.", { - status: 200, - headers: { "Content-Type": "text/plain" }, - }); - } - 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" }, - }); - } - return new Response("not found", { status: 404 }); - }), - ); -} - -function setupCli(params: { - config?: Parameters[0]; - runtime?: Partial; - ensureRuntime?: () => Promise; - callGatewayFromCli?: Parameters[0]["callGatewayFromCli"]; -}) { - const program = new Command(); - registerGoogleMeetCli({ - program, - config: resolveGoogleMeetConfig(params.config ?? {}), - ensureRuntime: - params.ensureRuntime ?? (async () => (params.runtime ?? {}) as unknown as GoogleMeetRuntime), - callGatewayFromCli: - params.callGatewayFromCli ?? - (vi.fn(async () => { - throw new Error("connect ECONNREFUSED 127.0.0.1:18789"); - }) as NonNullable[0]["callGatewayFromCli"]>), - }); - return program; -} +import { + captureStdout, + expectFields, + jsonResponse, + parseStdoutJson, + requestUrl, + setupCli, +} from "./test-support/cli-harness.js"; describe("google-meet CLI", () => { afterEach(() => { @@ -252,1011 +19,6 @@ describe("google-meet CLI", () => { vi.resetModules(); }); - it("prints setup checks as text and JSON", async () => { - { - const stdout = captureStdout(); - try { - await setupCli({ - runtime: { - setupStatus: async () => ({ - ok: true, - checks: [ - { - id: "audio-bridge", - ok: true, - message: "Chrome command-pair talk-back audio bridge configured (pcm16-24khz)", - }, - ], - }), - }, - }).parseAsync(["googlemeet", "setup"], { from: "user" }); - expect(stdout.output()).toContain("Google Meet setup: OK"); - expect(stdout.output()).toContain( - "[ok] audio-bridge: Chrome command-pair talk-back audio bridge configured (pcm16-24khz)", - ); - expect(stdout.output()).not.toContain('"checks"'); - } finally { - stdout.restore(); - } - } - - { - const stdout = captureStdout(); - try { - await setupCli({ - runtime: { - setupStatus: async () => ({ - ok: false, - checks: [{ id: "twilio-voice-call-plugin", ok: false, message: "missing" }], - }), - }, - }).parseAsync(["googlemeet", "setup", "--json"], { from: "user" }); - const payload = parseStdoutJson(stdout); - expectFields(payload, { ok: false }); - expectFields(firstRecord(payload.checks), { - id: "twilio-voice-call-plugin", - ok: false, - }); - } finally { - stdout.restore(); - } - } - }); - - it("prints artifacts and attendance output", async () => { - stubMeetArtifactsApi(); - - const artifactsStdout = captureStdout(); - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--json", - ], - { from: "user" }, - ); - const payload = parseStdoutJson(artifactsStdout); - expectFields(payload, { tokenSource: "cached-access-token" }); - expectFields(firstRecord(payload.conferenceRecords), { name: "conferenceRecords/rec-1" }); - const artifact = firstRecord(payload.artifacts); - expectFields(firstRecord(artifact.recordings), { - name: "conferenceRecords/rec-1/recordings/r1", - }); - expectFields(firstRecord(artifact.transcripts), { - name: "conferenceRecords/rec-1/transcripts/t1", - }); - const transcriptEntries = firstRecord(artifact.transcriptEntries); - expectFields(transcriptEntries, { transcript: "conferenceRecords/rec-1/transcripts/t1" }); - expectFields(firstRecord(transcriptEntries.entries), { text: "Hello from the transcript." }); - expectFields(firstRecord(artifact.smartNotes), { - name: "conferenceRecords/rec-1/smartNotes/sn1", - }); - } finally { - artifactsStdout.restore(); - } - - const attendanceStdout = captureStdout(); - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - ], - { from: "user" }, - ); - expect(attendanceStdout.output()).toContain("attendance rows: 1"); - expect(attendanceStdout.output()).toContain("participant: Alice"); - expect(attendanceStdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - attendanceStdout.restore(); - } - }); - - it("ends an active conference for a Meet space", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { - const url = requestUrl(input); - if (url.pathname === "/v2/spaces/abc-defg-hij") { - return jsonResponse({ - name: "spaces/space-resource-123", - meetingCode: "abc-defg-hij", - meetingUri: "https://meet.google.com/abc-defg-hij", - }); - } - if (url.pathname === "/v2/spaces/space-resource-123:endActiveConference") { - return jsonResponse({}); - } - return new Response("not found", { status: 404 }); - }); - vi.stubGlobal("fetch", fetchMock); - - const stdout = captureStdout(); - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "end-active-conference", - "https://meet.google.com/abc-defg-hij", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--json", - ], - { from: "user" }, - ); - expectFields(parseStdoutJson(stdout), { - space: "spaces/space-resource-123", - ended: true, - tokenSource: "cached-access-token", - }); - const endCall = fetchMock.mock.calls.find( - ([input]) => - input === "https://meet.googleapis.com/v2/spaces/space-resource-123:endActiveConference", - ); - expect(endCall?.[1]).toEqual({ - method: "POST", - body: "{}", - headers: { - Accept: "application/json", - Authorization: "Bearer token", - "Content-Type": "application/json", - }, - }); - } finally { - stdout.restore(); - } - }); - - it("rejects access policy flags when create would use browser fallback", async () => { - await expect( - setupCli({ - runtime: { - createViaBrowser: vi.fn(async () => { - throw new Error("browser fallback should not run"); - }), - }, - }).parseAsync(["googlemeet", "create", "--access-type", "OPEN"], { from: "user" }), - ).rejects.toThrow("access policy options require OAuth/API room creation"); - }); - - it("prints the latest conference record", async () => { - stubMeetArtifactsApi(); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "latest", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--meeting", - "abc-defg-hij", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("space: spaces/abc-defg-hij"); - expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); - } finally { - stdout.restore(); - } - }); - - it("prints the latest conference record from today's calendar", async () => { - stubMeetArtifactsApi(); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "latest", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--today", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("calendar event: Project sync"); - expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1"); - } finally { - stdout.restore(); - } - }); - - it("prints calendar event previews", async () => { - stubMeetArtifactsApi(); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "calendar-events", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--today", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("meet events: 1"); - expect(stdout.output()).toContain("* Project sync"); - expect(stdout.output()).toContain("https://meet.google.com/abc-defg-hij"); - } finally { - stdout.restore(); - } - }); - - it.each(["0", "1.5", "9007199254740993"])( - "rejects invalid Meet API page sizes: %s", - async (pageSize) => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - await expect( - setupCli({}).parseAsync( - [ - "googlemeet", - "artifacts", - "--access-token", - "token", - "--conference-record", - "rec-1", - "--page-size", - pageSize, - ], - { from: "user" }, - ), - ).rejects.toThrow("page-size must be a positive integer"); - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); - - it("prints markdown artifact and attendance output", async () => { - stubMeetArtifactsApi(); - const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-")); - const outputPath = path.join(tempDir, "artifacts.md"); - const artifactsStdout = captureStdout(); - - try { - await setupCli({}).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(artifactsStdout.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 { - artifactsStdout.restore(); - rmSync(tempDir, { recursive: true, force: true }); - } - - const attendanceStdout = captureStdout(); - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "markdown", - ], - { from: "user" }, - ); - expect(attendanceStdout.output()).toContain("# Google Meet Attendance"); - expect(attendanceStdout.output()).toContain("## Alice"); - expect(attendanceStdout.output()).toContain( - "conferenceRecords/rec-1/participants/p1/participantSessions/s1", - ); - } finally { - attendanceStdout.restore(); - } - }); - - it("prints CSV attendance output", async () => { - stubMeetArtifactsApi(); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "csv", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("conferenceRecord,displayName,user"); - expect(stdout.output()).toContain("conferenceRecords/rec-1,Alice,users/alice"); - } finally { - stdout.restore(); - } - }); - - it("neutralizes spreadsheet formulas in CSV attendance output", async () => { - stubMeetArtifactsApi({ participantDisplayName: " \t=1+1" }); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "csv", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain("conferenceRecords/rec-1,' \t=1+1,users/alice"); - } finally { - stdout.restore(); - } - }); - - it("quotes carriage returns in formula-neutralized CSV cells", async () => { - stubMeetArtifactsApi({ participantDisplayName: "\r=1+1" }); - const stdout = captureStdout(); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "attendance", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--format", - "csv", - ], - { from: "user" }, - ); - expect(stdout.output()).toContain('conferenceRecords/rec-1,"\'\r=1+1",users/alice'); - } finally { - stdout.restore(); - } - }); - - it("writes an export bundle", async () => { - stubMeetArtifactsApi(); - const stdout = captureStdout(); - const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-")); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "export", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--include-doc-bodies", - "--zip", - "--output", - tempDir, - ], - { from: "user" }, - ); - expect(stdout.output()).toContain(`export: ${tempDir}`); - expect(readFileSync(path.join(tempDir, "summary.md"), "utf8")).toContain( - "# Google Meet Artifacts", - ); - expect(readFileSync(path.join(tempDir, "attendance.csv"), "utf8")).toContain( - "conferenceRecords/rec-1,Alice,users/alice", - ); - expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain( - "Hello from the transcript.", - ); - expect(readFileSync(path.join(tempDir, "transcript.md"), "utf8")).toContain( - "Transcript document body.", - ); - const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8")); - expectFields(manifest.request, { - conferenceRecord: "rec-1", - includeDocumentBodies: true, - }); - expectFields(manifest, { - tokenSource: "cached-access-token", - }); - expectFields(manifest.counts, { attendanceRows: 1, warnings: 0 }); - expect(manifest.files).toEqual([ - "summary.md", - "attendance.csv", - "transcript.md", - "artifacts.json", - "attendance.json", - "manifest.json", - ]); - const artifacts = JSON.parse(readFileSync(path.join(tempDir, "artifacts.json"), "utf8")); - expectFields(firstRecord(artifacts.conferenceRecords), { name: "conferenceRecords/rec-1" }); - expectFields(firstRecord(firstRecord(artifacts.artifacts).transcripts), { - documentText: "Transcript document body.", - }); - const zip = await JSZip.loadAsync(readFileSync(`${tempDir}.zip`)); - expect(await zip.file("summary.md")?.async("string")).toContain("# Google Meet Artifacts"); - } finally { - stdout.restore(); - rmSync(tempDir, { recursive: true, force: true }); - rmSync(`${tempDir}.zip`, { force: true }); - } - }); - - it("neutralizes spreadsheet formulas in exported attendance CSV files", async () => { - stubMeetArtifactsApi({ participantDisplayName: "\uFF1D1+1" }); - const stdout = captureStdout(); - const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-export-csv-")); - - try { - await setupCli({}).parseAsync( - [ - "googlemeet", - "export", - "--access-token", - "token", - "--expires-at", - String(Date.now() + 120_000), - "--conference-record", - "rec-1", - "--output", - tempDir, - ], - { from: "user" }, - ); - expect(readFileSync(path.join(tempDir, "attendance.csv"), "utf8")).toContain( - "conferenceRecords/rec-1,'\uFF1D1+1,users/alice", - ); - } finally { - stdout.restore(); - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - 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")); - expectFields(manifest.counts, { warnings: 1 }); - expectFields(firstRecord(manifest.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("accepts --json on session status", async () => { - const stdout = captureStdout(); - try { - await setupCli({ - runtime: { - status: async () => ({ - found: true, - sessions: [ - { - id: "meet_1", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "twilio", - mode: "agent", - agentId: "main", - participantIdentity: "Twilio PSTN participant", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, - notes: [], - }, - ], - }), - }, - }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); - const payload = parseStdoutJson(stdout); - expectFields(payload, { found: true }); - expectFields(firstRecord(payload.sessions), { - id: "meet_1", - transport: "twilio", - }); - } finally { - stdout.restore(); - } - }); - - it("delegates session status to the gateway-owned runtime when available", async () => { - const callGatewayFromCli = vi.fn(async () => ({ - found: true, - sessions: [ - { - id: "meet_gateway", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "chrome-node", - mode: "agent", - agentId: "main", - participantIdentity: "signed-in Google Chrome profile on a paired node", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, - notes: [], - }, - ], - })); - const ensureRuntime = vi.fn(async () => { - throw new Error("local runtime should not be loaded"); - }); - const stdout = captureStdout(); - try { - await setupCli({ - callGatewayFromCli, - ensureRuntime: ensureRuntime as unknown as () => Promise, - }).parseAsync(["googlemeet", "status", "--json"], { from: "user" }); - expect(callGatewayFromCli).toHaveBeenCalledWith( - "googlemeet.status", - { json: true, timeout: "5000" }, - { sessionId: undefined }, - { progress: false }, - ); - expect(ensureRuntime).not.toHaveBeenCalled(); - const payload = parseStdoutJson(stdout); - expectFields(payload, { found: true }); - expectFields(firstRecord(payload.sessions), { - id: "meet_gateway", - transport: "chrome-node", - }); - } finally { - stdout.restore(); - } - }); - - it("prints cursor-based transcripts from the gateway-owned runtime", async () => { - const callGatewayFromCli = vi.fn(async () => ({ - found: true, - sessionId: "meet_gateway", - startIndex: 3, - nextIndex: 4, - droppedLines: 2, - lines: [{ at: "2026-07-12T06:00:00.000Z", speaker: "Alice", text: "fourth line" }], - })); - const stdout = captureStdout(); - try { - await setupCli({ callGatewayFromCli }).parseAsync( - ["googlemeet", "transcript", "meet_gateway", "--since", "3"], - { from: "user" }, - ); - expect(callGatewayFromCli).toHaveBeenCalledWith( - "googlemeet.transcript", - { json: true, timeout: "5000" }, - { sessionId: "meet_gateway", sinceIndex: 3 }, - { progress: false }, - ); - expect(stdout.output()).toContain("# 2 earlier lines dropped by the transcript cap"); - expect(stdout.output()).toContain("Alice: fourth line"); - expect(stdout.output()).toContain("# nextIndex: 4"); - } finally { - stdout.restore(); - } - }); - - it.each([ - ["0", 0], - ["3", 3], - ["+3", 3], - [" 3 ", 3], - [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], - ] as const)("accepts base-10 safe transcript cursors: %s", async (since, expected) => { - const callGatewayFromCli = vi.fn(async () => ({ - found: true, - sessionId: "meet_gateway", - startIndex: expected, - nextIndex: expected, - lines: [], - })); - - await setupCli({ callGatewayFromCli }).parseAsync( - ["googlemeet", "transcript", "meet_gateway", "--since", since], - { from: "user" }, - ); - - expect(callGatewayFromCli).toHaveBeenCalledWith( - "googlemeet.transcript", - { json: true, timeout: "5000" }, - { sessionId: "meet_gateway", sinceIndex: expected }, - { progress: false }, - ); - }); - - it.each(["", " ", "-1", "0x10", "0o10", "0b10", "1e0", "1.5", "9007199254740992"])( - "rejects non-decimal transcript cursors before gateway delegation: %s", - async (since) => { - const callGatewayFromCli = vi.fn(); - - await expect( - setupCli({ callGatewayFromCli }).parseAsync( - ["googlemeet", "transcript", "meet_gateway", "--since", since], - { from: "user" }, - ), - ).rejects.toThrow("--since must be a non-negative safe integer"); - - expect(callGatewayFromCli).not.toHaveBeenCalled(); - }, - ); - - it("delegates join to the gateway-owned runtime when available", async () => { - const callGatewayFromCli = vi.fn(async () => ({ - session: { - id: "meet_gateway", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "chrome-node", - mode: "realtime", - agentId: "main", - participantIdentity: "signed-in Google Chrome profile on a paired node", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" }, - notes: [], - }, - })); - const ensureRuntime = vi.fn(async () => { - throw new Error("local runtime should not be loaded"); - }); - const stdout = captureStdout(); - try { - await setupCli({ - callGatewayFromCli, - ensureRuntime: ensureRuntime as unknown as () => Promise, - }).parseAsync( - [ - "googlemeet", - "join", - "https://meet.google.com/abc-defg-hij", - "--transport", - "chrome-node", - "--mode", - "realtime", - "--message", - "Hello meeting", - ], - { from: "user" }, - ); - const gatewayCall = callGatewayFromCli.mock.calls.at(0) as unknown as - | [ - string, - { json?: boolean; timeout?: unknown }, - Record, - { progress?: boolean }, - ] - | undefined; - expect(gatewayCall?.[0]).toBe("googlemeet.join"); - expect(gatewayCall?.[1]?.json).toBe(true); - expect(typeof gatewayCall?.[1]?.timeout).toBe("string"); - expect(gatewayCall?.[1]?.timeout).not.toBe(""); - expect(gatewayCall?.[2]).toEqual({ - url: "https://meet.google.com/abc-defg-hij", - transport: "chrome-node", - mode: "realtime", - message: "Hello meeting", - dialInNumber: undefined, - pin: undefined, - dtmfSequence: undefined, - }); - expect(gatewayCall?.[3]).toEqual({ progress: false }); - expect(ensureRuntime).not.toHaveBeenCalled(); - expectFields(parseStdoutJson(stdout), { - id: "meet_gateway", - transport: "chrome-node", - }); - } finally { - stdout.restore(); - } - }); - - it("delegates test speech mode to the gateway-owned runtime", async () => { - const callGatewayFromCli = vi.fn(async () => ({ - createdSession: true, - spoken: true, - speechOutputVerified: true, - speechOutputTimedOut: false, - session: { - id: "meet_gateway", - url: "https://meet.google.com/abc-defg-hij", - state: "active", - transport: "chrome", - mode: "bidi", - agentId: "main", - participantIdentity: "signed-in Google Chrome profile", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: true, strategy: "bidi", provider: "openai" }, - notes: [], - }, - })); - const ensureRuntime = vi.fn(async () => { - throw new Error("local runtime should not be loaded"); - }); - const stdout = captureStdout(); - try { - await setupCli({ - callGatewayFromCli, - ensureRuntime: ensureRuntime as unknown as () => Promise, - }).parseAsync( - [ - "googlemeet", - "test-speech", - "https://meet.google.com/abc-defg-hij", - "--transport", - "chrome", - "--mode", - "bidi", - "--message", - "Hello meeting", - ], - { from: "user" }, - ); - - expect(callGatewayFromCli).toHaveBeenCalledWith( - "googlemeet.testSpeech", - { json: true, timeout: "60000" }, - { - url: "https://meet.google.com/abc-defg-hij", - transport: "chrome", - mode: "bidi", - message: "Hello meeting", - }, - { progress: false }, - ); - expect(ensureRuntime).not.toHaveBeenCalled(); - const payload = parseStdoutJson(stdout); - expectFields(payload, { createdSession: true }); - expectFields(payload.session, { mode: "bidi" }); - } finally { - stdout.restore(); - } - }); - - it("runs a listen-first health probe", async () => { - const testListen = vi.fn(async () => ({ - createdSession: true, - inCall: true, - manualActionRequired: false, - manualActionReason: undefined, - manualActionMessage: undefined, - listenVerified: true, - listenTimedOut: false, - captioning: true, - captionsEnabledAttempted: true, - transcriptLines: 1, - lastCaptionAt: undefined, - lastCaptionSpeaker: undefined, - lastCaptionText: undefined, - recentTranscript: [], - session: { - id: "meet_1", - url: "https://meet.google.com/abc-defg-hij", - state: "active" as const, - transport: "chrome-node" as const, - mode: "transcribe" as const, - agentId: "main", - participantIdentity: "signed-in Google Chrome profile on a paired node", - createdAt: "2026-04-25T00:00:00.000Z", - updatedAt: "2026-04-25T00:00:01.000Z", - realtime: { enabled: false, provider: "openai", toolPolicy: "safe-read-only" }, - notes: [], - }, - })); - const stdout = captureStdout(); - try { - await setupCli({ - runtime: { testListen }, - }).parseAsync( - [ - "googlemeet", - "test-listen", - "https://meet.google.com/abc-defg-hij", - "--transport", - "chrome-node", - "--timeout-ms", - "30000", - ], - { from: "user" }, - ); - expect(testListen).toHaveBeenCalledWith({ - url: "https://meet.google.com/abc-defg-hij", - transport: "chrome-node", - timeoutMs: 30000, - }); - expectFields(parseStdoutJson(stdout), { - listenVerified: true, - transcriptLines: 1, - }); - } finally { - stdout.restore(); - } - }); - - it.each(["0x10", "1e3"])("rejects non-decimal listen timeouts: %s", async (timeoutMs) => { - const testListen = vi.fn(); - - await expect( - setupCli({ - runtime: { testListen }, - }).parseAsync( - [ - "googlemeet", - "test-listen", - "https://meet.google.com/abc-defg-hij", - "--timeout-ms", - timeoutMs, - ], - { from: "user" }, - ), - ).rejects.toThrow("timeout-ms must be a positive number"); - - expect(testListen).not.toHaveBeenCalled(); - }); - - it.each(["0", "-1", "1e3"])("rejects invalid auth callback timeouts: %s", async (timeoutSec) => { - await expect( - setupCli({}).parseAsync( - ["googlemeet", "auth", "login", "--client-id", "client-id", "--timeout-sec", timeoutSec], - { from: "user" }, - ), - ).rejects.toThrow("timeout-sec must be a positive number"); - }); - - it("caps auth callback timeout seconds", () => { - expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs(undefined)).toBe(300_000); - expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs("1.5")).toBe(1_500); - expect(testing.resolveGoogleMeetOAuthCallbackTimeoutMs(String(Number.MAX_SAFE_INTEGER))).toBe( - MAX_TIMER_TIMEOUT_MS, - ); - }); - - it("caps gateway command timeout milliseconds", () => { - expect(testing.resolveGoogleMeetGatewayTimeoutMs(undefined)).toBe(5_000); - expect(testing.resolveGoogleMeetGatewayTimeoutMs(1.5)).toBe(2); - expect(testing.resolveGoogleMeetGatewayTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe( - MAX_TIMER_TIMEOUT_MS, - ); - }); - - 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()); - expectFields(payload, { - dryRun: true, - tokenSource: "cached-access-token", - }); - expectFields(payload.manifest.request, { - conferenceRecord: "rec-1", - includeDocumentBodies: true, - }); - expectFields(payload.manifest.counts, { - attendanceRows: 1, - transcriptEntries: 1, - warnings: 0, - }); - expect(payload.manifest.files).toEqual([ - "summary.md", - "attendance.csv", - "transcript.md", - "artifacts.json", - "attendance.json", - "manifest.json", - ]); - 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 { @@ -1484,4 +246,3 @@ describe("google-meet CLI", () => { } }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index fdda0aa6de42..8b908c5f1297 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -1,648 +1,51 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { createInterface } from "node:readline/promises"; -import { format } from "node:util"; import type { Command } from "commander"; -import JSZip from "jszip"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; -import { - clampTimerTimeoutMs, - parseStrictNonNegativeInteger, - parseStrictPositiveInteger, -} from "openclaw/plugin-sdk/number-runtime"; -import prettyMilliseconds from "pretty-ms"; import { buildGoogleMeetCalendarDayWindow, findGoogleMeetCalendarEvent, - listGoogleMeetCalendarEvents, type GoogleMeetCalendarLookupResult, } from "./calendar.js"; +import { registerGoogleMeetArtifactCommands } from "./cli-artifact-commands.js"; +import type { GoogleMeetCliCommandContext } from "./cli-command-context.js"; +import { registerGoogleMeetDoctorCommand } from "./cli-doctor.js"; import { - resolveGoogleMeetGatewayOperationTimeoutMs, - type GoogleMeetConfig, - type GoogleMeetModeInput, - type GoogleMeetTransport, -} from "./config.js"; -import { hasCreateSpaceConfigInput, resolveCreateSpaceConfig } from "./create.js"; + registerGoogleMeetLifecycleCommands, + registerGoogleMeetProbeCommands, + registerGoogleMeetSessionCommands, +} from "./cli-runtime-commands.js"; import { - buildGoogleMeetPreflightReport, - createGoogleMeetSpace, - endGoogleMeetActiveConference, - fetchGoogleMeetArtifacts, - fetchGoogleMeetAttendance, - fetchLatestGoogleMeetConferenceRecord, - fetchGoogleMeetSpace, - type GoogleMeetArtifactsResult, - type GoogleMeetAttendanceResult, - type GoogleMeetLatestConferenceRecordResult, -} from "./meet.js"; + parseOptionalNumber, + parsePositiveIntegerOption, + promptInput, + resolveGoogleMeetOAuthCallbackTimeoutMs, + testing, + type CreateOptions, + type MeetArtifactOptions, + type OAuthLoginOptions, + type ResolveSpaceOptions, + writeStdoutJson, + writeStdoutLine, +} from "./cli-shared.js"; +import { + registerGoogleMeetApiCommands, + registerGoogleMeetCreateCommands, +} from "./cli-space-commands.js"; +import { resolveGoogleMeetGatewayOperationTimeoutMs, type GoogleMeetConfig } from "./config.js"; import { buildGoogleMeetAuthUrl, createGoogleMeetOAuthState, createGoogleMeetPkce, exchangeGoogleMeetAuthCode, - resolveGoogleMeetAccessToken, waitForGoogleMeetAuthCode, } from "./oauth.js"; import type { GoogleMeetRuntime } from "./runtime.js"; -type JoinOptions = { - transport?: GoogleMeetTransport; - mode?: GoogleMeetModeInput; - message?: string; - timeoutMs?: string; - dialInNumber?: string; - pin?: string; - dtmfSequence?: string; -}; - -type OAuthLoginOptions = { - clientId?: string; - clientSecret?: string; - manual?: boolean; - json?: boolean; - timeoutSec?: string; -}; - -export const testing = { - parsePositiveNumber, - resolveGoogleMeetGatewayOperationTimeoutMs, - resolveGoogleMeetGatewayTimeoutMs, - resolveGoogleMeetOAuthCallbackTimeoutMs, -}; - -type ResolveSpaceOptions = { - meeting?: string; - today?: boolean; - event?: string; - calendar?: string; - accessToken?: string; - refreshToken?: string; - clientId?: string; - clientSecret?: string; - expiresAt?: string; - json?: boolean; -}; - -type MeetArtifactOptions = ResolveSpaceOptions & { - conferenceRecord?: string; - pageSize?: string; - transcriptEntries?: boolean; - allConferenceRecords?: boolean; - includeDocBodies?: boolean; - mergeDuplicates?: boolean; - lateAfterMinutes?: string; - earlyBeforeMinutes?: string; - zip?: boolean; - dryRun?: boolean; - format?: "summary" | "markdown" | "csv"; - output?: string; -}; - -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; -}; - -type GoogleMeetExportWarning = { - type: - | "smart_notes" - | "transcript_entries" - | "transcript_document_body" - | "smart_note_document_body"; - conferenceRecord: string; - resource?: string; - message: string; -}; - -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; - mode?: GoogleMeetModeInput; - transport?: GoogleMeetTransport; -}; - -type GoogleMeetGatewayMethod = - | "googlemeet.create" - | "googlemeet.join" - | "googlemeet.leave" - | "googlemeet.speak" - | "googlemeet.status" - | "googlemeet.transcript" - | "googlemeet.testListen" - | "googlemeet.testSpeech"; - -type GoogleMeetGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown }; - -const GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS = 5000; -const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/; - -type DoctorOptions = { - json?: boolean; - oauth?: boolean; - meeting?: string; - createSpace?: boolean; - accessToken?: string; - refreshToken?: string; - clientId?: string; - clientSecret?: string; - expiresAt?: string; -}; - -type JsonOptions = { - json?: boolean; -}; - -type RecoverTabOptions = JsonOptions & { - transport?: GoogleMeetTransport; -}; - -type CreateOptions = { - accessToken?: string; - refreshToken?: string; - clientId?: string; - clientSecret?: string; - expiresAt?: string; - accessType?: string; - entryPointAccess?: string; - join?: boolean; - transport?: GoogleMeetTransport; - mode?: GoogleMeetModeInput; - message?: string; - dialInNumber?: string; - pin?: string; - dtmfSequence?: string; - json?: boolean; -}; - -function writeStdoutJson(value: unknown): void { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function isGatewayUnavailableForLocalFallback( - err: unknown, - method: GoogleMeetGatewayMethod, -): boolean { - const message = formatErrorMessage(err); - return ( - message.includes("ECONNREFUSED") || - message.includes("ECONNRESET") || - message.includes("EHOSTUNREACH") || - message.includes("ENOTFOUND") || - message.includes("gateway not connected") || - message.includes(`unknown method: ${method}`) - ); -} - -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, - output: process.stderr, - }); - try { - return await rl.question(message); - } finally { - rl.close(); - } -} - -function parseOptionalNumber(value: string | undefined): number | undefined { - if (!value?.trim()) { - return undefined; - } - const trimmed = value.trim(); - const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN; - if (!Number.isFinite(parsed)) { - throw new Error(`Expected a numeric value, received ${value}`); - } - return parsed; -} - -function writeSetupStatus(status: Awaited>): void { - writeStdoutLine("Google Meet setup: %s", status.ok ? "OK" : "needs attention"); - for (const check of status.checks) { - writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); - } -} - -function formatBoolean(value: boolean | undefined): string { - return typeof value === "boolean" ? (value ? "yes" : "no") : "unknown"; -} - -function formatOptional(value: unknown): string { - return typeof value === "string" && value.trim() ? value : "n/a"; -} - -function parsePositiveNumber(value: string | undefined, label: string): number | undefined { - if (value === undefined) { - return undefined; - } - const trimmed = value.trim(); - const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN; - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${label} must be a positive number`); - } - return parsed; -} - -function resolveGoogleMeetGatewayTimeoutMs(timeoutMs: unknown): number { - return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) - ? (clampTimerTimeoutMs(Math.ceil(timeoutMs)) ?? 1) - : GOOGLE_MEET_GATEWAY_DEFAULT_TIMEOUT_MS; -} - -function resolveGoogleMeetOAuthCallbackTimeoutMs(timeoutSec: string | undefined): number { - return ( - clampTimerTimeoutMs((parsePositiveNumber(timeoutSec, "timeout-sec") ?? 300) * 1000) ?? 300_000 - ); -} - -function parsePositiveIntegerOption(value: string | undefined, label: string): number | undefined { - if (value === undefined) { - return undefined; - } - const parsed = parseStrictPositiveInteger(value); - if (parsed === undefined) { - throw new Error(`${label} must be a positive integer`); - } - return parsed; -} - -async function callGoogleMeetGateway(params: { - callGateway: typeof callGatewayFromCli; - method: GoogleMeetGatewayMethod; - payload?: Record; - timeoutMs?: number; -}): Promise { - try { - const timeoutMs = resolveGoogleMeetGatewayTimeoutMs(params.timeoutMs); - return { - ok: true, - payload: await params.callGateway( - params.method, - { json: true, timeout: String(timeoutMs) }, - params.payload, - { progress: false }, - ), - }; - } catch (err) { - if (isGatewayUnavailableForLocalFallback(err, params.method)) { - return { ok: false, error: err }; - } - throw err; - } -} - -function formatDuration(value: number | undefined): string { - if (value === undefined) { - return "n/a"; - } - return prettyMilliseconds(Math.max(0, Math.round(value / 1000) * 1000), { - unitCount: 2, - }); -} - -function writeDoctorStatus(status: Awaited>): void { - if (!status.found) { - writeStdoutLine("Google Meet session: not found"); - return; - } - const sessions = status.session ? [status.session] : (status.sessions ?? []); - if (sessions.length === 0) { - writeStdoutLine("Google Meet sessions: none"); - return; - } - writeStdoutLine("Google Meet sessions: %d", sessions.length); - for (const session of sessions) { - const health = session.chrome?.health; - writeStdoutLine(""); - writeStdoutLine("session: %s", session.id); - writeStdoutLine("url: %s", session.url); - writeStdoutLine("state: %s", session.state); - writeStdoutLine("transport: %s", session.transport); - writeStdoutLine("mode: %s", session.mode); - if (session.twilio) { - writeStdoutLine("twilio dial-in: %s", session.twilio.dialInNumber); - writeStdoutLine("voice call id: %s", formatOptional(session.twilio.voiceCallId)); - writeStdoutLine("dtmf sent: %s", formatBoolean(session.twilio.dtmfSent)); - writeStdoutLine("intro sent: %s", formatBoolean(session.twilio.introSent)); - } - if (!session.chrome) { - continue; - } - writeStdoutLine("node: %s", session.chrome?.nodeId ?? "local/none"); - writeStdoutLine("audio bridge: %s", session.chrome?.audioBridge?.type ?? "none"); - const bridgeProvider = - session.chrome?.audioBridge?.provider ?? - session.realtime.transcriptionProvider ?? - session.realtime.provider ?? - "n/a"; - writeStdoutLine( - session.mode === "agent" ? "transcription provider: %s" : "provider: %s", - bridgeProvider, - ); - if (session.realtime.enabled) { - writeStdoutLine("talk-back mode: %s", session.realtime.strategy ?? session.mode); - } - writeStdoutLine("in call: %s", formatBoolean(health?.inCall)); - writeStdoutLine("lobby waiting: %s", formatBoolean(health?.lobbyWaiting)); - writeStdoutLine("captioning: %s", formatBoolean(health?.captioning)); - writeStdoutLine("transcript lines: %s", health?.transcriptLines ?? 0); - writeStdoutLine("last caption: %s", formatOptional(health?.lastCaptionAt)); - writeStdoutLine("manual action: %s", formatBoolean(health?.manualActionRequired)); - if (health?.manualActionRequired) { - writeStdoutLine("manual reason: %s", formatOptional(health.manualActionReason)); - writeStdoutLine("manual message: %s", formatOptional(health.manualActionMessage)); - } - writeStdoutLine("speech ready: %s", formatBoolean(health?.speechReady)); - if (health?.speechReady === false) { - writeStdoutLine("speech blocked reason: %s", formatOptional(health.speechBlockedReason)); - writeStdoutLine("speech blocked message: %s", formatOptional(health.speechBlockedMessage)); - } - writeStdoutLine("provider connected: %s", formatBoolean(health?.providerConnected)); - writeStdoutLine("realtime ready: %s", formatBoolean(health?.realtimeReady)); - writeStdoutLine("audio input active: %s", formatBoolean(health?.audioInputActive)); - writeStdoutLine("audio output active: %s", formatBoolean(health?.audioOutputActive)); - writeStdoutLine("meet output routed: %s", formatBoolean(health?.audioOutputRouted)); - if (health?.audioOutputDeviceLabel || health?.audioOutputRouteError) { - writeStdoutLine("meet output device: %s", formatOptional(health.audioOutputDeviceLabel)); - writeStdoutLine("meet output route error: %s", formatOptional(health.audioOutputRouteError)); - } - writeStdoutLine( - "last input: %s (%s bytes)", - formatOptional(health?.lastInputAt), - health?.lastInputBytes ?? 0, - ); - writeStdoutLine( - "last output: %s (%s bytes)", - formatOptional(health?.lastOutputAt), - health?.lastOutputBytes ?? 0, - ); - writeStdoutLine("bridge closed: %s", formatBoolean(health?.bridgeClosed)); - writeStdoutLine("browser url: %s", formatOptional(health?.browserUrl)); - if (health?.lastCaptionText) { - const speaker = health.lastCaptionSpeaker ? `${health.lastCaptionSpeaker}: ` : ""; - writeStdoutLine("last caption text: %s%s", speaker, health.lastCaptionText); - } - writeStdoutLine("realtime transcript lines: %s", health?.realtimeTranscriptLines ?? 0); - if (health?.lastRealtimeTranscriptText) { - const role = health.lastRealtimeTranscriptRole - ? `${health.lastRealtimeTranscriptRole}: ` - : ""; - writeStdoutLine("last realtime transcript: %s%s", role, health.lastRealtimeTranscriptText); - } - if (health?.lastRealtimeEventType) { - const detail = health.lastRealtimeEventDetail ? ` ${health.lastRealtimeEventDetail}` : ""; - writeStdoutLine("last realtime event: %s%s", health.lastRealtimeEventType, detail); - } - } -} - -type OAuthDoctorCheck = { - id: string; - ok: boolean; - message: string; -}; - -type OAuthDoctorReport = { - ok: boolean; - configured: boolean; - tokenSource?: "cached-access-token" | "refresh-token"; - expiresAt?: number; - scope?: string; - meetingUri?: string; - createdSpace?: string; - checks: OAuthDoctorCheck[]; -}; - -function sanitizeOAuthErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message - .replace(/(access_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") - .replace(/(refresh_token["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]") - .replace(/(client_secret["'=:\s]+)[^"',\s&]+/gi, "$1[redacted]"); -} - -async function buildOAuthDoctorReport( - config: GoogleMeetConfig, - options: DoctorOptions, -): Promise { - const clientId = options.clientId?.trim() || config.oauth.clientId; - const clientSecret = options.clientSecret?.trim() || config.oauth.clientSecret; - const refreshToken = options.refreshToken?.trim() || config.oauth.refreshToken; - const accessToken = options.accessToken?.trim() || config.oauth.accessToken; - const expiresAt = parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt; - const checks: OAuthDoctorCheck[] = []; - - const hasRefreshConfig = Boolean(clientId && refreshToken); - const hasAccessConfig = Boolean(accessToken); - if (!hasRefreshConfig && !hasAccessConfig) { - checks.push({ - id: "oauth-config", - ok: false, - message: - "Missing Google Meet OAuth credentials. Configure oauth.clientId and oauth.refreshToken, or pass --client-id and --refresh-token.", - }); - return { ok: false, configured: false, checks }; - } - - checks.push({ - id: "oauth-config", - ok: true, - message: hasRefreshConfig - ? "Google Meet OAuth refresh credentials are configured" - : "Google Meet cached access token is configured", - }); - - let token: Awaited>; - try { - token = await resolveGoogleMeetAccessToken({ - clientId, - clientSecret, - refreshToken, - accessToken, - expiresAt, - }); - checks.push({ - id: "oauth-token", - ok: true, - message: token.refreshed - ? "Refresh token minted an access token" - : "Cached access token is still valid", - }); - } catch (error) { - checks.push({ - id: "oauth-token", - ok: false, - message: sanitizeOAuthErrorMessage(error), - }); - return { ok: false, configured: true, checks }; - } - - const report: OAuthDoctorReport = { - ok: true, - configured: true, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - expiresAt: token.expiresAt, - checks, - }; - - const meeting = options.meeting?.trim(); - if (meeting) { - try { - const space = await fetchGoogleMeetSpace({ accessToken: token.accessToken, meeting }); - checks.push({ - id: "meet-spaces-get", - ok: true, - message: `Resolved ${space.name}`, - }); - report.meetingUri = space.meetingUri; - } catch (error) { - checks.push({ - id: "meet-spaces-get", - ok: false, - message: sanitizeOAuthErrorMessage(error), - }); - } - } - - if (options.createSpace) { - try { - const created = await createGoogleMeetSpace({ accessToken: token.accessToken }); - checks.push({ - id: "meet-spaces-create", - ok: true, - message: `Created ${created.space.name}`, - }); - report.createdSpace = created.space.name; - report.meetingUri = created.meetingUri; - } catch (error) { - checks.push({ - id: "meet-spaces-create", - ok: false, - message: sanitizeOAuthErrorMessage(error), - }); - } - } - - report.ok = checks.every((check) => check.ok); - return report; -} - -function writeOAuthDoctorReport(report: OAuthDoctorReport): void { - writeStdoutLine("Google Meet OAuth: %s", report.ok ? "OK" : "needs attention"); - writeStdoutLine("configured: %s", report.configured ? "yes" : "no"); - if (report.tokenSource) { - writeStdoutLine("token source: %s", report.tokenSource); - } - if (report.meetingUri) { - writeStdoutLine("meeting uri: %s", report.meetingUri); - } - for (const check of report.checks) { - writeStdoutLine("[%s] %s: %s", check.ok ? "ok" : "fail", check.id, check.message); - } -} - -function writeRecoverCurrentTabResult( - result: Awaited>, -): void { - writeStdoutLine("Google Meet current tab: %s", result.found ? "found" : "not found"); - writeStdoutLine("transport: %s", result.transport); - writeStdoutLine("node: %s", result.nodeId ?? "local/none"); - if (result.targetId) { - writeStdoutLine("target: %s", result.targetId); - } - if (result.tab?.url) { - writeStdoutLine("tab url: %s", result.tab.url); - } - writeStdoutLine("message: %s", result.message); - if (result.browser) { - writeDoctorStatus({ - found: true, - session: { - id: "current-tab", - url: result.browser.browserUrl ?? result.tab?.url ?? "unknown", - transport: result.transport, - mode: "transcribe", - agentId: "main", - state: "active", - createdAt: "", - updatedAt: "", - participantIdentity: - result.transport === "chrome-node" - ? "signed-in Google Chrome profile on a paired node" - : "signed-in Google Chrome profile", - realtime: { enabled: false, toolPolicy: "safe-read-only" }, - chrome: { - audioBackend: "blackhole-2ch", - launched: true, - nodeId: result.nodeId, - health: result.browser, - }, - notes: [], - }, - }); - } -} - -function writeLeaveResult(sessionId: string, result: { browserLeft?: boolean }): void { - if (result.browserLeft === false) { - writeStdoutLine( - "left %s, but the browser participant may still be in the call; check session notes", - sessionId, - ); - return; - } - writeStdoutLine("left %s", sessionId); -} +export { + buildGoogleMeetExportManifest, + googleMeetExportFileNames, + writeMeetExportBundle, +} from "./cli-export.js"; +export { testing }; function resolveMeetingInput(config: GoogleMeetConfig, value?: string): string { const meeting = value?.trim() || config.defaults.meeting; @@ -805,573 +208,12 @@ function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boole ); } -function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void { - if (result.input) { - writeStdoutLine("input: %s", result.input); - } - if (result.space) { - writeStdoutLine("space: %s", result.space.name); - } - writeStdoutLine("conference records: %d", result.conferenceRecords.length); - for (const entry of result.artifacts) { - writeStdoutLine(""); - writeStdoutLine("record: %s", entry.conferenceRecord.name); - writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime)); - writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime)); - writeStdoutLine("participants: %d", entry.participants.length); - writeStdoutLine("recordings: %d", entry.recordings.length); - writeStdoutLine("transcripts: %d", entry.transcripts.length); - writeStdoutLine( - "transcript entries: %d", - entry.transcriptEntries.reduce((count, transcript) => count + transcript.entries.length, 0), - ); - writeStdoutLine("smart notes: %d", entry.smartNotes.length); - if (entry.smartNotesError) { - writeStdoutLine("smart notes warning: %s", entry.smartNotesError); - } - for (const recording of entry.recordings) { - writeStdoutLine("- recording: %s", recording.name); - } - 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) { - writeStdoutLine( - "- transcript entries warning: %s: %s", - transcriptEntries.transcript, - transcriptEntries.entriesError, - ); - } - } - for (const smartNote of entry.smartNotes) { - writeStdoutLine("- smart note: %s", smartNote.name); - if (smartNote.documentTextError) { - writeStdoutLine("- smart note document body warning: %s", smartNote.documentTextError); - } - } - } -} - -function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void { - if (result.input) { - writeStdoutLine("input: %s", result.input); - } - if (result.space) { - writeStdoutLine("space: %s", result.space.name); - } - writeStdoutLine("conference records: %d", result.conferenceRecords.length); - writeStdoutLine("attendance rows: %d", result.attendance.length); - for (const row of result.attendance) { - const identity = row.displayName || row.user || row.participant; - writeStdoutLine(""); - writeStdoutLine("participant: %s", identity); - writeStdoutLine("record: %s", row.conferenceRecord); - writeStdoutLine("resource: %s", row.participant); - writeStdoutLine("participants merged: %d", row.participants?.length ?? 1); - writeStdoutLine("first joined: %s", formatOptional(row.firstJoinTime ?? row.earliestStartTime)); - writeStdoutLine("last left: %s", formatOptional(row.lastLeaveTime ?? row.latestEndTime)); - writeStdoutLine("duration: %s", formatDuration(row.durationMs)); - writeStdoutLine("late: %s", row.late ? formatDuration(row.lateByMs) : "no"); - writeStdoutLine("early leave: %s", row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"); - writeStdoutLine("sessions: %d", row.sessions.length); - for (const session of row.sessions) { - writeStdoutLine( - "- %s: %s -> %s", - session.name, - formatOptional(session.startTime), - formatOptional(session.endTime), - ); - } - } -} - -function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRecordResult): void { - writeStdoutLine("input: %s", result.input); - writeStdoutLine("space: %s", result.space.name); - if (!result.conferenceRecord) { - writeStdoutLine("conference record: none"); - return; - } - writeStdoutLine("conference record: %s", result.conferenceRecord.name); - writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime)); - writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime)); -} - -function writeCalendarEventsSummary( - result: Awaited>, -): void { - writeStdoutLine("calendar: %s", result.calendarId); - writeStdoutLine("meet events: %d", result.events.length); - for (const entry of result.events) { - writeStdoutLine(""); - writeStdoutLine("%s%s", entry.selected ? "* " : "- ", entry.event.summary ?? "untitled"); - writeStdoutLine("meeting uri: %s", entry.meetingUri); - writeStdoutLine( - "starts: %s", - formatOptional(entry.event.start?.dateTime ?? entry.event.start?.date), - ); - writeStdoutLine("ends: %s", formatOptional(entry.event.end?.dateTime ?? entry.event.end?.date)); - } -} - -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 participantDisplayName( - entry: GoogleMeetArtifactsResult["artifacts"][number], - name: string, -): string { - const participant = entry.participants.find((candidate) => candidate.name === name); - if (!participant) { - return name; - } - return ( - participant.signedinUser?.displayName ?? - participant.anonymousUser?.displayName ?? - participant.phoneUser?.displayName ?? - participant.signedinUser?.user ?? - name - ); -} - -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}`); - 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"); - 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}`); - if (transcript.documentTextError) { - pushMarkdownLine(lines, ` - Document body warning: ${transcript.documentTextError}`); - } else if (transcript.documentText) { - pushMarkdownLine(lines, ` - Document body: ${transcript.documentText.length} chars`); - } - } - } - 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 - ? `${participantDisplayName(entry, 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}`); - if (smartNote.documentTextError) { - pushMarkdownLine(lines, ` - Document body warning: ${smartNote.documentTextError}`); - } else if (smartNote.documentText) { - pushMarkdownLine(lines, ` - Document body: ${smartNote.documentText.length} chars`); - } - } - } - } - 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, `Participants merged: ${row.participants?.length ?? 1}`); - pushMarkdownLine( - lines, - `First joined: ${formatMarkdownOptional(row.firstJoinTime ?? row.earliestStartTime)}`, - ); - pushMarkdownLine( - lines, - `Last left: ${formatMarkdownOptional(row.lastLeaveTime ?? row.latestEndTime)}`, - ); - pushMarkdownLine(lines, `Duration: ${formatDuration(row.durationMs)}`); - pushMarkdownLine(lines, `Late: ${row.late ? formatDuration(row.lateByMs) : "no"}`); - pushMarkdownLine( - lines, - `Early leave: ${row.earlyLeave ? formatDuration(row.earlyLeaveByMs) : "no"}`, - ); - 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`; -} - -function neutralizeSpreadsheetFormulaCell(text: string): string { - return /^[ \t\r\n]*[=+\-@\uFF0B\uFF0D\uFF1D\uFF20]/u.test(text) || /^[\t\r\n]/.test(text) - ? `'${text}` - : text; -} - -function csvCell(value: unknown): string { - const text = - value === undefined || value === null - ? "" - : typeof value === "string" || typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value); - const safeText = neutralizeSpreadsheetFormulaCell(text); - return /[",\r\n]/.test(safeText) ? `"${safeText.replaceAll('"', '""')}"` : safeText; -} - -function renderAttendanceCsv(result: GoogleMeetAttendanceResult): string { - const rows: unknown[][] = [ - [ - "conferenceRecord", - "displayName", - "user", - "participants", - "firstJoined", - "lastLeft", - "durationMs", - "sessions", - "late", - "lateByMs", - "earlyLeave", - "earlyLeaveByMs", - ], - ]; - for (const row of result.attendance) { - rows.push([ - row.conferenceRecord, - row.displayName ?? "", - row.user ?? "", - (row.participants ?? [row.participant]).join(";"), - row.firstJoinTime ?? row.earliestStartTime ?? "", - row.lastLeaveTime ?? row.latestEndTime ?? "", - row.durationMs ?? "", - row.sessions.length, - row.late ?? "", - row.lateByMs ?? "", - row.earlyLeave ?? "", - row.earlyLeaveByMs ?? "", - ]); - } - return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`; -} - -function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string { - const lines: string[] = ["# Google Meet Transcript"]; - if (result.input) { - pushMarkdownLine(lines, `Input: ${result.input}`); - } - for (const entry of result.artifacts) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, `## ${entry.conferenceRecord.name}`); - if (entry.transcriptEntries.length === 0) { - pushMarkdownLine(lines, "_No transcript entries._"); - continue; - } - for (const transcriptEntries of entry.transcriptEntries) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, `### ${transcriptEntries.transcript}`); - if (transcriptEntries.entriesError) { - pushMarkdownLine(lines, `Warning: ${transcriptEntries.entriesError}`); - continue; - } - for (const transcriptEntry of transcriptEntries.entries) { - const speaker = transcriptEntry.participant - ? participantDisplayName(entry, transcriptEntry.participant) - : "unknown"; - const time = transcriptEntry.startTime ? ` [${transcriptEntry.startTime}]` : ""; - pushMarkdownLine(lines, `- ${speaker}${time}: ${transcriptEntry.text ?? ""}`); - } - } - const docsTranscripts = entry.transcripts.filter((transcript) => transcript.documentText); - if (docsTranscripts.length > 0) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, "### Transcript Document Bodies"); - for (const transcript of docsTranscripts) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, `#### ${transcript.name}`); - pushMarkdownLine(lines, transcript.documentText?.trim() || "_Empty document body._"); - } - } - const smartNotes = entry.smartNotes.filter((smartNote) => smartNote.documentText); - if (smartNotes.length > 0) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, "### Smart Note Document Bodies"); - for (const smartNote of smartNotes) { - pushMarkdownLine(lines); - pushMarkdownLine(lines, `#### ${smartNote.name}`); - pushMarkdownLine(lines, smartNote.documentText?.trim() || "_Empty document body._"); - } - } - } - return `${lines.join("\n")}\n`; -} - -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; -} - -export 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, - }; -} - -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, "-")}`; -} - -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 = googleMeetExportFileNames(); - const files = [ - { - name: "summary.md", - content: `${renderArtifactsMarkdown(params.artifacts)}\n${renderAttendanceMarkdown(params.attendance)}`, - }, - { name: "attendance.csv", content: renderAttendanceCsv(params.attendance) }, - { 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"); - } - const result: { outputDir: string; files: string[]; zipFile?: string } = { - outputDir, - files: files.map((file) => path.join(outputDir, file.name)), - }; - if (zipFile) { - const zip = new JSZip(); - for (const file of files) { - zip.file(file.name, file.content); - } - await writeFile(zipFile, await zip.generateAsync({ type: "nodebuffer" })); - result.zipFile = zipFile; - } - return result; -} - export function registerGoogleMeetCli(params: { program: Command; config: GoogleMeetConfig; ensureRuntime: () => Promise; callGatewayFromCli?: typeof callGatewayFromCli; -}) { +}): void { const callGateway = params.callGatewayFromCli ?? callGatewayFromCli; const operationTimeoutMs = resolveGoogleMeetGatewayOperationTimeoutMs(params.config); const root = params.program @@ -1439,929 +281,26 @@ export function registerGoogleMeetCli(params: { } writeStdoutJson(payload); }); + const context: GoogleMeetCliCommandContext = { + root, + config: params.config, + ensureRuntime: params.ensureRuntime, + callGateway, + operationTimeoutMs, + resolveMeetingInput, + resolveOAuthTokenOptions, + resolveTokenOptions, + resolveMeetingForToken, + resolveCreateTokenOptions, + resolveArtifactTokenOptions, + hasCreateOAuth, + }; - root - .command("create") - .description("Create a new Google Meet space and print its meeting URL") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option( - "--access-type ", - "Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED", - ) - .option( - "--entry-point-access ", - "Google Meet SpaceConfig entryPointAccess for API create: ALL or CREATOR_APP_ONLY", - ) - .option("--no-join", "Only create the meeting URL; do not join it") - .option("--transport ", "Join transport: chrome, chrome-node, or twilio") - .option("--mode ", "Join mode: agent, bidi, or transcribe") - .option("--message ", "Realtime speech to trigger after join") - .option("--dial-in-number ", "Meet dial-in number for Twilio transport") - .option("--pin ", "Meet phone PIN; # is appended if omitted") - .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") - .option("--json", "Print JSON output", false) - .action(async (options: CreateOptions) => { - if (options.join !== false) { - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.create", - payload: { ...options }, - timeoutMs: operationTimeoutMs, - }); - if (delegated.ok) { - const payload = delegated.payload as { - browser?: { nodeId?: string }; - joined?: boolean; - join?: { session?: { id?: string } }; - meetingUri?: string; - source?: string; - space?: { name?: string; meetingCode?: string }; - tokenSource?: string; - }; - if (options.json) { - writeStdoutJson(payload); - return; - } - writeStdoutLine("meeting uri: %s", payload.meetingUri); - if (payload.space?.name) { - writeStdoutLine("space: %s", payload.space.name); - } - if (payload.space?.meetingCode) { - writeStdoutLine("meeting code: %s", payload.space.meetingCode); - } - if (payload.source) { - writeStdoutLine("source: %s", payload.source); - } - if (payload.browser?.nodeId) { - writeStdoutLine("node: %s", payload.browser.nodeId); - } - if (payload.tokenSource) { - writeStdoutLine("token source: %s", payload.tokenSource); - } - if (payload.joined && payload.join?.session?.id) { - writeStdoutLine("joined: %s", payload.join.session.id); - } else { - writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", payload.meetingUri); - } - return; - } - } - if (!hasCreateOAuth(params.config, options)) { - if (hasCreateSpaceConfigInput(options as Record)) { - throw new Error( - "Google Meet access policy options require OAuth/API room creation. Configure Google Meet OAuth or remove --access-type/--entry-point-access.", - ); - } - const rt = await params.ensureRuntime(); - const result = await rt.createViaBrowser(); - const join = - options.join !== false - ? await rt.join({ - url: result.meetingUri, - transport: options.transport, - mode: options.mode, - message: options.message, - dialInNumber: options.dialInNumber, - pin: options.pin, - dtmfSequence: options.dtmfSequence, - }) - : undefined; - const payload = { - source: result.source, - meetingUri: result.meetingUri, - joined: Boolean(join), - ...(join ? { join } : {}), - browser: { - nodeId: result.nodeId, - targetId: result.targetId, - browserUrl: result.browserUrl, - browserTitle: result.browserTitle, - }, - }; - if (options.json) { - writeStdoutJson(payload); - return; - } - writeStdoutLine("meeting uri: %s", result.meetingUri); - writeStdoutLine("source: browser"); - writeStdoutLine("node: %s", result.nodeId); - if (join) { - writeStdoutLine("joined: %s", join.session.id); - } else { - writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); - } - return; - } - const token = await resolveGoogleMeetAccessToken( - resolveCreateTokenOptions(params.config, options), - ); - const result = await createGoogleMeetSpace({ - accessToken: token.accessToken, - config: resolveCreateSpaceConfig(options as Record), - }); - const join = - options.join !== false - ? await ( - await params.ensureRuntime() - ).join({ - url: result.meetingUri, - transport: options.transport, - mode: options.mode, - message: options.message, - dialInNumber: options.dialInNumber, - pin: options.pin, - dtmfSequence: options.dtmfSequence, - }) - : undefined; - if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - joined: Boolean(join), - ...(join ? { join } : {}), - }); - return; - } - writeStdoutLine("meeting uri: %s", result.meetingUri); - writeStdoutLine("space: %s", result.space.name); - if (result.space.meetingCode) { - writeStdoutLine("meeting code: %s", result.space.meetingCode); - } - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - if (join) { - writeStdoutLine("joined: %s", join.session.id); - } else { - writeStdoutLine("joined: no (run `openclaw googlemeet join %s`)", result.meetingUri); - } - }); - - root - .command("end-active-conference") - .description("End the active conference for a Google Meet space") - .argument("[meeting]", "Meet URL, meeting code, or spaces/{id}") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (meeting: string | undefined, options: ResolveSpaceOptions & JsonOptions) => { - const token = await resolveGoogleMeetAccessToken( - resolveOAuthTokenOptions(params.config, options), - ); - const result = await endGoogleMeetActiveConference({ - accessToken: token.accessToken, - meeting: resolveMeetingInput(params.config, meeting ?? options.meeting), - }); - if (options.json) { - writeStdoutJson({ - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); - return; - } - writeStdoutLine("space: %s", result.space); - writeStdoutLine("ended: yes"); - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("join") - .argument("[url]", "Explicit https://meet.google.com/... URL") - .option("--transport ", "Transport: chrome, chrome-node, or twilio") - .option("--mode ", "Mode: agent, bidi, or transcribe") - .option("--message ", "Realtime speech to trigger after join") - .option("--dial-in-number ", "Meet dial-in number for Twilio transport") - .option("--pin ", "Meet phone PIN; # is appended if omitted") - .option("--dtmf-sequence ", "Explicit Twilio DTMF sequence") - .action(async (url: string | undefined, options: JoinOptions) => { - const payload = { - url: resolveMeetingInput(params.config, url), - transport: options.transport, - mode: options.mode, - message: options.message, - dialInNumber: options.dialInNumber, - pin: options.pin, - dtmfSequence: options.dtmfSequence, - }; - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.join", - payload, - timeoutMs: operationTimeoutMs, - }); - if (delegated.ok) { - const result = delegated.payload as { session?: unknown }; - writeStdoutJson(result.session ?? delegated.payload); - return; - } - const rt = await params.ensureRuntime(); - const result = await rt.join(payload); - writeStdoutJson(result.session); - }); - - root - .command("test-speech") - .argument("[url]", "Explicit https://meet.google.com/... URL") - .option("--transport ", "Transport: chrome, chrome-node, or twilio") - .option("--mode ", "Mode: agent, bidi, or transcribe") - .option( - "--message ", - "Realtime speech to trigger", - "Say exactly: Google Meet speech test complete.", - ) - .action(async (url: string | undefined, options: JoinOptions) => { - const payload = { - url: resolveMeetingInput(params.config, url), - transport: options.transport, - mode: options.mode, - message: options.message, - }; - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.testSpeech", - payload, - timeoutMs: operationTimeoutMs, - }); - if (delegated.ok) { - writeStdoutJson(delegated.payload); - return; - } - const rt = await params.ensureRuntime(); - writeStdoutJson(await rt.testSpeech(payload)); - }); - - root - .command("test-listen") - .argument("[url]", "Explicit https://meet.google.com/... URL") - .option("--transport ", "Transport: chrome or chrome-node") - .option("--timeout-ms ", "How long to wait for fresh captions/transcript movement") - .action(async (url: string | undefined, options: JoinOptions) => { - const payload = { - url: resolveMeetingInput(params.config, url), - transport: options.transport, - timeoutMs: parsePositiveNumber(options.timeoutMs, "timeout-ms"), - }; - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.testListen", - payload, - timeoutMs: operationTimeoutMs, - }); - if (delegated.ok) { - writeStdoutJson(delegated.payload); - return; - } - const rt = await params.ensureRuntime(); - writeStdoutJson(await rt.testListen(payload)); - }); - - root - .command("resolve-space") - .description("Resolve a Meet URL, meeting code, or spaces/{id} to its canonical space") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (options: ResolveSpaceOptions) => { - const resolved = resolveTokenOptions(params.config, options); - const token = await resolveGoogleMeetAccessToken(resolved); - const space = await fetchGoogleMeetSpace({ - accessToken: token.accessToken, - meeting: resolved.meeting, - }); - if (options.json) { - writeStdoutJson(space); - return; - } - writeStdoutLine("input: %s", resolved.meeting); - writeStdoutLine("space: %s", space.name); - if (space.meetingCode) { - writeStdoutLine("meeting code: %s", space.meetingCode); - } - if (space.meetingUri) { - writeStdoutLine("meeting uri: %s", space.meetingUri); - } - writeStdoutLine("active conference: %s", space.activeConference ? "yes" : "no"); - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("preflight") - .description("Validate OAuth + meeting resolution prerequisites for Meet media work") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (options: ResolveSpaceOptions) => { - const resolved = resolveTokenOptions(params.config, options); - const token = await resolveGoogleMeetAccessToken(resolved); - const space = await fetchGoogleMeetSpace({ - accessToken: token.accessToken, - meeting: resolved.meeting, - }); - const report = buildGoogleMeetPreflightReport({ - input: resolved.meeting, - space, - previewAcknowledged: params.config.preview.enrollmentAcknowledged, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); - if (options.json) { - writeStdoutJson(report); - return; - } - writeStdoutLine("input: %s", report.input); - writeStdoutLine("resolved space: %s", report.resolvedSpaceName); - if (report.meetingCode) { - writeStdoutLine("meeting code: %s", report.meetingCode); - } - if (report.meetingUri) { - writeStdoutLine("meeting uri: %s", report.meetingUri); - } - writeStdoutLine("active conference: %s", report.hasActiveConference ? "yes" : "no"); - writeStdoutLine("preview acknowledged: %s", report.previewAcknowledged ? "yes" : "no"); - writeStdoutLine("token source: %s", report.tokenSource); - if (report.blockers.length === 0) { - writeStdoutLine("blockers: none"); - return; - } - writeStdoutLine("blockers:"); - for (const blocker of report.blockers) { - writeStdoutLine("- %s", blocker); - } - }); - - root - .command("latest") - .description("Find the latest Meet conference record for a meeting") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--today", "Find a Meet link on today's calendar") - .option("--event ", "Find a matching calendar event with a Meet link") - .option("--calendar ", "Calendar id for --today or --event", "primary") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (options: ResolveSpaceOptions) => { - const token = await resolveGoogleMeetAccessToken( - resolveOAuthTokenOptions(params.config, options), - ); - const resolved = await resolveMeetingForToken({ - config: params.config, - options, - accessToken: token.accessToken, - configuredMeeting: options.meeting?.trim(), - }); - const result = await fetchLatestGoogleMeetConferenceRecord({ - accessToken: token.accessToken, - meeting: resolved.meeting, - }); - if (options.json) { - writeStdoutJson({ - ...result, - ...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}), - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }); - return; - } - if (resolved.calendarEvent) { - writeStdoutLine("calendar event: %s", resolved.calendarEvent.event.summary ?? "untitled"); - writeStdoutLine("calendar meet: %s", resolved.calendarEvent.meetingUri); - } - writeLatestConferenceRecordSummary(result); - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("calendar-events") - .description("Preview Calendar events with Google Meet links") - .option("--today", "Find Meet links on today's calendar") - .option("--event ", "Find matching calendar events with Meet links") - .option("--calendar ", "Calendar id for lookup", "primary") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (options: ResolveSpaceOptions) => { - const token = await resolveGoogleMeetAccessToken( - resolveOAuthTokenOptions(params.config, options), - ); - const window = options.today ? buildGoogleMeetCalendarDayWindow() : {}; - const result = await listGoogleMeetCalendarEvents({ - accessToken: token.accessToken, - calendarId: options.calendar, - eventQuery: options.event, - ...window, - }); - const payload = { - ...result, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }; - if (options.json) { - writeStdoutJson(payload); - return; - } - writeCalendarEventsSummary(result); - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("artifacts") - .description("List Meet conference records and available participant/artifact metadata") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--conference-record ", "Conference record name or id") - .option("--today", "Find a Meet link on today's calendar") - .option("--event ", "Find a matching calendar event with a Meet link") - .option("--calendar ", "Calendar id for --today or --event", "primary") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .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("--all-conference-records", "Fetch every conference record for --meeting") - .option("--no-transcript-entries", "Skip structured transcript entry lookup") - .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") - .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); - const token = await resolveGoogleMeetAccessToken(resolved); - const meeting = resolved.conferenceRecord - ? resolved.meeting - : ( - await resolveMeetingForToken({ - config: params.config, - options, - accessToken: token.accessToken, - configuredMeeting: resolved.meeting, - }) - ).meeting; - const result = await fetchGoogleMeetArtifacts({ - accessToken: token.accessToken, - meeting, - conferenceRecord: resolved.conferenceRecord, - pageSize: resolved.pageSize, - includeTranscriptEntries: resolved.includeTranscriptEntries, - allConferenceRecords: resolved.allConferenceRecords, - includeDocumentBodies: resolved.includeDocumentBodies, - }); - if (options.json) { - 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", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("attendance") - .description("List Meet participants and participant sessions") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--conference-record ", "Conference record name or id") - .option("--today", "Find a Meet link on today's calendar") - .option("--event ", "Find a matching calendar event with a Meet link") - .option("--calendar ", "Calendar id for --today or --event", "primary") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .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("--all-conference-records", "Fetch every conference record for --meeting") - .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") - .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") - .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") - .option("--format ", "Output format: summary, markdown, or csv", "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); - const token = await resolveGoogleMeetAccessToken(resolved); - const meeting = resolved.conferenceRecord - ? resolved.meeting - : ( - await resolveMeetingForToken({ - config: params.config, - options, - accessToken: token.accessToken, - configuredMeeting: resolved.meeting, - }) - ).meeting; - const result = await fetchGoogleMeetAttendance({ - accessToken: token.accessToken, - meeting, - conferenceRecord: resolved.conferenceRecord, - pageSize: resolved.pageSize, - allConferenceRecords: resolved.allConferenceRecords, - mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, - lateAfterMinutes: resolved.lateAfterMinutes, - earlyBeforeMinutes: resolved.earlyBeforeMinutes, - }); - if (options.json) { - 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 === "csv") { - await writeCliOutput(options, renderAttendanceCsv(result)); - return; - } - if (options.format && options.format !== "summary") { - throw new Error("Unsupported format. Expected summary, markdown, or csv."); - } - writeAttendanceSummary(result); - writeStdoutLine( - "token source: %s", - token.refreshed ? "refresh-token" : "cached-access-token", - ); - }); - - root - .command("export") - .description("Write Meet artifacts, attendance, transcript, and raw JSON into a folder") - .option("--meeting ", "Meet URL, meeting code, or spaces/{id}") - .option("--conference-record ", "Conference record name or id") - .option("--today", "Find a Meet link on today's calendar") - .option("--event ", "Find a matching calendar event with a Meet link") - .option("--calendar ", "Calendar id for --today or --event", "primary") - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .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("--all-conference-records", "Fetch every conference record for --meeting") - .option("--no-transcript-entries", "Skip structured transcript entry lookup") - .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") - .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") - .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") - .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") - .option("--output ", "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); - const token = await resolveGoogleMeetAccessToken(resolved); - const meetingResult: { meeting?: string; calendarEvent?: GoogleMeetCalendarLookupResult } = - resolved.conferenceRecord - ? { meeting: resolved.meeting } - : await resolveMeetingForToken({ - config: params.config, - options, - accessToken: token.accessToken, - configuredMeeting: resolved.meeting, - }); - const artifacts = await fetchGoogleMeetArtifacts({ - accessToken: token.accessToken, - meeting: meetingResult.meeting, - conferenceRecord: resolved.conferenceRecord, - pageSize: resolved.pageSize, - includeTranscriptEntries: resolved.includeTranscriptEntries, - allConferenceRecords: resolved.allConferenceRecords, - includeDocumentBodies: resolved.includeDocumentBodies, - }); - const attendance = await fetchGoogleMeetAttendance({ - accessToken: token.accessToken, - meeting: meetingResult.meeting, - conferenceRecord: resolved.conferenceRecord, - pageSize: resolved.pageSize, - allConferenceRecords: resolved.allConferenceRecords, - mergeDuplicateParticipants: resolved.mergeDuplicateParticipants, - 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 } - : {}), - }; - 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, - attendance, - zip: Boolean(options.zip), - request, - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), - }); - const payload = { - ...bundle, - ...(meetingResult.calendarEvent ? { calendarEvent: meetingResult.calendarEvent } : {}), - tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", - }; - if (options.json) { - writeStdoutJson(payload); - return; - } - writeStdoutLine("export: %s", bundle.outputDir); - for (const file of bundle.files) { - writeStdoutLine("- %s", file); - } - if (bundle.zipFile) { - writeStdoutLine("zip: %s", bundle.zipFile); - } - }); - - root - .command("status") - .argument("[session-id]", "Meet session ID") - .option("--json", "Print JSON output", false) - .action(async (sessionId?: string) => { - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.status", - payload: { sessionId }, - }); - if (delegated.ok) { - writeStdoutJson(delegated.payload); - return; - } - const rt = await params.ensureRuntime(); - writeStdoutJson(await rt.status(sessionId)); - }); - - root - .command("transcript") - .description("Print the bounded caption transcript for a Meet session") - .argument("", "Meet session ID") - .option("--since ", "Resume from the previous response's nextIndex") - .option("--json", "Print JSON output", false) - .action(async (sessionId: string, options: { since?: string; json?: boolean }) => { - const sinceIndex = parseStrictNonNegativeInteger(options.since); - if (options.since !== undefined && sinceIndex === undefined) { - throw new Error("--since must be a non-negative safe integer"); - } - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.transcript", - payload: { sessionId, ...(sinceIndex === undefined ? {} : { sinceIndex }) }, - }); - const result = delegated.ok - ? (delegated.payload as Awaited>) - : await ( - await params.ensureRuntime() - ).transcript(sessionId, sinceIndex === undefined ? {} : { sinceIndex }); - if (!result.found) { - throw new Error("session not found"); - } - if (options.json) { - writeStdoutJson(result); - return; - } - if (result.evicted) { - writeStdoutLine("# transcript evicted from runtime memory"); - } else if (result.droppedLines) { - writeStdoutLine("# %d earlier lines dropped by the transcript cap", result.droppedLines); - } - for (const line of result.lines ?? []) { - writeStdoutLine( - "%s%s%s", - line.at ? `[${line.at}] ` : "", - line.speaker ? `${line.speaker}: ` : "", - line.text, - ); - } - writeStdoutLine("# nextIndex: %d", result.nextIndex ?? 0); - }); - - root - .command("doctor") - .description("Show human-readable Meet session/browser/realtime health") - .argument("[session-id]", "Meet session ID") - .option("--oauth", "Verify Google Meet OAuth token refresh without printing secrets", false) - .option("--meeting ", "Also verify spaces.get for a Meet URL, code, or spaces/{id}") - .option("--create-space", "Also verify spaces.create by creating a throwaway Meet space", false) - .option("--access-token ", "Access token override") - .option("--refresh-token ", "Refresh token override") - .option("--client-id ", "OAuth client id override") - .option("--client-secret ", "OAuth client secret override") - .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") - .option("--json", "Print JSON output", false) - .action(async (sessionId: string | undefined, options: DoctorOptions) => { - if (options.oauth) { - const report = await buildOAuthDoctorReport(params.config, options); - if (options.json) { - writeStdoutJson(report); - return; - } - writeOAuthDoctorReport(report); - return; - } - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.status", - payload: { sessionId }, - }); - if (delegated.ok) { - const status = delegated.payload as Awaited>; - if (options.json) { - writeStdoutJson(status); - return; - } - writeDoctorStatus(status); - return; - } - const rt = await params.ensureRuntime(); - const status = await rt.status(sessionId); - if (options.json) { - writeStdoutJson(status); - return; - } - writeDoctorStatus(status); - }); - - root - .command("recover-tab") - .description("Focus and inspect an existing Google Meet tab") - .argument("[url]", "Optional Meet URL to match") - .option("--transport ", "Transport to inspect: chrome or chrome-node") - .option("--json", "Print JSON output", false) - .action(async (url: string | undefined, options: RecoverTabOptions) => { - const rt = await params.ensureRuntime(); - const result = await rt.recoverCurrentTab({ url, transport: options.transport }); - if (options.json) { - writeStdoutJson(result); - return; - } - writeRecoverCurrentTabResult(result); - }); - - root - .command("setup") - .description("Show Google Meet transport setup status") - .option("--transport ", "Transport to check: chrome, chrome-node, or twilio") - .option("--mode ", "Mode to check: agent, bidi, or transcribe") - .option("--json", "Print JSON output", false) - .action(async (options: SetupOptions) => { - const rt = await params.ensureRuntime(); - const status = await rt.setupStatus({ transport: options.transport, mode: options.mode }); - if (options.json) { - writeStdoutJson(status); - return; - } - writeSetupStatus(status); - }); - - root - .command("leave") - .argument("", "Meet session ID") - .action(async (sessionId: string) => { - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.leave", - payload: { sessionId }, - }); - if (delegated.ok) { - const result = delegated.payload as { found?: boolean; browserLeft?: boolean }; - if (!result.found) { - throw new Error("session not found"); - } - writeLeaveResult(sessionId, result); - return; - } - const rt = await params.ensureRuntime(); - const result = await rt.leave(sessionId); - if (!result.found) { - throw new Error("session not found"); - } - writeLeaveResult(sessionId, result); - }); - - root - .command("speak") - .argument("", "Meet session ID") - .argument("[message]", "Realtime instructions to speak now") - .action(async (sessionId: string, message?: string) => { - const delegated = await callGoogleMeetGateway({ - callGateway, - method: "googlemeet.speak", - payload: { sessionId, message }, - }); - if (delegated.ok) { - const result = delegated.payload as Awaited>; - if (!result.found) { - throw new Error("session not found"); - } - if (!result.spoken) { - throw new Error( - result.session?.chrome?.health?.speechBlockedMessage ?? - "session has no active realtime audio bridge", - ); - } - writeStdoutLine("speaking on %s", sessionId); - return; - } - const rt = await params.ensureRuntime(); - const result = await rt.speak(sessionId, message); - if (!result.found) { - throw new Error("session not found"); - } - if (!result.spoken) { - throw new Error( - result.session?.chrome?.health?.speechBlockedMessage ?? - "session has no active realtime audio bridge", - ); - } - writeStdoutLine("speaking on %s", sessionId); - }); + registerGoogleMeetCreateCommands(context); + registerGoogleMeetProbeCommands(context); + registerGoogleMeetApiCommands(context); + registerGoogleMeetArtifactCommands(context); + registerGoogleMeetSessionCommands(context); + registerGoogleMeetDoctorCommand(context); + registerGoogleMeetLifecycleCommands(context); } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/google-meet/src/test-support/cli-harness.ts b/extensions/google-meet/src/test-support/cli-harness.ts new file mode 100644 index 000000000000..278bd6520331 --- /dev/null +++ b/extensions/google-meet/src/test-support/cli-harness.ts @@ -0,0 +1,238 @@ +// Shared Google Meet CLI test harness. +import { Command } from "commander"; +import { expect, vi } from "vitest"; +import { registerGoogleMeetCli } from "../cli.js"; +import { resolveGoogleMeetConfig } from "../config.js"; +import type { GoogleMeetRuntime } from "../runtime.js"; + +const fetchGuardMocks = vi.hoisted(() => ({ + fetchWithSsrFGuard: vi.fn( + async (params: { + url: string; + init?: RequestInit; + }): Promise<{ + response: Response; + release: () => Promise; + }> => ({ + response: await fetch(params.url, params.init), + release: vi.fn(async () => {}), + }), + ), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); + +export function captureStdout() { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + return { + output: () => output, + restore: () => writeSpy.mockRestore(), + }; +} + +export function expectFields(value: unknown, expected: Record): void { + if (!value || typeof value !== "object") { + throw new Error("expected fields object"); + } + const record = value as Record; + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key], key).toEqual(expectedValue); + } +} + +export function firstRecord(value: unknown): Record { + expect(Array.isArray(value)).toBe(true); + const [record] = value as unknown[]; + if (!record || typeof record !== "object") { + throw new Error("expected first record"); + } + return record as Record; +} + +export function parseStdoutJson(stdout: { output: () => string }): Record { + return JSON.parse(stdout.output()) as Record; +} + +export function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +export function requestUrl(input: RequestInfo | URL): URL { + if (typeof input === "string") { + return new URL(input); + } + if (input instanceof URL) { + return input; + } + return new URL(input.url); +} + +export function stubMeetArtifactsApi( + options: { failSmartNoteDocumentBody?: boolean; participantDisplayName?: string } = {}, +) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.pathname === "/v2/spaces/abc-defg-hij") { + return jsonResponse({ + name: "spaces/abc-defg-hij", + meetingCode: "abc-defg-hij", + meetingUri: "https://meet.google.com/abc-defg-hij", + }); + } + if (url.pathname === "/calendar/v3/calendars/primary/events") { + return jsonResponse({ + items: [ + { + id: "event-1", + summary: "Project sync", + hangoutLink: "https://meet.google.com/abc-defg-hij", + start: { dateTime: "2026-04-25T10:00:00Z" }, + end: { dateTime: "2026-04-25T10:30:00Z" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords") { + return jsonResponse({ + conferenceRecords: [ + { + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1") { + return jsonResponse({ + name: "conferenceRecords/rec-1", + space: "spaces/abc-defg-hij", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:30:00Z", + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants") { + return jsonResponse({ + participants: [ + { + name: "conferenceRecords/rec-1/participants/p1", + signedinUser: { + user: "users/alice", + displayName: options.participantDisplayName ?? "Alice", + }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") { + return jsonResponse({ + participantSessions: [ + { + name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1", + startTime: "2026-04-25T10:00:00Z", + endTime: "2026-04-25T10:10:00Z", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") { + return jsonResponse({ + recordings: [ + { + name: "conferenceRecords/rec-1/recordings/r1", + state: "FILE_GENERATED", + driveDestination: { file: "drive-file-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") { + return jsonResponse({ + transcripts: [ + { + name: "conferenceRecords/rec-1/transcripts/t1", + state: "FILE_GENERATED", + docsDestination: { document: "doc-1" }, + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") { + return jsonResponse({ + transcriptEntries: [ + { + name: "conferenceRecords/rec-1/transcripts/t1/entries/e1", + text: "Hello from the transcript.", + startTime: "2026-04-25T10:01:00Z", + participant: "conferenceRecords/rec-1/participants/p1", + }, + ], + }); + } + if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") { + return jsonResponse({ + smartNotes: [ + { + name: "conferenceRecords/rec-1/smartNotes/sn1", + state: "FILE_GENERATED", + docsDestination: { document: "notes-1" }, + }, + ], + }); + } + if (url.pathname === "/drive/v3/files/doc-1/export") { + return new Response("Transcript document body.", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } + 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" }, + }); + } + return new Response("not found", { status: 404 }); + }), + ); +} + +export function setupCli(params: { + config?: Parameters[0]; + runtime?: Partial; + ensureRuntime?: () => Promise; + callGatewayFromCli?: Parameters[0]["callGatewayFromCli"]; +}) { + const program = new Command(); + registerGoogleMeetCli({ + program, + config: resolveGoogleMeetConfig(params.config ?? {}), + ensureRuntime: + params.ensureRuntime ?? (async () => (params.runtime ?? {}) as unknown as GoogleMeetRuntime), + callGatewayFromCli: + params.callGatewayFromCli ?? + (vi.fn(async () => { + throw new Error("connect ECONNREFUSED 127.0.0.1:18789"); + }) as NonNullable[0]["callGatewayFromCli"]>), + }); + return program; +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 7c0c78b01126..3efb98016ca0 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -2400,7 +2400,47 @@ const SOURCE_TEST_TARGETS = new Map([ ], ["src/plugin-sdk/reply-runtime.ts", ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"]], ["extensions/google-meet/index.ts", ["extensions/google-meet/index.test.ts"]], - ["extensions/google-meet/src/cli.ts", ["extensions/google-meet/src/cli.test.ts"]], + [ + "extensions/google-meet/src/cli.ts", + [ + "extensions/google-meet/src/cli-artifacts.test.ts", + "extensions/google-meet/src/cli-runtime.test.ts", + "extensions/google-meet/src/cli.test.ts", + ], + ], + [ + "extensions/google-meet/src/cli-artifact-commands.ts", + ["extensions/google-meet/src/cli-artifacts.test.ts"], + ], + [ + "extensions/google-meet/src/cli-export.ts", + ["extensions/google-meet/src/cli-artifacts.test.ts"], + ], + [ + "extensions/google-meet/src/cli-space-commands.ts", + ["extensions/google-meet/src/cli-artifacts.test.ts"], + ], + [ + "extensions/google-meet/src/cli-runtime-commands.ts", + ["extensions/google-meet/src/cli-runtime.test.ts"], + ], + ["extensions/google-meet/src/cli-doctor.ts", ["extensions/google-meet/src/cli.test.ts"]], + [ + "extensions/google-meet/src/cli-command-context.ts", + [ + "extensions/google-meet/src/cli-artifacts.test.ts", + "extensions/google-meet/src/cli-runtime.test.ts", + "extensions/google-meet/src/cli.test.ts", + ], + ], + [ + "extensions/google-meet/src/cli-shared.ts", + [ + "extensions/google-meet/src/cli-artifacts.test.ts", + "extensions/google-meet/src/cli-runtime.test.ts", + "extensions/google-meet/src/cli.test.ts", + ], + ], ["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]], ["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]], [ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index b10716b1fb21..15db45f64303 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -3740,7 +3740,11 @@ describe("scripts/test-projects changed-target routing", () => { it("routes Google Meet CLI edits to the lightweight CLI tests", () => { expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/cli.ts"])).toEqual({ mode: "targets", - targets: ["extensions/google-meet/src/cli.test.ts"], + targets: [ + "extensions/google-meet/src/cli-artifacts.test.ts", + "extensions/google-meet/src/cli-runtime.test.ts", + "extensions/google-meet/src/cli.test.ts", + ], }); });