From 03484b74abd55f5ef031affb08071036bc42b55a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 08:28:24 +0100 Subject: [PATCH] feat(google-meet): polish exports and calendar previews --- .../google-meet/google-meet.live.test.ts | 56 +++++ extensions/google-meet/index.test.ts | 103 ++++++++ extensions/google-meet/index.ts | 45 ++++ extensions/google-meet/src/calendar.ts | 78 +++++-- extensions/google-meet/src/cli.test.ts | 45 ++++ extensions/google-meet/src/cli.ts | 220 +++++++++++++++++- extensions/google-meet/src/drive.ts | 72 ++++++ .../google-meet/src/google-api-errors.ts | 20 ++ extensions/google-meet/src/meet.ts | 91 +++++++- extensions/google-meet/src/oauth.test.ts | 1 + extensions/google-meet/src/oauth.ts | 1 + 11 files changed, 708 insertions(+), 24 deletions(-) create mode 100644 extensions/google-meet/google-meet.live.test.ts create mode 100644 extensions/google-meet/src/drive.ts create mode 100644 extensions/google-meet/src/google-api-errors.ts diff --git a/extensions/google-meet/google-meet.live.test.ts b/extensions/google-meet/google-meet.live.test.ts new file mode 100644 index 00000000000..402b938db77 --- /dev/null +++ b/extensions/google-meet/google-meet.live.test.ts @@ -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); +}); diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index b36f95a1c7d..78b111a7f6b 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -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( { diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 3f0794c5c0f..563cc9e86a7 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -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, }), ); diff --git a/extensions/google-meet/src/calendar.ts b/extensions/google-meet/src/calendar.ts index 04b99d4bb2b..181a04dddc0 100644 --- a/extensions/google-meet/src/calendar.ts +++ b/extensions/google-meet/src/calendar.ts @@ -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) { 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 { +}): 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 { + 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 { + 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, + }; +} diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index a2244c705ab..5df945e787a 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -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 }); } }); diff --git a/extensions/google-meet/src/cli.ts b/extensions/google-meet/src/cli.ts index d995e71cba5..144e9fd8a5e 100644 --- a/extensions/google-meet/src/cli.ts +++ b/extensions/google-meet/src/cli.ts @@ -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>, +): 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 ", "Find matching calendar events with Meet links") + .option("--calendar ", "Calendar id for lookup", "primary") + .option("--access-token ", "Access token override") + .option("--refresh-token ", "Refresh token override") + .option("--client-id ", "OAuth client id override") + .option("--client-secret ", "OAuth client secret override") + .option("--expires-at ", "Cached access token expiry as unix epoch milliseconds") + .option("--json", "Print JSON output", false) + .action(async (options: ResolveSpaceOptions) => { + const token = await resolveGoogleMeetAccessToken( + resolveOAuthTokenOptions(params.config, options), + ); + const window = options.today ? buildGoogleMeetCalendarDayWindow() : {}; + const result = await listGoogleMeetCalendarEvents({ + accessToken: token.accessToken, + calendarId: options.calendar, + eventQuery: options.event, + ...window, + }); + const payload = { + ...result, + tokenSource: token.refreshed ? "refresh-token" : "cached-access-token", + }; + if (options.json) { + writeStdoutJson(payload); + return; + } + writeCalendarEventsSummary(result); + writeStdoutLine( + "token source: %s", + token.refreshed ? "refresh-token" : "cached-access-token", + ); + }); + root .command("artifacts") .description("List Meet conference records and available participant/artifact metadata") @@ -1261,6 +1464,7 @@ export function registerGoogleMeetCli(params: { .option("--page-size ", "Max resources per Meet API page") .option("--all-conference-records", "Fetch every conference record for --meeting") .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") .option("--format ", "Output format: summary or markdown", "summary") .option("--output ", "Write output to a file instead of stdout") .option("--json", "Print JSON output", false) @@ -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 ", "Max resources per Meet API page") .option("--all-conference-records", "Fetch every conference record for --meeting") .option("--no-transcript-entries", "Skip structured transcript entry lookup") + .option("--include-doc-bodies", "Export linked transcript and smart-note Google Docs text") .option("--no-merge-duplicates", "Keep duplicate participant resources as separate rows") .option("--late-after-minutes ", "Mark participants late after this many minutes", "5") .option("--early-before-minutes ", "Mark early leavers before this many minutes", "5") .option("--output ", "Output directory") + .option("--zip", "Also write a portable .zip archive") .option("--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 diff --git a/extensions/google-meet/src/drive.ts b/extensions/google-meet/src/drive.ts new file mode 100644 index 00000000000..503fb6c8fc6 --- /dev/null +++ b/extensions/google-meet/src/drive.ts @@ -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) { + 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 { + 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(); + } +} diff --git a/extensions/google-meet/src/google-api-errors.ts b/extensions/google-meet/src/google-api-errors.ts new file mode 100644 index 00000000000..b78bf2a7764 --- /dev/null +++ b/extensions/google-meet/src/google-api-errors.ts @@ -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 { + 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}`, + ); +} diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 6dfc4561dda..33bb8ed90f9 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -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; + documentText?: string; + documentTextError?: string; }; export type GoogleMeetTranscriptEntry = { @@ -93,6 +100,8 @@ export type GoogleMeetSmartNote = { startTime?: string; endTime?: string; docsDestination?: Record; + documentText?: string; + documentTextError?: string; }; export type GoogleMeetArtifactsEntry = { @@ -258,7 +267,12 @@ async function fetchGoogleMeetJson(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 | undefined, +): string | undefined { + return ( + extractGoogleDriveDocumentId(destination?.document) ?? + extractGoogleDriveDocumentId(destination?.documentId) ?? + extractGoogleDriveDocumentId(destination?.file) + ); +} + +async function attachDocumentText }>(params: { + accessToken: string; + resource: T; +}): Promise { + 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 { 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 } : {}), diff --git a/extensions/google-meet/src/oauth.test.ts b/extensions/google-meet/src/oauth.test.ts index 85ddee52a08..d961f185427 100644 --- a/extensions/google-meet/src/oauth.test.ts +++ b/extensions/google-meet/src/oauth.test.ts @@ -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({ diff --git a/extensions/google-meet/src/oauth.ts b/extensions/google-meet/src/oauth.ts index c3e1aa4e0d9..941627c514c 100644 --- a/extensions/google-meet/src/oauth.ts +++ b/extensions/google-meet/src/oauth.ts @@ -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 = {