feat(google-meet): add artifacts and attendance commands

This commit is contained in:
Peter Steinberger
2026-04-25 07:36:05 +01:00
parent 209d50b52c
commit 1752b15a21
6 changed files with 1060 additions and 5 deletions

View File

@@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete.
- Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete.
- Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts, smart notes, and participant sessions. Thanks @steipete.
- Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete.
- Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete.

View File

@@ -480,6 +480,27 @@ Run preflight before media work:
openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij
```
List meeting artifacts and attendance after Meet has created conference records:
```bash
openclaw googlemeet artifacts --meeting https://meet.google.com/abc-defg-hij
openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij
```
If you already know the conference record id, address it directly:
```bash
openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json
openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json
```
`artifacts` returns conference record metadata plus participant, recording,
transcript, and smart-note resource metadata when Google exposes it for the
meeting. `attendance` expands participants into participant-session rows with
join/leave timestamps. These commands use the Meet REST API only; transcript or
smart-note document body download is intentionally out of scope because that
requires separate Google Docs/Drive access.
Create a fresh Meet space:
```bash

View File

@@ -9,6 +9,8 @@ import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/c
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchGoogleMeetSpace,
normalizeGoogleMeetSpaceName,
} from "./src/meet.js";
@@ -64,6 +66,112 @@ function setup(
return setupGoogleMeetPlugin(plugin, config, options);
}
function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function requestUrl(input: RequestInfo | URL): URL {
if (typeof input === "string") {
return new URL(input);
}
if (input instanceof URL) {
return input;
}
return new URL(input.url);
}
function stubMeetArtifactsApi() {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = requestUrl(input);
if (url.pathname === "/v2/spaces/abc-defg-hij") {
return jsonResponse({
name: "spaces/abc-defg-hij",
meetingCode: "abc-defg-hij",
meetingUri: "https://meet.google.com/abc-defg-hij",
});
}
if (url.pathname === "/v2/conferenceRecords") {
return jsonResponse({
conferenceRecords: [
{
name: "conferenceRecords/rec-1",
space: "spaces/abc-defg-hij",
startTime: "2026-04-25T10:00:00Z",
endTime: "2026-04-25T10:30:00Z",
},
],
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1") {
return jsonResponse({
name: "conferenceRecords/rec-1",
space: "spaces/abc-defg-hij",
startTime: "2026-04-25T10:00:00Z",
endTime: "2026-04-25T10:30:00Z",
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1/participants") {
return jsonResponse({
participants: [
{
name: "conferenceRecords/rec-1/participants/p1",
earliestStartTime: "2026-04-25T10:00:00Z",
latestEndTime: "2026-04-25T10:30:00Z",
signedinUser: { user: "users/alice", displayName: "Alice" },
},
],
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") {
return jsonResponse({
participantSessions: [
{
name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
startTime: "2026-04-25T10:00:00Z",
endTime: "2026-04-25T10:30:00Z",
},
],
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") {
return jsonResponse({
recordings: [
{
name: "conferenceRecords/rec-1/recordings/r1",
driveDestination: { file: "drive/file-1" },
},
],
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") {
return jsonResponse({
transcripts: [
{
name: "conferenceRecords/rec-1/transcripts/t1",
docsDestination: { document: "docs/doc-1" },
},
],
});
}
if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") {
return jsonResponse({
smartNotes: [
{
name: "conferenceRecords/rec-1/smartNotes/sn1",
docsDestination: { document: "docs/doc-2" },
},
],
});
}
return new Response(`unexpected ${url.pathname}`, { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
type TestBridgeProcess = {
stdin?: { write(chunk: unknown): unknown } | null;
stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
@@ -218,6 +326,8 @@ describe("google-meet plugin", () => {
"setup_status",
"resolve_space",
"preflight",
"artifacts",
"attendance",
"recover_current_tab",
"leave",
"speak",
@@ -310,6 +420,82 @@ describe("google-meet plugin", () => {
);
});
it("lists Meet artifact metadata for conference records", async () => {
const fetchMock = stubMeetArtifactsApi();
await expect(
fetchGoogleMeetArtifacts({
accessToken: "token",
meeting: "abc-defg-hij",
pageSize: 2,
}),
).resolves.toMatchObject({
input: "abc-defg-hij",
space: { name: "spaces/abc-defg-hij" },
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
artifacts: [
{
conferenceRecord: { name: "conferenceRecords/rec-1" },
participants: [{ name: "conferenceRecords/rec-1/participants/p1" }],
recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }],
transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }],
smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }],
},
],
});
const listCall = fetchMock.mock.calls.find(([input]) => {
const url = requestUrl(input);
return url.pathname === "/v2/conferenceRecords";
});
if (!listCall) {
throw new Error("Expected conferenceRecords.list fetch call");
}
const listUrl = requestUrl(listCall[0]);
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
expect(listUrl.searchParams.get("pageSize")).toBe("2");
expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2",
auditContext: "google-meet.conferenceRecords.smartNotes.list",
}),
);
});
it("lists Meet attendance rows with participant sessions", async () => {
const fetchMock = stubMeetArtifactsApi();
await expect(
fetchGoogleMeetAttendance({
accessToken: "token",
conferenceRecord: "rec-1",
pageSize: 3,
}),
).resolves.toMatchObject({
input: "rec-1",
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
attendance: [
{
conferenceRecord: "conferenceRecords/rec-1",
participant: "conferenceRecords/rec-1/participants/p1",
displayName: "Alice",
user: "users/alice",
sessions: [
{
name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
},
],
},
],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://meet.googleapis.com/v2/conferenceRecords/rec-1",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer token" }),
}),
);
});
it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => {
expect(
buildGoogleMeetPreflightReport({
@@ -454,6 +640,27 @@ describe("google-meet plugin", () => {
expect(result.details.ok).toBe(true);
});
it("reports attendance through the tool", async () => {
stubMeetArtifactsApi();
const { tools } = setup();
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { attendance?: Array<{ displayName?: string }> } }>;
};
const result = await tool.execute("id", {
action: "attendance",
accessToken: "token",
expiresAt: Date.now() + 120_000,
conferenceRecord: "rec-1",
pageSize: 3,
});
expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]);
});
it("fails setup status when the configured Chrome node is not connected", async () => {
const { tools } = setup(
{
@@ -630,6 +837,81 @@ describe("google-meet plugin", () => {
}
});
it("CLI artifacts prints JSON output", async () => {
stubMeetArtifactsApi();
const program = new Command();
const stdout = captureStdout();
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({}),
ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime,
});
try {
await program.parseAsync(
[
"googlemeet",
"artifacts",
"--access-token",
"token",
"--expires-at",
String(Date.now() + 120_000),
"--conference-record",
"rec-1",
"--json",
],
{ from: "user" },
);
expect(JSON.parse(stdout.output())).toMatchObject({
conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
artifacts: [
{
recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }],
transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }],
smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }],
},
],
tokenSource: "cached-access-token",
});
} finally {
stdout.restore();
}
});
it("CLI attendance prints participant sessions by default", async () => {
stubMeetArtifactsApi();
const program = new Command();
const stdout = captureStdout();
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({}),
ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime,
});
try {
await program.parseAsync(
[
"googlemeet",
"attendance",
"--access-token",
"token",
"--expires-at",
String(Date.now() + 120_000),
"--conference-record",
"rec-1",
],
{ from: "user" },
);
expect(stdout.output()).toContain("attendance rows: 1");
expect(stdout.output()).toContain("participant: Alice");
expect(stdout.output()).toContain(
"conferenceRecords/rec-1/participants/p1/participantSessions/s1",
);
} finally {
stdout.restore();
}
});
it("CLI doctor prints human-readable session health", async () => {
const program = new Command();
const stdout = captureStdout();

View File

@@ -15,7 +15,12 @@ import {
createMeetFromParams,
shouldJoinCreatedMeet,
} from "./src/create.js";
import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet.js";
import {
buildGoogleMeetPreflightReport,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { resolveGoogleMeetAccessToken } from "./src/oauth.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
@@ -145,6 +150,8 @@ const GoogleMeetToolSchema = Type.Object({
"setup_status",
"resolve_space",
"preflight",
"artifacts",
"attendance",
"recover_current_tab",
"leave",
"speak",
@@ -175,6 +182,10 @@ const GoogleMeetToolSchema = Type.Object({
sessionId: Type.Optional(Type.String({ description: "Meet session ID" })),
message: Type.Optional(Type.String({ description: "Realtime instructions to speak now" })),
meeting: Type.Optional(Type.String({ description: "Meet URL, meeting code, or spaces/{id}" })),
conferenceRecord: Type.Optional(
Type.String({ description: "Meet conferenceRecords/{id} resource name or id" }),
),
pageSize: Type.Optional(Type.Number({ description: "Meet API page size for list actions" })),
accessToken: Type.Optional(Type.String({ description: "Access token override" })),
refreshToken: Type.Optional(Type.String({ description: "Refresh token override" })),
clientId: Type.Optional(Type.String({ description: "OAuth client id override" })),
@@ -211,15 +222,33 @@ function resolveMeetingInput(config: GoogleMeetConfig, value: unknown): string {
return meeting;
}
async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
const meeting = resolveMeetingInput(config, raw.meeting);
const token = await resolveGoogleMeetAccessToken({
function resolveOptionalPositiveInteger(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = typeof value === "number" ? value : Number(normalizeOptionalString(value));
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error("Expected pageSize to be a positive integer");
}
return parsed;
}
async function resolveGoogleMeetTokenFromParams(
config: GoogleMeetConfig,
raw: Record<string, unknown>,
) {
return resolveGoogleMeetAccessToken({
clientId: normalizeOptionalString(raw.clientId) ?? config.oauth.clientId,
clientSecret: normalizeOptionalString(raw.clientSecret) ?? config.oauth.clientSecret,
refreshToken: normalizeOptionalString(raw.refreshToken) ?? config.oauth.refreshToken,
accessToken: normalizeOptionalString(raw.accessToken) ?? config.oauth.accessToken,
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : config.oauth.expiresAt,
});
}
async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record<string, unknown>) {
const meeting = resolveMeetingInput(config, raw.meeting);
const token = await resolveGoogleMeetTokenFromParams(config, raw);
const space = await fetchGoogleMeetSpace({
accessToken: token.accessToken,
meeting,
@@ -227,6 +256,24 @@ async function resolveSpaceFromParams(config: GoogleMeetConfig, raw: Record<stri
return { meeting, token, space };
}
async function resolveArtifactQueryFromParams(
config: GoogleMeetConfig,
raw: Record<string, unknown>,
) {
const meeting = normalizeOptionalString(raw.meeting) ?? config.defaults.meeting;
const conferenceRecord = normalizeOptionalString(raw.conferenceRecord);
if (!meeting && !conferenceRecord) {
throw new Error("Meeting input or conferenceRecord required");
}
const token = await resolveGoogleMeetTokenFromParams(config, raw);
return {
token,
meeting,
conferenceRecord,
pageSize: resolveOptionalPositiveInteger(raw.pageSize),
};
}
export default definePluginEntry({
id: "google-meet",
name: "Google Meet",
@@ -337,6 +384,48 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.artifacts",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const resolved = await resolveArtifactQueryFromParams(config, raw);
respond(
true,
await fetchGoogleMeetArtifacts({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
}),
);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.attendance",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const resolved = await resolveArtifactQueryFromParams(config, raw);
respond(
true,
await fetchGoogleMeetAttendance({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
}),
);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.leave",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -469,6 +558,28 @@ export default definePluginEntry({
}),
);
}
case "artifacts": {
const resolved = await resolveArtifactQueryFromParams(config, raw);
return json(
await fetchGoogleMeetArtifacts({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
}),
);
}
case "attendance": {
const resolved = await resolveArtifactQueryFromParams(config, raw);
return json(
await fetchGoogleMeetAttendance({
accessToken: resolved.token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
}),
);
}
case "leave": {
const rt = await ensureRuntime();
const sessionId = normalizeOptionalString(raw.sessionId);

View File

@@ -5,7 +5,11 @@ import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./co
import {
buildGoogleMeetPreflightReport,
createGoogleMeetSpace,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchGoogleMeetSpace,
type GoogleMeetArtifactsResult,
type GoogleMeetAttendanceResult,
} from "./meet.js";
import {
buildGoogleMeetAuthUrl,
@@ -44,6 +48,11 @@ type ResolveSpaceOptions = {
json?: boolean;
};
type MeetArtifactOptions = ResolveSpaceOptions & {
conferenceRecord?: string;
pageSize?: string;
};
type SetupOptions = {
json?: boolean;
};
@@ -251,6 +260,38 @@ function resolveCreateTokenOptions(
};
}
function resolveArtifactTokenOptions(
config: GoogleMeetConfig,
options: MeetArtifactOptions,
): {
meeting?: string;
conferenceRecord?: string;
clientId?: string;
clientSecret?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: number;
pageSize?: number;
} {
const meeting = options.meeting?.trim() || config.defaults.meeting;
const conferenceRecord = options.conferenceRecord?.trim();
if (!meeting && !conferenceRecord) {
throw new Error(
"Meeting input or conference record is required. Pass --meeting, --conference-record, or configure defaults.meeting.",
);
}
return {
meeting,
conferenceRecord,
clientId: options.clientId?.trim() || config.oauth.clientId,
clientSecret: options.clientSecret?.trim() || config.oauth.clientSecret,
refreshToken: options.refreshToken?.trim() || config.oauth.refreshToken,
accessToken: options.accessToken?.trim() || config.oauth.accessToken,
expiresAt: parseOptionalNumber(options.expiresAt) ?? config.oauth.expiresAt,
pageSize: parseOptionalNumber(options.pageSize),
};
}
function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boolean {
return Boolean(
options.accessToken?.trim() ||
@@ -260,6 +301,67 @@ function hasCreateOAuth(config: GoogleMeetConfig, options: CreateOptions): boole
);
}
function writeArtifactsSummary(result: GoogleMeetArtifactsResult): void {
if (result.input) {
writeStdoutLine("input: %s", result.input);
}
if (result.space) {
writeStdoutLine("space: %s", result.space.name);
}
writeStdoutLine("conference records: %d", result.conferenceRecords.length);
for (const entry of result.artifacts) {
writeStdoutLine("");
writeStdoutLine("record: %s", entry.conferenceRecord.name);
writeStdoutLine("started: %s", formatOptional(entry.conferenceRecord.startTime));
writeStdoutLine("ended: %s", formatOptional(entry.conferenceRecord.endTime));
writeStdoutLine("participants: %d", entry.participants.length);
writeStdoutLine("recordings: %d", entry.recordings.length);
writeStdoutLine("transcripts: %d", entry.transcripts.length);
writeStdoutLine("smart notes: %d", entry.smartNotes.length);
if (entry.smartNotesError) {
writeStdoutLine("smart notes warning: %s", entry.smartNotesError);
}
for (const recording of entry.recordings) {
writeStdoutLine("- recording: %s", recording.name);
}
for (const transcript of entry.transcripts) {
writeStdoutLine("- transcript: %s", transcript.name);
}
for (const smartNote of entry.smartNotes) {
writeStdoutLine("- smart note: %s", smartNote.name);
}
}
}
function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void {
if (result.input) {
writeStdoutLine("input: %s", result.input);
}
if (result.space) {
writeStdoutLine("space: %s", result.space.name);
}
writeStdoutLine("conference records: %d", result.conferenceRecords.length);
writeStdoutLine("attendance rows: %d", result.attendance.length);
for (const row of result.attendance) {
const identity = row.displayName || row.user || row.participant;
writeStdoutLine("");
writeStdoutLine("participant: %s", identity);
writeStdoutLine("record: %s", row.conferenceRecord);
writeStdoutLine("resource: %s", row.participant);
writeStdoutLine("first joined: %s", formatOptional(row.earliestStartTime));
writeStdoutLine("last left: %s", formatOptional(row.latestEndTime));
writeStdoutLine("sessions: %d", row.sessions.length);
for (const session of row.sessions) {
writeStdoutLine(
"- %s: %s -> %s",
session.name,
formatOptional(session.startTime),
formatOptional(session.endTime),
);
}
}
}
export function registerGoogleMeetCli(params: {
program: Command;
config: GoogleMeetConfig;
@@ -570,6 +672,76 @@ export function registerGoogleMeetCli(params: {
}
});
root
.command("artifacts")
.description("List Meet conference records and available participant/artifact metadata")
.option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--conference-record <name>", "Conference record name or id")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "Max resources per Meet API page")
.option("--json", "Print JSON output", false)
.action(async (options: MeetArtifactOptions) => {
const resolved = resolveArtifactTokenOptions(params.config, options);
const token = await resolveGoogleMeetAccessToken(resolved);
const result = await fetchGoogleMeetArtifacts({
accessToken: token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
});
if (options.json) {
writeStdoutJson({
...result,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
});
return;
}
writeArtifactsSummary(result);
writeStdoutLine(
"token source: %s",
token.refreshed ? "refresh-token" : "cached-access-token",
);
});
root
.command("attendance")
.description("List Meet participants and participant sessions")
.option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--conference-record <name>", "Conference record name or id")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--page-size <n>", "Max resources per Meet API page")
.option("--json", "Print JSON output", false)
.action(async (options: MeetArtifactOptions) => {
const resolved = resolveArtifactTokenOptions(params.config, options);
const token = await resolveGoogleMeetAccessToken(resolved);
const result = await fetchGoogleMeetAttendance({
accessToken: token.accessToken,
meeting: resolved.meeting,
conferenceRecord: resolved.conferenceRecord,
pageSize: resolved.pageSize,
});
if (options.json) {
writeStdoutJson({
...result,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
});
return;
}
writeAttendanceSummary(result);
writeStdoutLine(
"token source: %s",
token.refreshed ? "refresh-token" : "cached-access-token",
);
});
root
.command("status")
.argument("[session-id]", "Meet session ID")

View File

@@ -1,6 +1,7 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
const GOOGLE_MEET_API_BASE_URL = "https://meet.googleapis.com/v2";
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";
@@ -28,6 +29,95 @@ export type GoogleMeetCreateSpaceResult = {
meetingUri: string;
};
export type GoogleMeetConferenceRecord = {
name: string;
space?: string;
startTime?: string;
endTime?: string;
expireTime?: string;
};
export type GoogleMeetParticipant = {
name: string;
earliestStartTime?: string;
latestEndTime?: string;
signedinUser?: {
user?: string;
displayName?: string;
};
anonymousUser?: {
displayName?: string;
};
phoneUser?: {
displayName?: string;
};
};
export type GoogleMeetParticipantSession = {
name: string;
startTime?: string;
endTime?: string;
};
export type GoogleMeetRecording = {
name: string;
startTime?: string;
endTime?: string;
driveDestination?: Record<string, unknown>;
};
export type GoogleMeetTranscript = {
name: string;
startTime?: string;
endTime?: string;
docsDestination?: Record<string, unknown>;
};
export type GoogleMeetSmartNote = {
name: string;
startTime?: string;
endTime?: string;
docsDestination?: Record<string, unknown>;
};
export type GoogleMeetArtifactsEntry = {
conferenceRecord: GoogleMeetConferenceRecord;
participants: GoogleMeetParticipant[];
recordings: GoogleMeetRecording[];
transcripts: GoogleMeetTranscript[];
smartNotes: GoogleMeetSmartNote[];
smartNotesError?: string;
};
export type GoogleMeetArtifactsResult = {
input?: string;
space?: GoogleMeetSpace;
conferenceRecords: GoogleMeetConferenceRecord[];
artifacts: GoogleMeetArtifactsEntry[];
};
export type GoogleMeetAttendanceRow = {
conferenceRecord: string;
participant: string;
displayName?: string;
user?: string;
earliestStartTime?: string;
latestEndTime?: string;
sessions: GoogleMeetParticipantSession[];
};
export type GoogleMeetAttendanceResult = {
input?: string;
space?: GoogleMeetSpace;
conferenceRecords: GoogleMeetConferenceRecord[];
attendance: GoogleMeetAttendanceRow[];
};
type GoogleMeetSmartNotesListResult = {
smartNotes: GoogleMeetSmartNote[];
smartNotesError?: string;
};
export function normalizeGoogleMeetSpaceName(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
@@ -61,6 +151,121 @@ function encodeSpaceNameForPath(name: string): string {
return name.split("/").map(encodeURIComponent).join("/");
}
function encodeResourceNameForPath(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Google Meet resource name is required");
}
return trimmed.split("/").map(encodeURIComponent).join("/");
}
function normalizeConferenceRecordName(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Conference record is required");
}
return trimmed.startsWith("conferenceRecords/") ? trimmed : `conferenceRecords/${trimmed}`;
}
function appendQuery(
url: string,
query?: Record<string, string | number | boolean | undefined>,
): string {
if (!query) {
return url;
}
const parsed = new URL(url);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
parsed.searchParams.set(key, String(value));
}
}
return parsed.toString();
}
function assertResourceArray<T extends { name?: string }>(
value: unknown,
key: string,
context: string,
): T[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error(`Google Meet ${context} response had non-array ${key}`);
}
const resources = value as T[];
for (const resource of resources) {
if (!resource.name?.trim()) {
throw new Error(`Google Meet ${context} response included a resource without name`);
}
}
return resources;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function fetchGoogleMeetJson<T>(params: {
accessToken: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
auditContext: string;
errorPrefix: string;
}): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: appendQuery(`${GOOGLE_MEET_API_BASE_URL}/${params.path}`, params.query),
init: {
headers: {
Authorization: `Bearer ${params.accessToken}`,
Accept: "application/json",
},
},
policy: { allowedHostnames: [GOOGLE_MEET_API_HOST] },
auditContext: params.auditContext,
});
try {
if (!response.ok) {
const detail = await response.text();
throw new Error(`${params.errorPrefix} failed (${response.status}): ${detail}`);
}
return (await response.json()) as T;
} finally {
await release();
}
}
async function listGoogleMeetCollection<T extends { name?: string }>(params: {
accessToken: string;
path: string;
collectionKey: string;
query?: Record<string, string | number | boolean | undefined>;
auditContext: string;
errorPrefix: string;
}): Promise<T[]> {
const items: T[] = [];
let pageToken: string | undefined;
do {
const payload = await fetchGoogleMeetJson<Record<string, unknown>>({
accessToken: params.accessToken,
path: params.path,
query: { ...params.query, pageToken },
auditContext: params.auditContext,
errorPrefix: params.errorPrefix,
});
items.push(
...assertResourceArray<T>(
payload[params.collectionKey],
params.collectionKey,
params.errorPrefix,
),
);
pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined;
} while (pageToken);
return items;
}
export async function fetchGoogleMeetSpace(params: {
accessToken: string;
meeting: string;
@@ -128,6 +333,269 @@ export async function createGoogleMeetSpace(params: {
}
}
export async function fetchGoogleMeetConferenceRecord(params: {
accessToken: string;
conferenceRecord: string;
}): Promise<GoogleMeetConferenceRecord> {
const name = normalizeConferenceRecordName(params.conferenceRecord);
const payload = await fetchGoogleMeetJson<GoogleMeetConferenceRecord>({
accessToken: params.accessToken,
path: encodeResourceNameForPath(name),
auditContext: "google-meet.conferenceRecords.get",
errorPrefix: "Google Meet conferenceRecords.get",
});
if (!payload.name?.trim()) {
throw new Error("Google Meet conferenceRecords.get response was missing name");
}
return payload;
}
export async function listGoogleMeetConferenceRecords(params: {
accessToken: string;
meeting?: string;
pageSize?: number;
}): Promise<GoogleMeetConferenceRecord[]> {
const filter = params.meeting
? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"`
: undefined;
return listGoogleMeetCollection<GoogleMeetConferenceRecord>({
accessToken: params.accessToken,
path: "conferenceRecords",
collectionKey: "conferenceRecords",
query: {
pageSize: params.pageSize,
filter,
},
auditContext: "google-meet.conferenceRecords.list",
errorPrefix: "Google Meet conferenceRecords.list",
});
}
export async function listGoogleMeetParticipants(params: {
accessToken: string;
conferenceRecord: string;
pageSize?: number;
}): Promise<GoogleMeetParticipant[]> {
const parent = normalizeConferenceRecordName(params.conferenceRecord);
return listGoogleMeetCollection<GoogleMeetParticipant>({
accessToken: params.accessToken,
path: `${encodeResourceNameForPath(parent)}/participants`,
collectionKey: "participants",
query: { pageSize: params.pageSize },
auditContext: "google-meet.conferenceRecords.participants.list",
errorPrefix: "Google Meet conferenceRecords.participants.list",
});
}
export async function listGoogleMeetParticipantSessions(params: {
accessToken: string;
participant: string;
pageSize?: number;
}): Promise<GoogleMeetParticipantSession[]> {
return listGoogleMeetCollection<GoogleMeetParticipantSession>({
accessToken: params.accessToken,
path: `${encodeResourceNameForPath(params.participant)}/participantSessions`,
collectionKey: "participantSessions",
query: { pageSize: params.pageSize },
auditContext: "google-meet.conferenceRecords.participants.participantSessions.list",
errorPrefix: "Google Meet conferenceRecords.participants.participantSessions.list",
});
}
export async function listGoogleMeetRecordings(params: {
accessToken: string;
conferenceRecord: string;
pageSize?: number;
}): Promise<GoogleMeetRecording[]> {
const parent = normalizeConferenceRecordName(params.conferenceRecord);
return listGoogleMeetCollection<GoogleMeetRecording>({
accessToken: params.accessToken,
path: `${encodeResourceNameForPath(parent)}/recordings`,
collectionKey: "recordings",
query: { pageSize: params.pageSize },
auditContext: "google-meet.conferenceRecords.recordings.list",
errorPrefix: "Google Meet conferenceRecords.recordings.list",
});
}
export async function listGoogleMeetTranscripts(params: {
accessToken: string;
conferenceRecord: string;
pageSize?: number;
}): Promise<GoogleMeetTranscript[]> {
const parent = normalizeConferenceRecordName(params.conferenceRecord);
return listGoogleMeetCollection<GoogleMeetTranscript>({
accessToken: params.accessToken,
path: `${encodeResourceNameForPath(parent)}/transcripts`,
collectionKey: "transcripts",
query: { pageSize: params.pageSize },
auditContext: "google-meet.conferenceRecords.transcripts.list",
errorPrefix: "Google Meet conferenceRecords.transcripts.list",
});
}
export async function listGoogleMeetSmartNotes(params: {
accessToken: string;
conferenceRecord: string;
pageSize?: number;
}): Promise<GoogleMeetSmartNote[]> {
const parent = normalizeConferenceRecordName(params.conferenceRecord);
return listGoogleMeetCollection<GoogleMeetSmartNote>({
accessToken: params.accessToken,
path: `${encodeResourceNameForPath(parent)}/smartNotes`,
collectionKey: "smartNotes",
query: { pageSize: params.pageSize },
auditContext: "google-meet.conferenceRecords.smartNotes.list",
errorPrefix: "Google Meet conferenceRecords.smartNotes.list",
});
}
function getParticipantDisplayName(participant: GoogleMeetParticipant): string | undefined {
return (
participant.signedinUser?.displayName ??
participant.anonymousUser?.displayName ??
participant.phoneUser?.displayName
);
}
function getParticipantUser(participant: GoogleMeetParticipant): string | undefined {
return participant.signedinUser?.user;
}
async function resolveConferenceRecordQuery(params: {
accessToken: string;
meeting?: string;
conferenceRecord?: string;
pageSize?: number;
}): Promise<{
input?: string;
space?: GoogleMeetSpace;
conferenceRecords: GoogleMeetConferenceRecord[];
}> {
if (params.conferenceRecord?.trim()) {
const conferenceRecord = await fetchGoogleMeetConferenceRecord({
accessToken: params.accessToken,
conferenceRecord: params.conferenceRecord,
});
return {
input: params.conferenceRecord.trim(),
conferenceRecords: [conferenceRecord],
};
}
if (!params.meeting?.trim()) {
throw new Error("Meeting input or conference record is required");
}
const space = await fetchGoogleMeetSpace({
accessToken: params.accessToken,
meeting: params.meeting,
});
const conferenceRecords = await listGoogleMeetConferenceRecords({
accessToken: params.accessToken,
meeting: space.name,
pageSize: params.pageSize,
});
return {
input: params.meeting,
space,
conferenceRecords,
};
}
export async function fetchGoogleMeetArtifacts(params: {
accessToken: string;
meeting?: string;
conferenceRecord?: string;
pageSize?: number;
}): Promise<GoogleMeetArtifactsResult> {
const resolved = await resolveConferenceRecordQuery(params);
const artifacts = await Promise.all(
resolved.conferenceRecords.map(async (conferenceRecord) => {
const [participants, recordings, transcripts, smartNotesResult] = await Promise.all([
listGoogleMeetParticipants({
accessToken: params.accessToken,
conferenceRecord: conferenceRecord.name,
pageSize: params.pageSize,
}),
listGoogleMeetRecordings({
accessToken: params.accessToken,
conferenceRecord: conferenceRecord.name,
pageSize: params.pageSize,
}),
listGoogleMeetTranscripts({
accessToken: params.accessToken,
conferenceRecord: conferenceRecord.name,
pageSize: params.pageSize,
}),
listGoogleMeetSmartNotes({
accessToken: params.accessToken,
conferenceRecord: conferenceRecord.name,
pageSize: params.pageSize,
})
.then<GoogleMeetSmartNotesListResult>((smartNotes) => ({ smartNotes }))
.catch((error: unknown) => ({
smartNotes: [],
smartNotesError: getErrorMessage(error),
})),
]);
return {
conferenceRecord,
participants,
recordings,
transcripts,
smartNotes: smartNotesResult.smartNotes,
...(smartNotesResult.smartNotesError
? { smartNotesError: smartNotesResult.smartNotesError }
: {}),
};
}),
);
return {
input: resolved.input,
space: resolved.space,
conferenceRecords: resolved.conferenceRecords,
artifacts,
};
}
export async function fetchGoogleMeetAttendance(params: {
accessToken: string;
meeting?: string;
conferenceRecord?: string;
pageSize?: number;
}): Promise<GoogleMeetAttendanceResult> {
const resolved = await resolveConferenceRecordQuery(params);
const nestedRows = await Promise.all(
resolved.conferenceRecords.map(async (conferenceRecord) => {
const participants = await listGoogleMeetParticipants({
accessToken: params.accessToken,
conferenceRecord: conferenceRecord.name,
pageSize: params.pageSize,
});
return Promise.all(
participants.map(async (participant) => ({
conferenceRecord: conferenceRecord.name,
participant: participant.name,
displayName: getParticipantDisplayName(participant),
user: getParticipantUser(participant),
earliestStartTime: participant.earliestStartTime,
latestEndTime: participant.latestEndTime,
sessions: await listGoogleMeetParticipantSessions({
accessToken: params.accessToken,
participant: participant.name,
pageSize: params.pageSize,
}),
})),
);
}),
);
return {
input: resolved.input,
space: resolved.space,
conferenceRecords: resolved.conferenceRecords,
attendance: nestedRows.flat(),
};
}
export function buildGoogleMeetPreflightReport(params: {
input: string;
space: GoogleMeetSpace;