mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:31:33 +00:00
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
This commit is contained in:
committed by
GitHub
parent
cca67fc842
commit
bac2bbd243
@@ -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
|
||||
|
||||
293
extensions/google-meet/src/cli-artifact-commands.ts
Normal file
293
extensions/google-meet/src/cli-artifact-commands.ts
Normal file
@@ -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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--conference-record <name>", "Conference record name or id")
|
||||
.option("--today", "Find a Meet link on today's calendar")
|
||||
.option("--event <query>", "Find a matching calendar event with a Meet link")
|
||||
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
|
||||
.option("--page-size <n>", "Max resources per Meet API page")
|
||||
.option("--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 <format>", "Output format: summary or markdown", "summary")
|
||||
.option("--output <path>", "Write output to a file instead of stdout")
|
||||
.option("--json", "Print JSON output", false)
|
||||
.action(async (options: MeetArtifactOptions) => {
|
||||
const resolved = resolveArtifactTokenOptions(params.config, options);
|
||||
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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--conference-record <name>", "Conference record name or id")
|
||||
.option("--today", "Find a Meet link on today's calendar")
|
||||
.option("--event <query>", "Find a matching calendar event with a Meet link")
|
||||
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
|
||||
.option("--page-size <n>", "Max resources per Meet API page")
|
||||
.option("--all-conference-records", "Fetch every conference record for --meeting")
|
||||
.option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows")
|
||||
.option("--late-after-minutes <n>", "Mark participants late after this many minutes", "5")
|
||||
.option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
|
||||
.option("--format <format>", "Output format: summary, markdown, or csv", "summary")
|
||||
.option("--output <path>", "Write output to a file instead of stdout")
|
||||
.option("--json", "Print JSON output", false)
|
||||
.action(async (options: MeetArtifactOptions) => {
|
||||
const resolved = resolveArtifactTokenOptions(params.config, options);
|
||||
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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--conference-record <name>", "Conference record name or id")
|
||||
.option("--today", "Find a Meet link on today's calendar")
|
||||
.option("--event <query>", "Find a matching calendar event with a Meet link")
|
||||
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
|
||||
.option("--page-size <n>", "Max resources per Meet API page")
|
||||
.option("--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 <n>", "Mark participants late after this many minutes", "5")
|
||||
.option("--early-before-minutes <n>", "Mark early leavers before this many minutes", "5")
|
||||
.option("--output <dir>", "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);
|
||||
}
|
||||
});
|
||||
}
|
||||
586
extensions/google-meet/src/cli-artifacts.test.ts
Normal file
586
extensions/google-meet/src/cli-artifacts.test.ts
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
58
extensions/google-meet/src/cli-command-context.ts
Normal file
58
extensions/google-meet/src/cli-command-context.ts
Normal file
@@ -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<GoogleMeetRuntime>;
|
||||
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;
|
||||
};
|
||||
209
extensions/google-meet/src/cli-doctor.ts
Normal file
209
extensions/google-meet/src/cli-doctor.ts
Normal file
@@ -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<OAuthDoctorReport> {
|
||||
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<ReturnType<typeof resolveGoogleMeetAccessToken>>;
|
||||
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 <value>", "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 <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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<ReturnType<GoogleMeetRuntime["status"]>>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
580
extensions/google-meet/src/cli-export.ts
Normal file
580
extensions/google-meet/src/cli-export.ts
Normal file
@@ -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<ReturnType<typeof listGoogleMeetCalendarEvents>>,
|
||||
): 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;
|
||||
}
|
||||
278
extensions/google-meet/src/cli-runtime-commands.ts
Normal file
278
extensions/google-meet/src/cli-runtime-commands.ts
Normal file
@@ -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>", "Transport: chrome, chrome-node, or twilio")
|
||||
.option("--mode <mode>", "Mode: agent, bidi, or transcribe")
|
||||
.option("--message <text>", "Realtime speech to trigger after join")
|
||||
.option("--dial-in-number <phone>", "Meet dial-in number for Twilio transport")
|
||||
.option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
|
||||
.option("--dtmf-sequence <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>", "Transport: chrome, chrome-node, or twilio")
|
||||
.option("--mode <mode>", "Mode: agent, bidi, or transcribe")
|
||||
.option(
|
||||
"--message <text>",
|
||||
"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>", "Transport: chrome or chrome-node")
|
||||
.option("--timeout-ms <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("<session-id>", "Meet session ID")
|
||||
.option("--since <index>", "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<ReturnType<GoogleMeetRuntime["transcript"]>>)
|
||||
: 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>", "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>", "Transport to check: chrome, chrome-node, or twilio")
|
||||
.option("--mode <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("<session-id>", "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("<session-id>", "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<ReturnType<GoogleMeetRuntime["speak"]>>;
|
||||
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);
|
||||
});
|
||||
}
|
||||
467
extensions/google-meet/src/cli-runtime.test.ts
Normal file
467
extensions/google-meet/src/cli-runtime.test.ts
Normal file
@@ -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<GoogleMeetRuntime>,
|
||||
}).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<GoogleMeetRuntime>,
|
||||
}).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<string, unknown>,
|
||||
{ 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<GoogleMeetRuntime>,
|
||||
}).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
474
extensions/google-meet/src/cli-shared.ts
Normal file
474
extensions/google-meet/src/cli-shared.ts
Normal file
@@ -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<void> {
|
||||
if (options.output?.trim()) {
|
||||
await writeFile(options.output, text.endsWith("\n") ? text : `${text}\n`, "utf8");
|
||||
writeStdoutLine("wrote: %s", options.output);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
|
||||
export async function promptInput(message: string): Promise<string> {
|
||||
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<ReturnType<GoogleMeetRuntime["setupStatus"]>>,
|
||||
): 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<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<GoogleMeetGatewayCallResult> {
|
||||
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<ReturnType<GoogleMeetRuntime["status"]>>): 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<ReturnType<GoogleMeetRuntime["recoverCurrentTab"]>>,
|
||||
): 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);
|
||||
}
|
||||
401
extensions/google-meet/src/cli-space-commands.ts
Normal file
401
extensions/google-meet/src/cli-space-commands.ts
Normal file
@@ -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 <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
|
||||
.option(
|
||||
"--access-type <type>",
|
||||
"Google Meet SpaceConfig accessType for API create: OPEN, TRUSTED, or RESTRICTED",
|
||||
)
|
||||
.option(
|
||||
"--entry-point-access <type>",
|
||||
"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 <transport>", "Join transport: chrome, chrome-node, or twilio")
|
||||
.option("--mode <mode>", "Join mode: agent, bidi, or transcribe")
|
||||
.option("--message <text>", "Realtime speech to trigger after join")
|
||||
.option("--dial-in-number <phone>", "Meet dial-in number for Twilio transport")
|
||||
.option("--pin <pin>", "Meet phone PIN; # is appended if omitted")
|
||||
.option("--dtmf-sequence <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<string, unknown>)) {
|
||||
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<string, unknown>),
|
||||
});
|
||||
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 <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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 <value>", "Meet URL, meeting code, or spaces/{id}")
|
||||
.option("--today", "Find a Meet link on today's calendar")
|
||||
.option("--event <query>", "Find a matching calendar event with a Meet link")
|
||||
.option("--calendar <id>", "Calendar id for --today or --event", "primary")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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 <query>", "Find matching calendar events with Meet links")
|
||||
.option("--calendar <id>", "Calendar id for lookup", "primary")
|
||||
.option("--access-token <token>", "Access token override")
|
||||
.option("--refresh-token <token>", "Refresh token override")
|
||||
.option("--client-id <id>", "OAuth client id override")
|
||||
.option("--client-secret <secret>", "OAuth client secret override")
|
||||
.option("--expires-at <ms>", "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",
|
||||
);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
238
extensions/google-meet/src/test-support/cli-harness.ts
Normal file
238
extensions/google-meet/src/test-support/cli-harness.ts
Normal file
@@ -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<void>;
|
||||
}> => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
release: vi.fn(async () => {}),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
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<string, unknown>): void {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("expected fields object");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
expect(record[key], key).toEqual(expectedValue);
|
||||
}
|
||||
}
|
||||
|
||||
export function firstRecord(value: unknown): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseStdoutJson(stdout: { output: () => string }): Record<string, unknown> {
|
||||
return JSON.parse(stdout.output()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<typeof resolveGoogleMeetConfig>[0];
|
||||
runtime?: Partial<GoogleMeetRuntime>;
|
||||
ensureRuntime?: () => Promise<GoogleMeetRuntime>;
|
||||
callGatewayFromCli?: Parameters<typeof registerGoogleMeetCli>[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<Parameters<typeof registerGoogleMeetCli>[0]["callGatewayFromCli"]>),
|
||||
});
|
||||
return program;
|
||||
}
|
||||
@@ -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"]],
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user