feat(google-meet): add latest conference command

This commit is contained in:
Peter Steinberger
2026-04-25 08:04:29 +01:00
parent dfa52aaab0
commit 459d277076
6 changed files with 198 additions and 6 deletions

View File

@@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai
- 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 and transcript entries, smart notes, and participant sessions. Thanks @steipete.
- Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet latest` plus matching tool/gateway actions to find the newest conference record for a meeting. Thanks @steipete.
- Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. 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.

View File

@@ -638,6 +638,7 @@ 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 latest --meeting https://meet.google.com/abc-defg-hij
openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json
openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json
```

View File

@@ -14,6 +14,7 @@ import {
createGoogleMeetSpace,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchLatestGoogleMeetConferenceRecord,
fetchGoogleMeetSpace,
normalizeGoogleMeetSpaceName,
} from "./src/meet.js";
@@ -343,6 +344,7 @@ describe("google-meet plugin", () => {
"setup_status",
"resolve_space",
"preflight",
"latest",
"artifacts",
"attendance",
"recover_current_tab",
@@ -496,6 +498,32 @@ describe("google-meet plugin", () => {
);
});
it("fetches only the latest Meet conference record for a meeting", async () => {
const fetchMock = stubMeetArtifactsApi();
await expect(
fetchLatestGoogleMeetConferenceRecord({
accessToken: "token",
meeting: "abc-defg-hij",
}),
).resolves.toMatchObject({
input: "abc-defg-hij",
space: { name: "spaces/abc-defg-hij" },
conferenceRecord: { name: "conferenceRecords/rec-1" },
});
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("pageSize")).toBe("1");
expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
});
it("lists Meet attendance rows with participant sessions", async () => {
const fetchMock = stubMeetArtifactsApi();
@@ -695,6 +723,26 @@ describe("google-meet plugin", () => {
expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]);
});
it("reports the latest conference record through the tool", async () => {
stubMeetArtifactsApi();
const { tools } = setup();
const tool = tools[0] as {
execute: (
id: string,
params: unknown,
) => Promise<{ details: { conferenceRecord?: { name?: string } } }>;
};
const result = await tool.execute("id", {
action: "latest",
accessToken: "token",
expiresAt: Date.now() + 120_000,
meeting: "abc-defg-hij",
});
expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" });
});
it("fails setup status when the configured Chrome node is not connected", async () => {
const { tools } = setup(
{
@@ -918,6 +966,37 @@ describe("google-meet plugin", () => {
}
});
it("CLI latest prints the latest conference record", async () => {
stubMeetArtifactsApi();
const program = new Command();
const stdout = captureStdout();
registerGoogleMeetCli({
program,
config: resolveGoogleMeetConfig({}),
ensureRuntime: async () => ({}) as unknown as GoogleMeetRuntime,
});
try {
await program.parseAsync(
[
"googlemeet",
"latest",
"--access-token",
"token",
"--expires-at",
String(Date.now() + 120_000),
"--meeting",
"abc-defg-hij",
],
{ from: "user" },
);
expect(stdout.output()).toContain("space: spaces/abc-defg-hij");
expect(stdout.output()).toContain("conference record: conferenceRecords/rec-1");
} finally {
stdout.restore();
}
});
it("CLI artifacts writes markdown output", async () => {
stubMeetArtifactsApi();
const program = new Command();

View File

@@ -19,6 +19,7 @@ import {
buildGoogleMeetPreflightReport,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchLatestGoogleMeetConferenceRecord,
fetchGoogleMeetSpace,
} from "./src/meet.js";
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
@@ -150,6 +151,7 @@ const GoogleMeetToolSchema = Type.Object({
"setup_status",
"resolve_space",
"preflight",
"latest",
"artifacts",
"attendance",
"recover_current_tab",
@@ -388,6 +390,26 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"googlemeet.latest",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asParamRecord(params);
const meeting = resolveMeetingInput(config, raw.meeting);
const token = await resolveGoogleMeetTokenFromParams(config, raw);
respond(
true,
await fetchLatestGoogleMeetConferenceRecord({
accessToken: token.accessToken,
meeting,
}),
);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"googlemeet.artifacts",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -563,6 +585,16 @@ export default definePluginEntry({
}),
);
}
case "latest": {
const meeting = resolveMeetingInput(config, raw.meeting);
const token = await resolveGoogleMeetTokenFromParams(config, raw);
return json(
await fetchLatestGoogleMeetConferenceRecord({
accessToken: token.accessToken,
meeting,
}),
);
}
case "artifacts": {
const resolved = await resolveArtifactQueryFromParams(config, raw);
return json(

View File

@@ -8,9 +8,11 @@ import {
createGoogleMeetSpace,
fetchGoogleMeetArtifacts,
fetchGoogleMeetAttendance,
fetchLatestGoogleMeetConferenceRecord,
fetchGoogleMeetSpace,
type GoogleMeetArtifactsResult,
type GoogleMeetAttendanceResult,
type GoogleMeetLatestConferenceRecordResult,
} from "./meet.js";
import {
buildGoogleMeetAuthUrl,
@@ -547,6 +549,18 @@ function writeAttendanceSummary(result: GoogleMeetAttendanceResult): void {
}
}
function writeLatestConferenceRecordSummary(result: GoogleMeetLatestConferenceRecordResult): void {
writeStdoutLine("input: %s", result.input);
writeStdoutLine("space: %s", result.space.name);
if (!result.conferenceRecord) {
writeStdoutLine("conference record: none");
return;
}
writeStdoutLine("conference record: %s", result.conferenceRecord.name);
writeStdoutLine("started: %s", formatOptional(result.conferenceRecord.startTime));
writeStdoutLine("ended: %s", formatOptional(result.conferenceRecord.endTime));
}
function pushMarkdownLine(lines: string[], text = ""): void {
lines.push(text);
}
@@ -974,6 +988,37 @@ export function registerGoogleMeetCli(params: {
}
});
root
.command("latest")
.description("Find the latest Meet conference record for a meeting")
.option("--meeting <value>", "Meet URL, meeting code, or spaces/{id}")
.option("--access-token <token>", "Access token override")
.option("--refresh-token <token>", "Refresh token override")
.option("--client-id <id>", "OAuth client id override")
.option("--client-secret <secret>", "OAuth client secret override")
.option("--expires-at <ms>", "Cached access token expiry as unix epoch milliseconds")
.option("--json", "Print JSON output", false)
.action(async (options: ResolveSpaceOptions) => {
const resolved = resolveTokenOptions(params.config, options);
const token = await resolveGoogleMeetAccessToken(resolved);
const result = await fetchLatestGoogleMeetConferenceRecord({
accessToken: token.accessToken,
meeting: resolved.meeting,
});
if (options.json) {
writeStdoutJson({
...result,
tokenSource: token.refreshed ? "refresh-token" : "cached-access-token",
});
return;
}
writeLatestConferenceRecordSummary(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")

View File

@@ -112,6 +112,12 @@ export type GoogleMeetArtifactsResult = {
artifacts: GoogleMeetArtifactsEntry[];
};
export type GoogleMeetLatestConferenceRecordResult = {
input: string;
space: GoogleMeetSpace;
conferenceRecord?: GoogleMeetConferenceRecord;
};
export type GoogleMeetAttendanceRow = {
conferenceRecord: string;
participant: string;
@@ -257,6 +263,7 @@ async function listGoogleMeetCollection<T extends { name?: string }>(params: {
path: string;
collectionKey: string;
query?: Record<string, string | number | boolean | undefined>;
maxItems?: number;
auditContext: string;
errorPrefix: string;
}): Promise<T[]> {
@@ -270,13 +277,17 @@ async function listGoogleMeetCollection<T extends { name?: string }>(params: {
auditContext: params.auditContext,
errorPrefix: params.errorPrefix,
});
items.push(
...assertResourceArray<T>(
payload[params.collectionKey],
params.collectionKey,
params.errorPrefix,
),
const pageItems = assertResourceArray<T>(
payload[params.collectionKey],
params.collectionKey,
params.errorPrefix,
);
const remaining =
typeof params.maxItems === "number" ? Math.max(params.maxItems - items.length, 0) : undefined;
items.push(...(remaining === undefined ? pageItems : pageItems.slice(0, remaining)));
if (typeof params.maxItems === "number" && items.length >= params.maxItems) {
break;
}
pageToken = typeof payload.nextPageToken === "string" ? payload.nextPageToken : undefined;
} while (pageToken);
return items;
@@ -370,6 +381,7 @@ export async function listGoogleMeetConferenceRecords(params: {
accessToken: string;
meeting?: string;
pageSize?: number;
maxItems?: number;
}): Promise<GoogleMeetConferenceRecord[]> {
const filter = params.meeting
? `space.name = "${normalizeGoogleMeetSpaceName(params.meeting)}"`
@@ -382,11 +394,33 @@ export async function listGoogleMeetConferenceRecords(params: {
pageSize: params.pageSize,
filter,
},
maxItems: params.maxItems,
auditContext: "google-meet.conferenceRecords.list",
errorPrefix: "Google Meet conferenceRecords.list",
});
}
export async function fetchLatestGoogleMeetConferenceRecord(params: {
accessToken: string;
meeting: string;
}): Promise<GoogleMeetLatestConferenceRecordResult> {
const space = await fetchGoogleMeetSpace({
accessToken: params.accessToken,
meeting: params.meeting,
});
const [conferenceRecord] = await listGoogleMeetConferenceRecords({
accessToken: params.accessToken,
meeting: space.name,
pageSize: 1,
maxItems: 1,
});
return {
input: params.meeting,
space,
...(conferenceRecord ? { conferenceRecord } : {}),
};
}
export async function listGoogleMeetParticipants(params: {
accessToken: string;
conferenceRecord: string;