feat(google-meet): polish exports and calendar previews

This commit is contained in:
Peter Steinberger
2026-04-25 08:28:24 +01:00
parent e0beea97aa
commit 03484b74ab
11 changed files with 708 additions and 24 deletions

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
import { fetchGoogleMeetArtifacts, fetchLatestGoogleMeetConferenceRecord } from "./src/meet.js";
import { resolveGoogleMeetAccessToken } from "./src/oauth.js";
const LIVE_MEETING = process.env.OPENCLAW_GOOGLE_MEET_LIVE_MEETING?.trim() ?? "";
const CLIENT_ID =
process.env.OPENCLAW_GOOGLE_MEET_CLIENT_ID?.trim() ??
process.env.GOOGLE_MEET_CLIENT_ID?.trim() ??
"";
const CLIENT_SECRET =
process.env.OPENCLAW_GOOGLE_MEET_CLIENT_SECRET?.trim() ??
process.env.GOOGLE_MEET_CLIENT_SECRET?.trim();
const REFRESH_TOKEN =
process.env.OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN?.trim() ??
process.env.GOOGLE_MEET_REFRESH_TOKEN?.trim() ??
"";
const ACCESS_TOKEN =
process.env.OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN?.trim() ??
process.env.GOOGLE_MEET_ACCESS_TOKEN?.trim();
const EXPIRES_AT = Number(
process.env.OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT ??
process.env.GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT,
);
const LIVE =
isLiveTestEnabled() &&
LIVE_MEETING.length > 0 &&
((CLIENT_ID.length > 0 && REFRESH_TOKEN.length > 0) || Boolean(ACCESS_TOKEN));
const describeLive = LIVE ? describe : describe.skip;
describeLive("google-meet live", () => {
it("resolves latest conference record and artifacts for a real meeting", async () => {
const token = await resolveGoogleMeetAccessToken({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
refreshToken: REFRESH_TOKEN,
accessToken: ACCESS_TOKEN,
expiresAt: Number.isFinite(EXPIRES_AT) ? EXPIRES_AT : undefined,
});
const latest = await fetchLatestGoogleMeetConferenceRecord({
accessToken: token.accessToken,
meeting: LIVE_MEETING,
});
expect(latest.space.name).toMatch(/^spaces\//);
const artifacts = await fetchGoogleMeetArtifacts({
accessToken: token.accessToken,
meeting: LIVE_MEETING,
pageSize: 5,
});
expect(artifacts.conferenceRecords.length).toBeLessThanOrEqual(1);
expect(Array.isArray(artifacts.artifacts)).toBe(true);
}, 120_000);
});

View File

@@ -6,6 +6,7 @@ import plugin from "./index.js";
import {
extractGoogleMeetUriFromCalendarEvent,
findGoogleMeetCalendarEvent,
listGoogleMeetCalendarEvents,
} from "./src/calendar.js";
import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js";
import {
@@ -186,6 +187,18 @@ function stubMeetArtifactsApi() {
],
});
}
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/doc-2/export") {
return new Response("Smart note document body.", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
return new Response(`unexpected ${url.pathname}`, { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
@@ -347,6 +360,7 @@ describe("google-meet plugin", () => {
"resolve_space",
"preflight",
"latest",
"calendar_events",
"artifacts",
"attendance",
"recover_current_tab",
@@ -400,6 +414,21 @@ describe("google-meet plugin", () => {
meetingUri: "https://meet.google.com/abc-defg-hij",
event: { summary: "Project sync" },
});
await expect(
listGoogleMeetCalendarEvents({
accessToken: "token",
now: new Date("2026-04-25T09:50:00Z"),
timeMin: "2026-04-25T00:00:00Z",
timeMax: "2026-04-26T00:00:00Z",
}),
).resolves.toMatchObject({
events: [
{
meetingUri: "https://meet.google.com/abc-defg-hij",
selected: true,
},
],
});
const calendarCall = fetchMock.mock.calls.find(([input]) => {
const url = requestUrl(input);
return url.pathname === "/calendar/v3/calendars/primary/events";
@@ -418,6 +447,28 @@ describe("google-meet plugin", () => {
);
});
it("adds a reauth hint for missing Calendar scopes", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("insufficientPermissions", { status: 403 })),
);
await expect(
findGoogleMeetCalendarEvent({
accessToken: "token",
timeMin: "2026-04-25T00:00:00Z",
timeMax: "2026-04-26T00:00:00Z",
}),
).rejects.toThrow("calendar.events.readonly");
await expect(
findGoogleMeetCalendarEvent({
accessToken: "token",
timeMin: "2026-04-25T00:00:00Z",
timeMax: "2026-04-26T00:00:00Z",
}),
).rejects.toThrow("googlemeet auth login");
});
it("fetches Meet spaces without percent-encoding the spaces path separator", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
@@ -567,6 +618,33 @@ describe("google-meet plugin", () => {
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
});
it("exports linked Google Docs bodies when requested", async () => {
const fetchMock = stubMeetArtifactsApi();
await expect(
fetchGoogleMeetArtifacts({
accessToken: "token",
conferenceRecord: "rec-1",
includeDocumentBodies: true,
}),
).resolves.toMatchObject({
artifacts: [
{
transcripts: [{ documentText: "Transcript document body." }],
smartNotes: [{ documentText: "Smart note document body." }],
},
],
});
const driveCalls = fetchMock.mock.calls
.map(([input]) => requestUrl(input))
.filter((url) => url.pathname.startsWith("/drive/v3/files/"));
expect(driveCalls.map((url) => url.pathname)).toEqual([
"/drive/v3/files/doc-1/export",
"/drive/v3/files/doc-2/export",
]);
expect(driveCalls.every((url) => url.searchParams.get("mimeType") === "text/plain")).toBe(true);
});
it("fetches only the latest Meet conference record for a meeting", async () => {
const fetchMock = stubMeetArtifactsApi();
@@ -854,6 +932,31 @@ describe("google-meet plugin", () => {
});
});
it("reports calendar event previews through the tool", async () => {
stubMeetArtifactsApi();
const { tools } = setup();
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { events?: Array<{ selected?: boolean; meetingUri?: string }> } }>;
};
const result = await tool.execute("id", {
action: "calendar_events",
accessToken: "token",
expiresAt: Date.now() + 120_000,
today: true,
});
expect(result.details.events).toEqual([
expect.objectContaining({
selected: true,
meetingUri: "https://meet.google.com/abc-defg-hij",
}),
]);
});
it("fails setup status when the configured Chrome node is not connected", async () => {
const { tools } = setup(
{

View File

@@ -6,6 +6,7 @@ import { Type } from "typebox";
import {
buildGoogleMeetCalendarDayWindow,
findGoogleMeetCalendarEvent,
listGoogleMeetCalendarEvents,
type GoogleMeetCalendarLookupResult,
} from "./src/calendar.js";
import {
@@ -150,6 +151,7 @@ const GoogleMeetToolSchema = Type.Object({
"resolve_space",
"preflight",
"latest",
"calendar_events",
"artifacts",
"attendance",
"recover_current_tab",
@@ -200,6 +202,12 @@ const GoogleMeetToolSchema = Type.Object({
includeTranscriptEntries: Type.Optional(
Type.Boolean({ description: "For artifacts, include structured transcript entries" }),
),
includeDocumentBodies: Type.Optional(
Type.Boolean({
description:
"For artifacts, export linked transcript and smart-note Google Docs text through Drive.",
}),
),
includeAllConferenceRecords: Type.Optional(
Type.Boolean({
description:
@@ -358,6 +366,7 @@ async function resolveArtifactQueryFromParams(
conferenceRecord,
pageSize: resolveOptionalPositiveInteger(raw.pageSize),
includeTranscriptEntries: raw.includeTranscriptEntries !== false,
includeDocumentBodies: raw.includeDocumentBodies === true,
allConferenceRecords: raw.includeAllConferenceRecords === true,
mergeDuplicateParticipants: raw.mergeDuplicateParticipants !== false,
lateAfterMinutes: resolveOptionalPositiveInteger(raw.lateAfterMinutes),
@@ -499,6 +508,28 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.calendarEvents",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const token = await resolveGoogleMeetTokenFromParams(config, raw);
const window = raw.today === true ? buildGoogleMeetCalendarDayWindow() : {};
respond(
true,
await listGoogleMeetCalendarEvents({
accessToken: token.accessToken,
calendarId: normalizeOptionalString(raw.calendarId),
eventQuery: normalizeOptionalString(raw.event),
...window,
}),
);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.artifacts",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -513,6 +544,7 @@ export default definePluginEntry({
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
includeTranscriptEntries: resolved.includeTranscriptEntries,
includeDocumentBodies: resolved.includeDocumentBodies,
allConferenceRecords: resolved.allConferenceRecords,
}),
);
@@ -694,6 +726,18 @@ export default definePluginEntry({
...(resolved.calendarEvent ? { calendarEvent: resolved.calendarEvent } : {}),
});
}
case "calendar_events": {
const token = await resolveGoogleMeetTokenFromParams(config, raw);
const window = raw.today === true ? buildGoogleMeetCalendarDayWindow() : {};
return json(
await listGoogleMeetCalendarEvents({
accessToken: token.accessToken,
calendarId: normalizeOptionalString(raw.calendarId),
eventQuery: normalizeOptionalString(raw.event),
...window,
}),
);
}
case "artifacts": {
const resolved = await resolveArtifactQueryFromParams(config, raw);
return json(
@@ -703,6 +747,7 @@ export default definePluginEntry({
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
includeTranscriptEntries: resolved.includeTranscriptEntries,
includeDocumentBodies: resolved.includeDocumentBodies,
allConferenceRecords: resolved.allConferenceRecords,
}),
);

View File

@@ -1,8 +1,10 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { googleApiError } from "./google-api-errors.js";
const GOOGLE_CALENDAR_API_BASE_URL = "https://www.googleapis.com/calendar/v3";
const GOOGLE_CALENDAR_API_HOST = "www.googleapis.com";
const GOOGLE_MEET_URL_HOST = "meet.google.com";
const GOOGLE_CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events.readonly";
type GoogleCalendarEventDate = {
date?: string;
@@ -42,6 +44,15 @@ export type GoogleMeetCalendarLookupResult = {
meetingUri: string;
};
export type GoogleMeetCalendarEventsResult = {
calendarId: string;
events: Array<{
event: GoogleMeetCalendarEvent;
meetingUri: string;
selected: boolean;
}>;
};
function appendQuery(url: string, query: Record<string, string | number | boolean | undefined>) {
const parsed = new URL(url);
for (const [key, value] of Object.entries(query)) {
@@ -133,7 +144,7 @@ function chooseBestMeetCalendarEvent(
.toSorted((left, right) => rankCalendarEvent(left, nowMs) - rankCalendarEvent(right, nowMs))[0];
}
export async function findGoogleMeetCalendarEvent(params: {
async function fetchGoogleCalendarEvents(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
@@ -141,7 +152,7 @@ export async function findGoogleMeetCalendarEvent(params: {
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<GoogleMeetCalendarLookupResult> {
}): Promise<{ calendarId: string; events: GoogleMeetCalendarEvent[]; now: Date }> {
const calendarId = params.calendarId?.trim() || "primary";
const now = params.now ?? new Date();
const defaultTimeMax = new Date(now);
@@ -171,25 +182,62 @@ export async function findGoogleMeetCalendarEvent(params: {
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`Google Calendar events.list failed (${response.status}): ${detail}`);
throw await googleApiError({
response,
detail,
prefix: "Google Calendar events.list",
scopes: [GOOGLE_CALENDAR_EVENTS_SCOPE],
});
}
const payload = (await response.json()) as { items?: unknown };
if (payload.items !== undefined && !Array.isArray(payload.items)) {
throw new Error("Google Calendar events.list response had non-array items");
}
const event = chooseBestMeetCalendarEvent(
(payload.items ?? []) as GoogleMeetCalendarEvent[],
now,
);
if (!event) {
throw new Error("No Google Calendar event with a Google Meet link matched the query");
}
const meetingUri = extractGoogleMeetUriFromCalendarEvent(event);
if (!meetingUri) {
throw new Error("Matched Google Calendar event did not include a Google Meet link");
}
return { calendarId, event, meetingUri };
return { calendarId, events: (payload.items ?? []) as GoogleMeetCalendarEvent[], now };
} finally {
await release();
}
}
export async function listGoogleMeetCalendarEvents(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
timeMin?: string;
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<GoogleMeetCalendarEventsResult> {
const { calendarId, events, now } = await fetchGoogleCalendarEvents(params);
const best = chooseBestMeetCalendarEvent(events, now);
return {
calendarId,
events: events
.map((event) => {
const meetingUri = extractGoogleMeetUriFromCalendarEvent(event);
return meetingUri ? { event, meetingUri, selected: event === best } : undefined;
})
.filter((event): event is GoogleMeetCalendarEventsResult["events"][number] => Boolean(event)),
};
}
export async function findGoogleMeetCalendarEvent(params: {
accessToken: string;
calendarId?: string;
eventQuery?: string;
timeMin?: string;
timeMax?: string;
maxResults?: number;
now?: Date;
}): Promise<GoogleMeetCalendarLookupResult> {
const result = await listGoogleMeetCalendarEvents(params);
const selected = result.events.find((event) => event.selected) ?? result.events[0];
if (!selected) {
throw new Error("No Google Calendar event with a Google Meet link matched the query");
}
return {
calendarId: result.calendarId,
event: selected.event,
meetingUri: selected.meetingUri,
};
}

View File

@@ -166,6 +166,18 @@ function stubMeetArtifactsApi() {
],
});
}
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") {
return new Response("Smart note document body.", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
return new Response("not found", { status: 404 });
}),
);
@@ -354,6 +366,31 @@ describe("google-meet CLI", () => {
}
});
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("prints markdown artifact and attendance output", async () => {
stubMeetArtifactsApi();
const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-artifacts-"));
@@ -459,6 +496,8 @@ describe("google-meet CLI", () => {
String(Date.now() + 120_000),
"--conference-record",
"rec-1",
"--include-doc-bodies",
"--zip",
"--output",
tempDir,
],
@@ -474,12 +513,18 @@ describe("google-meet CLI", () => {
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.",
);
expect(JSON.parse(readFileSync(path.join(tempDir, "artifacts.json"), "utf8"))).toMatchObject({
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
artifacts: [{ transcripts: [{ documentText: "Transcript document body." }] }],
});
expect(readFileSync(`${tempDir}.zip`).subarray(0, 4).toString("hex")).toBe("504b0304");
} finally {
stdout.restore();
rmSync(tempDir, { recursive: true, force: true });
rmSync(`${tempDir}.zip`, { force: true });
}
});

View File

@@ -6,6 +6,7 @@ import type { Command } from "commander";
import {
buildGoogleMeetCalendarDayWindow,
findGoogleMeetCalendarEvent,
listGoogleMeetCalendarEvents,
type GoogleMeetCalendarLookupResult,
} from "./calendar.js";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
@@ -65,9 +66,11 @@ type MeetArtifactOptions = ResolveSpaceOptions & {
pageSize?: string;
transcriptEntries?: boolean;
allConferenceRecords?: boolean;
includeDocBodies?: boolean;
mergeDuplicates?: boolean;
lateAfterMinutes?: string;
earlyBeforeMinutes?: string;
zip?: boolean;
format?: "summary" | "markdown" | "csv";
output?: string;
};
@@ -530,6 +533,7 @@ function resolveArtifactTokenOptions(
pageSize?: number;
includeTranscriptEntries?: boolean;
allConferenceRecords?: boolean;
includeDocumentBodies?: boolean;
mergeDuplicateParticipants?: boolean;
lateAfterMinutes?: number;
earlyBeforeMinutes?: number;
@@ -552,6 +556,7 @@ function resolveArtifactTokenOptions(
pageSize: parseOptionalNumber(options.pageSize),
includeTranscriptEntries: options.transcriptEntries !== false,
allConferenceRecords: Boolean(options.allConferenceRecords),
includeDocumentBodies: Boolean(options.includeDocBodies),
mergeDuplicateParticipants: options.mergeDuplicates !== false,
lateAfterMinutes: parseOptionalNumber(options.lateAfterMinutes),
earlyBeforeMinutes: parseOptionalNumber(options.earlyBeforeMinutes),
@@ -657,6 +662,23 @@ function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRe
writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime));
}
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);
}
@@ -669,6 +691,23 @@ function formatMarkdownIdentity(row: GoogleMeetAttendanceResult["attendance"][nu
return row.displayName || row.user || row.participant;
}
function participantDisplayName(
entry: GoogleMeetArtifactsResult["artifacts"][number],
name: string,
): string {
const participant = entry.participants.find((candidate) => candidate.name === name);
if (!participant) {
return name;
}
return (
participant.signedinUser?.displayName ??
participant.anonymousUser?.displayName ??
participant.phoneUser?.displayName ??
participant.signedinUser?.user ??
name
);
}
function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
const lines: string[] = ["# Google Meet Artifacts"];
if (result.input) {
@@ -708,6 +747,11 @@ function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
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) {
@@ -728,7 +772,9 @@ function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
transcriptEntry.endTime,
)})`
: "";
const speaker = transcriptEntry.participant ? `${transcriptEntry.participant}: ` : "";
const speaker = transcriptEntry.participant
? `${participantDisplayName(entry, transcriptEntry.participant)}: `
: "";
pushMarkdownLine(lines, `- ${speaker}${transcriptEntry.text ?? ""}${times}`);
}
}
@@ -737,6 +783,11 @@ function renderArtifactsMarkdown(result: GoogleMeetArtifactsResult): string {
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`);
}
}
}
}
@@ -853,11 +904,33 @@ function renderTranscriptMarkdown(result: GoogleMeetArtifactsResult): string {
continue;
}
for (const transcriptEntry of transcriptEntries.entries) {
const speaker = transcriptEntry.participant ?? "unknown";
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`;
}
@@ -866,11 +939,94 @@ function defaultExportDirectory(): string {
return `google-meet-export-${new Date().toISOString().replace(/[:.]/g, "-")}`;
}
const CRC32_TABLE = new Uint32Array(
Array.from({ length: 256 }, (_, index) => {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
return value >>> 0;
}),
);
function crc32(buffer: Buffer): number {
let value = 0xffffffff;
for (const byte of buffer) {
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
function dosDateTime(date = new Date()): { date: number; time: number } {
return {
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
date: ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
};
}
function buildZipArchive(files: Array<{ name: string; content: string }>): Buffer {
const localParts: Buffer[] = [];
const centralParts: Buffer[] = [];
let offset = 0;
const stamp = dosDateTime();
for (const file of files) {
const name = Buffer.from(file.name, "utf8");
const content = Buffer.from(file.content, "utf8");
const checksum = crc32(content);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(0, 8);
local.writeUInt16LE(stamp.time, 10);
local.writeUInt16LE(stamp.date, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(content.length, 18);
local.writeUInt32LE(content.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, content);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(0, 10);
central.writeUInt16LE(stamp.time, 12);
central.writeUInt16LE(stamp.date, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(content.length, 20);
central.writeUInt32LE(content.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.length + name.length + content.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralDirectory, end]);
}
async function writeMeetExportBundle(params: {
outputDir?: string;
artifacts: GoogleMeetArtifactsResult;
attendance: GoogleMeetAttendanceResult;
}): Promise<{ outputDir: string; files: string[] }> {
zip?: boolean;
}): Promise<{ outputDir: string; files: string[]; zipFile?: string }> {
const outputDir = params.outputDir?.trim() || defaultExportDirectory();
await mkdir(outputDir, { recursive: true });
const files = [
@@ -886,7 +1042,16 @@ async function writeMeetExportBundle(params: {
for (const file of files) {
await writeFile(path.join(outputDir, file.name), file.content, "utf8");
}
return { outputDir, files: files.map((file) => path.join(outputDir, file.name)) };
const result: { outputDir: string; files: string[]; zipFile?: string } = {
outputDir,
files: files.map((file) => path.join(outputDir, file.name)),
};
if (params.zip) {
const zipFile = `${outputDir.replace(/\/$/, "")}.zip`;
await writeFile(zipFile, buildZipArchive(files));
result.zipFile = zipFile;
}
return result;
}
export function registerGoogleMeetCli(params: {
@@ -1245,6 +1410,44 @@ export function registerGoogleMeetCli(params: {
);
});
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",
);
});
root
.command("artifacts")
.description("List Meet conference records and available participant/artifact metadata")
@@ -1261,6 +1464,7 @@ export function registerGoogleMeetCli(params: {
.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)
@@ -1284,6 +1488,7 @@ export function registerGoogleMeetCli(params: {
pageSize: resolved.pageSize,
includeTranscriptEntries: resolved.includeTranscriptEntries,
allConferenceRecords: resolved.allConferenceRecords,
includeDocumentBodies: resolved.includeDocumentBodies,
});
if (options.json) {
await writeCliOutput(
@@ -1405,10 +1610,12 @@ export function registerGoogleMeetCli(params: {
.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("--json", "Print JSON output", false)
.action(async (options: MeetArtifactOptions) => {
const resolved = resolveArtifactTokenOptions(params.config, options);
@@ -1429,6 +1636,7 @@ export function registerGoogleMeetCli(params: {
pageSize: resolved.pageSize,
includeTranscriptEntries: resolved.includeTranscriptEntries,
allConferenceRecords: resolved.allConferenceRecords,
includeDocumentBodies: resolved.includeDocumentBodies,
});
const attendance = await fetchGoogleMeetAttendance({
accessToken: token.accessToken,
@@ -1444,6 +1652,7 @@ export function registerGoogleMeetCli(params: {
outputDir: options.output,
artifacts,
attendance,
zip: Boolean(options.zip),
});
const payload = {
...bundle,
@@ -1458,6 +1667,9 @@ export function registerGoogleMeetCli(params: {
for (const file of bundle.files) {
writeStdoutLine("- %s", file);
}
if (bundle.zipFile) {
writeStdoutLine("zip: %s", bundle.zipFile);
}
});
root

View File

@@ -0,0 +1,72 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { googleApiError } from "./google-api-errors.js";
const GOOGLE_DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3";
const GOOGLE_DRIVE_API_HOST = "www.googleapis.com";
const GOOGLE_DRIVE_MEET_SCOPE = "https://www.googleapis.com/auth/drive.meet.readonly";
const TEXT_PLAIN_MIME = "text/plain";
function appendQuery(url: string, query: Record<string, string | undefined>) {
const parsed = new URL(url);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
parsed.searchParams.set(key, value);
}
}
return parsed.toString();
}
export function extractGoogleDriveDocumentId(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (/^https?:\/\//i.test(trimmed)) {
try {
const url = new URL(trimmed);
const documentMatch = url.pathname.match(/\/document\/d\/([^/]+)/);
return documentMatch?.[1];
} catch {
return undefined;
}
}
const segments = trimmed.split("/").filter(Boolean);
return segments.at(-1);
}
export async function exportGoogleDriveDocumentText(params: {
accessToken: string;
documentId: string;
}): Promise<string> {
const { response, release } = await fetchWithSsrFGuard({
url: appendQuery(
`${GOOGLE_DRIVE_API_BASE_URL}/files/${encodeURIComponent(params.documentId)}/export`,
{ mimeType: TEXT_PLAIN_MIME },
),
init: {
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: TEXT_PLAIN_MIME,
},
},
policy: { allowedHostnames: [GOOGLE_DRIVE_API_HOST] },
auditContext: "google-meet.drive.files.export",
});
try {
if (!response.ok) {
const detail = await response.text();
throw await googleApiError({
response,
detail,
prefix: "Google Drive files.export",
scopes: [GOOGLE_DRIVE_MEET_SCOPE],
});
}
return await response.text();
} finally {
await release();
}
}

View File

@@ -0,0 +1,20 @@
const REAUTH_HINT = "Re-run `openclaw googlemeet auth login` and store the refreshed oauth block.";
function scopeText(scopes: readonly string[]): string {
return scopes.map((scope) => `\`${scope}\``).join(", ");
}
export async function googleApiError(params: {
response: Response;
detail: string;
prefix: string;
scopes?: readonly string[];
}): Promise<Error> {
const scopeHint =
params.scopes && params.scopes.length > 0
? ` Required OAuth scope: ${scopeText(params.scopes)}. ${REAUTH_HINT}`
: "";
return new Error(
`${params.prefix} failed (${params.response.status}): ${params.detail}${scopeHint}`,
);
}

View File

@@ -1,9 +1,14 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { exportGoogleDriveDocumentText, extractGoogleDriveDocumentId } from "./drive.js";
import { googleApiError } from "./google-api-errors.js";
const GOOGLE_MEET_API_ORIGIN = "https://meet.googleapis.com";
const GOOGLE_MEET_API_BASE_URL = `${GOOGLE_MEET_API_ORIGIN}/v2`;
const GOOGLE_MEET_URL_HOST = "meet.google.com";
const GOOGLE_MEET_API_HOST = "meet.googleapis.com";
const GOOGLE_MEET_MEDIA_SCOPE =
"https://www.googleapis.com/auth/meetings.conference.media.readonly";
const GOOGLE_MEET_SPACE_SCOPE = "https://www.googleapis.com/auth/meetings.space.readonly";
export type GoogleMeetSpace = {
name: string;
@@ -71,6 +76,8 @@ export type GoogleMeetTranscript = {
startTime?: string;
endTime?: string;
docsDestination?: Record<string, unknown>;
documentText?: string;
documentTextError?: string;
};
export type GoogleMeetTranscriptEntry = {
@@ -93,6 +100,8 @@ export type GoogleMeetSmartNote = {
startTime?: string;
endTime?: string;
docsDestination?: Record<string, unknown>;
documentText?: string;
documentTextError?: string;
};
export type GoogleMeetArtifactsEntry = {
@@ -258,7 +267,12 @@ async function fetchGoogleMeetJson<T>(params: {
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`${params.errorPrefix} failed (${response.status}): ${detail}`);
throw await googleApiError({
response,
detail,
prefix: params.errorPrefix,
scopes: [GOOGLE_MEET_MEDIA_SCOPE],
});
}
return (await response.json()) as T;
} finally {
@@ -320,7 +334,12 @@ export async function fetchGoogleMeetSpace(params: {
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`Google Meet spaces.get failed (${response.status}): ${detail}`);
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.get",
scopes: [GOOGLE_MEET_SPACE_SCOPE],
});
}
const payload = (await response.json()) as GoogleMeetSpace;
if (!payload.name?.trim()) {
@@ -352,7 +371,12 @@ export async function createGoogleMeetSpace(params: {
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`Google Meet spaces.create failed (${response.status}): ${detail}`);
throw await googleApiError({
response,
detail,
prefix: "Google Meet spaces.create",
scopes: ["https://www.googleapis.com/auth/meetings.space.created"],
});
}
const payload = (await response.json()) as GoogleMeetSpace;
if (!payload.name?.trim()) {
@@ -535,6 +559,40 @@ function getParticipantUser(participant: GoogleMeetParticipant): string | undefi
return participant.signedinUser?.user;
}
function getDocsDestinationDocumentId(
destination: Record<string, unknown> | undefined,
): string | undefined {
return (
extractGoogleDriveDocumentId(destination?.document) ??
extractGoogleDriveDocumentId(destination?.documentId) ??
extractGoogleDriveDocumentId(destination?.file)
);
}
async function attachDocumentText<T extends { docsDestination?: Record<string, unknown> }>(params: {
accessToken: string;
resource: T;
}): Promise<T & { documentText?: string; documentTextError?: string }> {
const documentId = getDocsDestinationDocumentId(params.resource.docsDestination);
if (!documentId) {
return params.resource;
}
try {
return {
...params.resource,
documentText: await exportGoogleDriveDocumentText({
accessToken: params.accessToken,
documentId,
}),
};
} catch (error) {
return {
...params.resource,
documentTextError: getErrorMessage(error),
};
}
}
function parseGoogleMeetTimestamp(value: string | undefined): number | undefined {
if (!value?.trim()) {
return undefined;
@@ -737,6 +795,7 @@ export async function fetchGoogleMeetArtifacts(params: {
pageSize?: number;
includeTranscriptEntries?: boolean;
allConferenceRecords?: boolean;
includeDocumentBodies?: boolean;
}): Promise<GoogleMeetArtifactsResult> {
const resolved = await resolveConferenceRecordQuery(params);
const artifacts = await Promise.all(
@@ -791,13 +850,35 @@ export async function fetchGoogleMeetArtifacts(params: {
}
}),
);
const transcriptsWithText =
params.includeDocumentBodies === true
? await Promise.all(
transcripts.map((transcript) =>
attachDocumentText({
accessToken: params.accessToken,
resource: transcript,
}),
),
)
: transcripts;
const smartNotesWithText =
params.includeDocumentBodies === true
? await Promise.all(
smartNotesResult.smartNotes.map((smartNote) =>
attachDocumentText({
accessToken: params.accessToken,
resource: smartNote,
}),
),
)
: smartNotesResult.smartNotes;
return {
conferenceRecord,
participants,
recordings,
transcripts,
transcripts: transcriptsWithText,
transcriptEntries,
smartNotes: smartNotesResult.smartNotes,
smartNotes: smartNotesWithText,
...(smartNotesResult.smartNotesError
? { smartNotesError: smartNotesResult.smartNotesError }
: {}),

View File

@@ -25,6 +25,7 @@ describe("Google Meet OAuth", () => {
expect(url.searchParams.get("scope")).toContain("meetings.space.created");
expect(url.searchParams.get("scope")).toContain("meetings.conference.media.readonly");
expect(url.searchParams.get("scope")).toContain("calendar.events.readonly");
expect(url.searchParams.get("scope")).toContain("drive.meet.readonly");
await expect(
resolveGoogleMeetAccessToken({

View File

@@ -15,6 +15,7 @@ export const GOOGLE_MEET_SCOPES = [
"https://www.googleapis.com/auth/meetings.space.readonly",
"https://www.googleapis.com/auth/meetings.conference.media.readonly",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/drive.meet.readonly",
] as const;
export type GoogleMeetOAuthTokens = {