mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
feat(google-meet): polish exports and calendar previews
This commit is contained in:
56
extensions/google-meet/google-meet.live.test.ts
Normal file
56
extensions/google-meet/google-meet.live.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
72
extensions/google-meet/src/drive.ts
Normal file
72
extensions/google-meet/src/drive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
extensions/google-meet/src/google-api-errors.ts
Normal file
20
extensions/google-meet/src/google-api-errors.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user