refactor(meetings): share plugin entry registration (#113007)

This commit is contained in:
Peter Steinberger
2026-07-23 07:18:38 -04:00
committed by GitHub
parent 852548ce05
commit d124fb235f
4 changed files with 542 additions and 784 deletions

View File

@@ -1,32 +1,19 @@
import {
readNonNegativeIntegerParam,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
callGatewayFromCli,
ErrorCodes,
errorShape,
type GatewayRequestHandlerOptions,
} from "openclaw/plugin-sdk/gateway-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { Type } from "typebox";
import {
resolveTeamsMeetingsConfig,
resolveTeamsMeetingsGatewayOperationTimeoutMs,
type TeamsMeetingsConfig,
type TeamsMeetingsMode,
type TeamsMeetingsTransport,
} from "./src/config.js";
import { handleTeamsMeetingsNodeHostCommand } from "./src/node-host.js";
import { createTeamsMeetingsNodeInvokePolicy } from "./src/node-invoke-policy.js";
import { TeamsMeetingsRuntime } from "./src/runtime.js";
import { TEAMS_MEETINGS_NODE_COMMAND } from "./src/transports/teams-meetings-platform-constants.js";
import { normalizeTeamsMeetingUrl } from "./src/transports/teams-meetings-urls.js";
import type { TeamsMeetingsJoinRequest } from "./src/transports/types.js";
const loadTeamsMeetingsCli = createLazyRuntimeModule(() => import("./src/cli.js"));
@@ -71,384 +58,72 @@ const TeamsMeetingsToolSchema = Type.Object({
message: Type.Optional(Type.String({ description: "Instructions to speak" })),
});
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
class TeamsMeetingsInvalidRequestError extends Error {}
function invalidRequest(message: string): TeamsMeetingsInvalidRequestError {
return new TeamsMeetingsInvalidRequestError(message);
}
function normalizeTransport(value: unknown): TeamsMeetingsTransport | undefined {
if (value === undefined) {
return undefined;
}
if (value === "chrome" || value === "chrome-node") {
return value;
}
throw invalidRequest("transport must be chrome or chrome-node");
}
function normalizeMode(value: unknown): TeamsMeetingsMode | undefined {
if (value === undefined) {
return undefined;
}
if (value === "agent" || value === "bidi" || value === "transcribe") {
return value;
}
throw invalidRequest("mode must be agent, bidi, or transcribe");
}
function requireString(value: unknown, name: string): string {
const normalized = normalizeOptionalString(value);
if (!normalized) {
throw invalidRequest(`${name} required`);
}
return normalized;
}
function readSinceIndex(raw: Record<string, unknown>): number | undefined {
try {
return readNonNegativeIntegerParam(raw, "sinceIndex");
} catch (error) {
throw invalidRequest(formatErrorMessage(error));
}
}
function keepTrustedToolAgentId(
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): Record<string, unknown> {
const { agentId: rawAgentId, ...rest } = raw;
if (client?.internal?.pluginRuntimeOwnerId !== "teams-meetings") {
return rest;
}
const agentId = normalizeOptionalString(rawAgentId);
return agentId ? { ...rest, agentId } : rest;
}
function trustedToolAgentId(
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): string | undefined {
return normalizeOptionalString(keepTrustedToolAgentId(raw, client).agentId);
}
function joinRequest(raw: Record<string, unknown>, options?: { allowTimeout?: boolean }) {
if (!options?.allowTimeout && raw.timeoutMs !== undefined) {
throw invalidRequest("timeoutMs is supported only by testSpeech or testListen");
}
let url: string;
let timeoutMs: number | undefined;
try {
url = normalizeTeamsMeetingUrl(requireString(raw.url, "url"));
timeoutMs = readPositiveIntegerParam(raw, "timeoutMs");
} catch (error) {
if (error instanceof TeamsMeetingsInvalidRequestError) {
throw error;
}
throw invalidRequest(formatErrorMessage(error));
}
return {
url,
transport: normalizeTransport(raw.transport),
mode: normalizeMode(raw.mode),
message: normalizeOptionalString(raw.message),
requesterSessionKey: normalizeOptionalString(raw.requesterSessionKey),
agentId: normalizeOptionalString(raw.agentId),
timeoutMs,
};
}
type ToolAction = "join" | "leave" | "status" | "transcript" | "speak";
function gatewayMethod(action: ToolAction): string {
return `teamsmeetings.${action}`;
}
function readErrorDetails(error: unknown): unknown {
return error && typeof error === "object" && "details" in error
? (error as { details?: unknown }).details
: undefined;
}
async function callGatewayFromTool(params: {
action: ToolAction;
config: TeamsMeetingsConfig;
raw: Record<string, unknown>;
runtime?: OpenClawPluginApi["runtime"];
}) {
try {
if (params.runtime) {
return await params.runtime.gateway.request(gatewayMethod(params.action), params.raw, {
timeoutMs: resolveTeamsMeetingsGatewayOperationTimeoutMs(params.config),
scopes: ["operator.admin"],
});
}
return await callGatewayFromCli(
gatewayMethod(params.action),
{
json: true,
timeout: String(resolveTeamsMeetingsGatewayOperationTimeoutMs(params.config)),
},
params.raw,
{ progress: false, scopes: ["operator.admin"] },
);
} catch (error) {
const details = readErrorDetails(error);
if (details && typeof details === "object") {
return details;
}
throw error;
}
}
export default definePluginEntry({
id: "teams-meetings",
name: "Microsoft Teams meetings",
description: "Join Microsoft Teams meetings as a Chrome browser guest",
configSchema: teamsMeetingsConfigSchema,
register(api: OpenClawPluginApi) {
const config = teamsMeetingsConfigSchema.parse(api.pluginConfig);
let runtime: TeamsMeetingsRuntime | undefined;
const ensureRuntime = async () => {
if (!config.enabled) {
throw new Error("Microsoft Teams meetings plugin disabled in plugin config");
export default definePluginEntry(
MeetingPlatformAdapter.createPluginEntry<
TeamsMeetingsConfig,
TeamsMeetingsJoinRequest,
TeamsMeetingsRuntime
>({
id: "teams-meetings",
name: "Microsoft Teams meetings",
description: "Join Microsoft Teams meetings as a Chrome browser guest",
configSchema: teamsMeetingsConfigSchema,
disabledMessage: "Microsoft Teams meetings plugin disabled in plugin config",
gatewayMethodPrefix: "teamsmeetings",
invalidRequest: (message) => new TeamsMeetingsInvalidRequestError(message),
isInvalidRequest: (error) => error instanceof TeamsMeetingsInvalidRequestError,
normalizeUrl: normalizeTeamsMeetingUrl,
resolveGatewayTimeoutMs: resolveTeamsMeetingsGatewayOperationTimeoutMs,
normalizeRequesterSessionKey: (value) =>
typeof value === "string" && value.trim() ? value.trim() : undefined,
normalizeToolAgentId: (agentId) => (agentId ? normalizeAgentId(agentId) : undefined),
resolveToolRuntime: async (api, agentId) => {
const trustedRouting = Boolean(agentId && agentId !== "main");
const useRuntime = trustedRouting ? await api.runtime.gateway.isAvailable() : false;
if (trustedRouting && !useRuntime) {
throw new Error(
"Per-agent Microsoft Teams meeting routing requires a Gateway-hosted agent run.",
);
}
runtime ??= new TeamsMeetingsRuntime({
return useRuntime ? api.runtime : undefined;
},
unknownActionMessage: "unknown teams_meetings action",
toolName: "teams_meetings",
toolLabel: "Microsoft Teams meetings",
toolDescription:
"Join and manage Microsoft Teams meeting browser guests. Guest admission, tenant sign-in, and media permissions may require manual action in the OpenClaw Chrome profile.",
toolParameters: TeamsMeetingsToolSchema,
createRuntime: ({ api, config }) =>
new TeamsMeetingsRuntime({
config,
fullConfig: api.config,
runtime: api.runtime,
logger: api.logger,
});
return runtime;
};
const sendError = (
respond: GatewayRequestHandlerOptions["respond"],
error: unknown,
code: Parameters<typeof errorShape>[0] = ErrorCodes.UNAVAILABLE,
) => {
const payload = { error: formatErrorMessage(error) };
respond(false, payload, errorShape(code, payload.error, { details: payload }));
};
const sendRequestError = (respond: GatewayRequestHandlerOptions["respond"], error: unknown) =>
sendError(
respond,
error,
error instanceof TeamsMeetingsInvalidRequestError
? ErrorCodes.INVALID_REQUEST
: ErrorCodes.UNAVAILABLE,
);
api.registerGatewayMethod(
"teamsmeetings.join",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolAgentId(asRecord(params), client);
respond(true, await (await ensureRuntime()).join(joinRequest(raw)));
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"teamsmeetings.leave",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const sessionId = requireString(raw.sessionId, "sessionId");
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.leave(sessionId),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"teamsmeetings.status",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId
? await rt.statusForAgent(agentId, normalizeOptionalString(raw.sessionId))
: await rt.status(normalizeOptionalString(raw.sessionId)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"teamsmeetings.transcript",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const sinceIndex = readSinceIndex(raw);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.transcript(sessionId, sinceIndex === undefined ? {} : { sinceIndex }),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"teamsmeetings.speak",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false, spoken: false }
: await rt.speak(sessionId, normalizeOptionalString(raw.message)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"teamsmeetings.setup",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
respond(
true,
await (
await ensureRuntime()
).setupStatus({
mode: normalizeMode(params?.mode),
transport: normalizeTransport(params?.transport),
}),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
for (const [method, run] of [
[
"teamsmeetings.testSpeech",
(rt: TeamsMeetingsRuntime, raw: Record<string, unknown>) =>
rt.testSpeech(joinRequest(raw, { allowTimeout: true })),
],
[
"teamsmeetings.testListen",
(rt: TeamsMeetingsRuntime, raw: Record<string, unknown>) =>
rt.testListen(joinRequest(raw, { allowTimeout: true })),
],
] as const) {
api.registerGatewayMethod(
method,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolAgentId(asRecord(params), client);
respond(true, await run(await ensureRuntime(), raw));
} catch (error) {
sendRequestError(respond, error);
}
},
);
}
api.registerTool(
(toolContext) => ({
name: "teams_meetings",
label: "Microsoft Teams meetings",
description:
"Join and manage Microsoft Teams meeting browser guests. Guest admission, tenant sign-in, and media permissions may require manual action in the OpenClaw Chrome profile.",
parameters: TeamsMeetingsToolSchema,
async execute(_toolCallId, params) {
const raw = asRecord(params);
const action = raw.action as ToolAction;
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const contextAgentId =
toolContext.agentId ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : undefined;
try {
if (!(["join", "leave", "status", "transcript", "speak"] as const).includes(action)) {
throw new Error("unknown teams_meetings action");
}
const trustedRouting = Boolean(agentId && agentId !== "main");
const useRuntime = trustedRouting ? await api.runtime.gateway.isAvailable() : false;
if (trustedRouting && !useRuntime) {
throw new Error(
"Per-agent Microsoft Teams meeting routing requires a Gateway-hosted agent run.",
);
}
return json(
await callGatewayFromTool({
action,
config,
raw: {
...raw,
...(requesterSessionKey ? { requesterSessionKey } : {}),
...(useRuntime ? { agentId } : {}),
},
runtime: useRuntime ? api.runtime : undefined,
}),
);
} catch (error) {
return json({ error: formatErrorMessage(error) });
}
},
}),
{ name: "teams_meetings" },
);
api.registerNodeHostCommand({
command: TEAMS_MEETINGS_NODE_COMMAND,
cap: "teams-meetings",
dangerous: true,
handle: handleTeamsMeetingsNodeHostCommand,
});
api.registerNodeInvokePolicy(createTeamsMeetingsNodeInvokePolicy(config));
api.registerCli(
async ({ program }) => {
const cli = await loadTeamsMeetingsCli();
cli.registerTeamsMeetingsCli({ program, config });
},
{
commands: ["teamsmeetings"],
descriptors: [
{
name: "teamsmeetings",
description: "Join and manage Microsoft Teams meeting guests",
hasSubcommands: true,
},
],
},
);
},
});
nodeCommand: TEAMS_MEETINGS_NODE_COMMAND,
cap: "teams-meetings",
nodeHandler: handleTeamsMeetingsNodeHostCommand,
createNodePolicy: createTeamsMeetingsNodeInvokePolicy,
registerNodeWhen: () => true,
registerCli: (api, config) => {
api.registerCli(
async ({ program }) => {
const cli = await loadTeamsMeetingsCli();
cli.registerTeamsMeetingsCli({ program, config });
},
{
commands: ["teamsmeetings"],
descriptors: [
{
name: "teamsmeetings",
description: "Join and manage Microsoft Teams meeting guests",
hasSubcommands: true,
},
],
},
);
},
}),
);

View File

@@ -1,34 +1,18 @@
import {
readNonNegativeIntegerParam,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
callGatewayFromCli,
ErrorCodes,
errorShape,
type GatewayRequestHandlerOptions,
} from "openclaw/plugin-sdk/gateway-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { Type } from "typebox";
import {
resolveZoomMeetingsConfig,
resolveZoomMeetingsGatewayOperationTimeoutMs,
type ZoomMeetingsConfig,
type ZoomMeetingsMode,
type ZoomMeetingsTransport,
} from "./src/config.js";
import {
ZoomMeetingsInvalidRequestError,
zoomMeetingsInvalidRequest as invalidRequest,
} from "./src/errors.js";
import { ZoomMeetingsInvalidRequestError, zoomMeetingsInvalidRequest } from "./src/errors.js";
import { handleZoomMeetingsNodeHostCommand } from "./src/node-host.js";
import { createZoomMeetingsNodeInvokePolicy } from "./src/node-invoke-policy.js";
import { ZoomMeetingsRuntime } from "./src/runtime.js";
import type { ZoomMeetingsJoinRequest } from "./src/transports/types.js";
import { ZOOM_MEETINGS_NODE_COMMAND } from "./src/transports/zoom-meetings-platform-constants.js";
import { normalizeZoomMeetingUrl } from "./src/transports/zoom-meetings-urls.js";
@@ -75,382 +59,66 @@ const ZoomMeetingsToolSchema = Type.Object({
message: Type.Optional(Type.String({ description: "Instructions to speak" })),
});
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizeTransport(value: unknown): ZoomMeetingsTransport | undefined {
if (value === undefined) {
return undefined;
}
if (value === "chrome" || value === "chrome-node") {
return value;
}
throw invalidRequest("transport must be chrome or chrome-node");
}
function normalizeMode(value: unknown): ZoomMeetingsMode | undefined {
if (value === undefined) {
return undefined;
}
if (value === "agent" || value === "bidi" || value === "transcribe") {
return value;
}
throw invalidRequest("mode must be agent, bidi, or transcribe");
}
function requireString(value: unknown, name: string): string {
const normalized = normalizeOptionalString(value);
if (!normalized) {
throw invalidRequest(`${name} required`);
}
return normalized;
}
function readSinceIndex(raw: Record<string, unknown>): number | undefined {
try {
return readNonNegativeIntegerParam(raw, "sinceIndex");
} catch (error) {
throw invalidRequest(formatErrorMessage(error));
}
}
function keepTrustedToolContext(
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): Record<string, unknown> {
const { agentId: rawAgentId, requesterSessionKey: rawRequesterSessionKey, ...rest } = raw;
if (client?.internal?.pluginRuntimeOwnerId !== "zoom-meetings") {
return rest;
}
const agentId = normalizeOptionalString(rawAgentId);
const requesterSessionKey = normalizeOptionalString(rawRequesterSessionKey);
return {
...rest,
...(agentId ? { agentId } : {}),
...(requesterSessionKey ? { requesterSessionKey } : {}),
};
}
function trustedToolAgentId(
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): string | undefined {
return normalizeOptionalString(keepTrustedToolContext(raw, client).agentId);
}
function joinRequest(raw: Record<string, unknown>, options?: { allowTimeout?: boolean }) {
if (!options?.allowTimeout && raw.timeoutMs !== undefined) {
throw invalidRequest("timeoutMs is supported only by testSpeech or testListen");
}
let url: string;
let timeoutMs: number | undefined;
try {
url = normalizeZoomMeetingUrl(requireString(raw.url, "url"));
timeoutMs = readPositiveIntegerParam(raw, "timeoutMs");
} catch (error) {
if (error instanceof ZoomMeetingsInvalidRequestError) {
throw error;
}
throw invalidRequest(formatErrorMessage(error));
}
return {
url,
transport: normalizeTransport(raw.transport),
mode: normalizeMode(raw.mode),
message: normalizeOptionalString(raw.message),
requesterSessionKey: normalizeOptionalString(raw.requesterSessionKey),
agentId: normalizeOptionalString(raw.agentId),
timeoutMs,
};
}
type ToolAction = "join" | "leave" | "status" | "transcript" | "speak";
function gatewayMethod(action: ToolAction): string {
return `zoommeetings.${action}`;
}
function readErrorDetails(error: unknown): unknown {
return error && typeof error === "object" && "details" in error
? (error as { details?: unknown }).details
: undefined;
}
async function callGatewayFromTool(params: {
action: ToolAction;
config: ZoomMeetingsConfig;
raw: Record<string, unknown>;
runtime?: OpenClawPluginApi["runtime"];
}) {
try {
if (params.runtime) {
return await params.runtime.gateway.request(gatewayMethod(params.action), params.raw, {
timeoutMs: resolveZoomMeetingsGatewayOperationTimeoutMs(params.config),
scopes: ["operator.admin"],
});
}
return await callGatewayFromCli(
gatewayMethod(params.action),
{
json: true,
timeout: String(resolveZoomMeetingsGatewayOperationTimeoutMs(params.config)),
},
params.raw,
{ progress: false, scopes: ["operator.admin"] },
);
} catch (error) {
const details = readErrorDetails(error);
if (details && typeof details === "object") {
return details;
}
throw error;
}
}
export default definePluginEntry({
id: "zoom-meetings",
name: "Zoom meetings",
description: "Join Zoom meetings as a Chrome browser guest",
configSchema: zoomMeetingsConfigSchema,
register(api: OpenClawPluginApi) {
const config = zoomMeetingsConfigSchema.parse(api.pluginConfig);
let runtime: ZoomMeetingsRuntime | undefined;
const ensureRuntime = async () => {
if (!config.enabled) {
throw new Error("Zoom meetings plugin disabled in plugin config");
export default definePluginEntry(
MeetingPlatformAdapter.createPluginEntry<
ZoomMeetingsConfig,
ZoomMeetingsJoinRequest,
ZoomMeetingsRuntime
>({
id: "zoom-meetings",
name: "Zoom meetings",
description: "Join Zoom meetings as a Chrome browser guest",
configSchema: zoomMeetingsConfigSchema,
disabledMessage: "Zoom meetings plugin disabled in plugin config",
gatewayMethodPrefix: "zoommeetings",
invalidRequest: zoomMeetingsInvalidRequest,
isInvalidRequest: (error) => error instanceof ZoomMeetingsInvalidRequestError,
normalizeUrl: normalizeZoomMeetingUrl,
resolveGatewayTimeoutMs: resolveZoomMeetingsGatewayOperationTimeoutMs,
normalizeRequesterSessionKey: (value, trustedOwner) =>
trustedOwner && typeof value === "string" && value.trim() ? value.trim() : undefined,
normalizeToolAgentId: (agentId) => normalizeAgentId(agentId),
resolveToolRuntime: async (api) => {
if (!(await api.runtime.gateway.isAvailable())) {
throw new Error("Zoom meeting tools require a Gateway-hosted agent run.");
}
runtime ??= new ZoomMeetingsRuntime({
return api.runtime;
},
unknownActionMessage: "unknown zoom_meetings action",
toolName: "zoom_meetings",
toolLabel: "Zoom meetings",
toolDescription:
"Join and manage Zoom meeting browser guests. Guest admission, tenant sign-in, and media permissions may require manual action in the OpenClaw Chrome profile.",
toolParameters: ZoomMeetingsToolSchema,
createRuntime: ({ api, config }) =>
new ZoomMeetingsRuntime({
config,
fullConfig: api.config,
runtime: api.runtime,
logger: api.logger,
});
return runtime;
};
const sendError = (
respond: GatewayRequestHandlerOptions["respond"],
error: unknown,
code: Parameters<typeof errorShape>[0] = ErrorCodes.UNAVAILABLE,
) => {
const payload = { error: formatErrorMessage(error) };
respond(false, payload, errorShape(code, payload.error, { details: payload }));
};
const sendRequestError = (respond: GatewayRequestHandlerOptions["respond"], error: unknown) =>
sendError(
respond,
error,
error instanceof ZoomMeetingsInvalidRequestError
? ErrorCodes.INVALID_REQUEST
: ErrorCodes.UNAVAILABLE,
);
api.registerGatewayMethod(
"zoommeetings.join",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
respond(true, await (await ensureRuntime()).join(joinRequest(raw)));
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"zoommeetings.leave",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const sessionId = requireString(raw.sessionId, "sessionId");
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.leave(sessionId),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"zoommeetings.status",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId
? await rt.statusForAgent(agentId, normalizeOptionalString(raw.sessionId))
: await rt.status(normalizeOptionalString(raw.sessionId)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"zoommeetings.transcript",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const sinceIndex = readSinceIndex(raw);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.transcript(sessionId, sinceIndex === undefined ? {} : { sinceIndex }),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"zoommeetings.speak",
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false, spoken: false }
: await rt.speak(sessionId, normalizeOptionalString(raw.message)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
"zoommeetings.setup",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
respond(
true,
await (
await ensureRuntime()
).setupStatus({
mode: normalizeMode(params?.mode),
transport: normalizeTransport(params?.transport),
}),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
for (const [method, run] of [
[
"zoommeetings.testSpeech",
(rt: ZoomMeetingsRuntime, raw: Record<string, unknown>) =>
rt.testSpeech(joinRequest(raw, { allowTimeout: true })),
],
[
"zoommeetings.testListen",
(rt: ZoomMeetingsRuntime, raw: Record<string, unknown>) =>
rt.testListen(joinRequest(raw, { allowTimeout: true })),
],
] as const) {
api.registerGatewayMethod(
method,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
respond(true, await run(await ensureRuntime(), raw));
} catch (error) {
sendRequestError(respond, error);
}
},
);
}
api.registerTool(
(toolContext) => ({
name: "zoom_meetings",
label: "Zoom meetings",
description:
"Join and manage Zoom meeting browser guests. Guest admission, tenant sign-in, and media permissions may require manual action in the OpenClaw Chrome profile.",
parameters: ZoomMeetingsToolSchema,
async execute(_toolCallId, params) {
const raw = asRecord(params);
const action = raw.action as ToolAction;
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const contextAgentId =
toolContext.agentId ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
const agentId = normalizeAgentId(contextAgentId);
try {
if (!(["join", "leave", "status", "transcript", "speak"] as const).includes(action)) {
throw new Error("unknown zoom_meetings action");
}
const useRuntime = await api.runtime.gateway.isAvailable();
if (!useRuntime) {
throw new Error("Zoom meeting tools require a Gateway-hosted agent run.");
}
return json(
await callGatewayFromTool({
action,
config,
raw: {
...raw,
...(requesterSessionKey ? { requesterSessionKey } : {}),
agentId,
},
runtime: api.runtime,
}),
);
} catch (error) {
return json({ error: formatErrorMessage(error) });
}
},
}),
{ name: "zoom_meetings" },
);
if (config.enabled) {
api.registerNodeHostCommand({
command: ZOOM_MEETINGS_NODE_COMMAND,
cap: "zoom-meetings",
dangerous: true,
handle: handleZoomMeetingsNodeHostCommand,
});
api.registerNodeInvokePolicy(createZoomMeetingsNodeInvokePolicy(config));
}
api.registerCli(
async ({ program }) => {
const cli = await loadZoomMeetingsCli();
cli.registerZoomMeetingsCli({ program, config });
},
{
commands: ["zoommeetings"],
descriptors: [
{
name: "zoommeetings",
description: "Join and manage Zoom meeting guests",
hasSubcommands: true,
},
],
},
);
},
});
nodeCommand: ZOOM_MEETINGS_NODE_COMMAND,
cap: "zoom-meetings",
nodeHandler: handleZoomMeetingsNodeHostCommand,
createNodePolicy: createZoomMeetingsNodeInvokePolicy,
registerNodeWhen: (config) => config.enabled,
registerCli: (api, config) => {
api.registerCli(
async ({ program }) => {
const cli = await loadZoomMeetingsCli();
cli.registerZoomMeetingsCli({ program, config });
},
{
commands: ["zoommeetings"],
descriptors: [
{
name: "zoommeetings",
description: "Join and manage Zoom meeting guests",
hasSubcommands: true,
},
],
},
);
},
}),
);

View File

@@ -8,6 +8,7 @@ import type {
MeetingPlatformAdapter as MeetingPlatformAdapterContract,
MeetingPlatformRuntimeMetadata,
} from "./platform-adapter-contract.js";
import { createMeetingPluginEntryOptions } from "./plugin-entry.js";
import { createMeetingRuntimeProbes } from "./runtime-probes.js";
import type { MeetingBrowserHealth, MeetingTranscriptSnapshot } from "./session-types.js";
import { createMeetingStatusCallSource } from "./status-call-source.js";
@@ -362,6 +363,7 @@ export const MeetingPlatformAdapter = {
createChromeTransport: createMeetingChromeTransport,
createRuntimeProbes: createMeetingRuntimeProbes,
createNodeHostHandler: createMeetingConfiguredNodeHost,
createPluginEntry: createMeetingPluginEntryOptions,
createStatusCallSource: createMeetingStatusCallSource,
createStatusPreludeSource: createMeetingStatusPreludeSource,
};

View File

@@ -0,0 +1,413 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { TObject } from "typebox";
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/schema/error-codes.js";
import { readNonNegativeIntegerParam, readPositiveIntegerParam } from "../agents/tools/common.js";
import { jsonResult } from "../agents/tools/common.js";
import { callGatewayFromCli } from "../cli/gateway-rpc.js";
import type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { OpenClawPluginApi } from "../plugins/plugin-api.types.js";
import type { OpenClawPluginConfigSchema } from "../plugins/plugin-config-schema.types.js";
import type { OpenClawPluginNodeInvokePolicy } from "../plugins/plugin-registration.types.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
type MeetingToolAction = "join" | "leave" | "status" | "transcript" | "speak";
type MeetingMode = "agent" | "bidi" | "transcribe";
type MeetingTransport = "chrome" | "chrome-node";
type MeetingJoinRequest = {
agentId?: string;
message?: string;
mode?: MeetingMode;
requesterSessionKey?: string;
timeoutMs?: number;
transport?: MeetingTransport;
url: string;
};
type MeetingPluginConfig = {
enabled: boolean;
chromeNode: { node?: string };
};
type MeetingPluginRuntime<Request extends MeetingJoinRequest> = {
join(request: Request): Promise<unknown>;
leave(sessionId: string): Promise<unknown>;
ownsSession(agentId: string, sessionId: string): boolean;
setupStatus(params: { mode?: MeetingMode; transport?: MeetingTransport }): Promise<unknown>;
speak(sessionId: string, message?: string): Promise<unknown>;
status(sessionId?: string): Promise<unknown>;
statusForAgent(agentId: string, sessionId?: string): Promise<unknown>;
testListen(request: Request): Promise<unknown>;
testSpeech(request: Request): Promise<unknown>;
transcript(sessionId: string, options: { sinceIndex?: number }): Promise<unknown>;
};
type MeetingPluginEntryOptions<
Config extends MeetingPluginConfig,
Request extends MeetingJoinRequest,
Runtime extends MeetingPluginRuntime<Request>,
> = {
cap: string;
configSchema: OpenClawPluginConfigSchema & { parse(value: unknown): Config };
createNodePolicy(config: Config): OpenClawPluginNodeInvokePolicy;
createRuntime(params: { api: OpenClawPluginApi; config: Config }): Runtime;
description: string;
disabledMessage: string;
gatewayMethodPrefix: string;
id: string;
invalidRequest(message: string): Error;
isInvalidRequest(error: unknown): boolean;
name: string;
nodeCommand: string;
nodeHandler(paramsJSON?: string | null): Promise<string>;
normalizeRequesterSessionKey(value: unknown, trustedOwner: boolean): string | undefined;
normalizeToolAgentId(agentId: string | undefined): string | undefined;
normalizeUrl(url: string): string;
registerCli(api: OpenClawPluginApi, config: Config): void;
registerNodeWhen(config: Config): boolean;
resolveGatewayTimeoutMs(config: Config): number;
resolveToolRuntime(
api: OpenClawPluginApi,
agentId: string | undefined,
): Promise<OpenClawPluginApi["runtime"] | undefined>;
toolDescription: string;
toolLabel: string;
toolName: string;
toolParameters: TObject;
unknownActionMessage: string;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readErrorDetails(error: unknown): unknown {
return error && typeof error === "object" && "details" in error
? (error as { details?: unknown }).details
: undefined;
}
export function createMeetingPluginEntryOptions<
Config extends MeetingPluginConfig,
Request extends MeetingJoinRequest,
Runtime extends MeetingPluginRuntime<Request>,
>(options: MeetingPluginEntryOptions<Config, Request, Runtime>) {
const invalidRequest = (message: string): never => {
throw options.invalidRequest(message);
};
const normalizeTransport = (value: unknown): MeetingTransport | undefined => {
if (value === undefined) {
return undefined;
}
if (value === "chrome" || value === "chrome-node") {
return value;
}
return invalidRequest("transport must be chrome or chrome-node");
};
const normalizeMode = (value: unknown): MeetingMode | undefined => {
if (value === undefined) {
return undefined;
}
if (value === "agent" || value === "bidi" || value === "transcribe") {
return value;
}
return invalidRequest("mode must be agent, bidi, or transcribe");
};
const requireString = (value: unknown, name: string): string => {
const normalized = normalizeOptionalString(value);
return normalized ?? invalidRequest(`${name} required`);
};
const readSinceIndex = (raw: Record<string, unknown>): number | undefined => {
try {
return readNonNegativeIntegerParam(raw, "sinceIndex");
} catch (error) {
return invalidRequest(formatErrorMessage(error));
}
};
const keepTrustedToolContext = (
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
): Record<string, unknown> => {
const { agentId: rawAgentId, requesterSessionKey: rawRequesterSessionKey, ...rest } = raw;
const trustedOwner = client?.internal?.pluginRuntimeOwnerId === options.id;
const agentId = trustedOwner ? normalizeOptionalString(rawAgentId) : undefined;
const requesterSessionKey = options.normalizeRequesterSessionKey(
rawRequesterSessionKey,
trustedOwner,
);
return {
...rest,
...(agentId ? { agentId } : {}),
...(requesterSessionKey ? { requesterSessionKey } : {}),
};
};
const trustedToolAgentId = (
raw: Record<string, unknown>,
client: GatewayRequestHandlerOptions["client"],
) => normalizeOptionalString(keepTrustedToolContext(raw, client).agentId);
const joinRequest = (
raw: Record<string, unknown>,
joinOptions?: { allowTimeout?: boolean },
): Request => {
if (!joinOptions?.allowTimeout && raw.timeoutMs !== undefined) {
return invalidRequest("timeoutMs is supported only by testSpeech or testListen");
}
try {
return {
url: options.normalizeUrl(requireString(raw.url, "url")),
transport: normalizeTransport(raw.transport),
mode: normalizeMode(raw.mode),
message: normalizeOptionalString(raw.message),
requesterSessionKey: normalizeOptionalString(raw.requesterSessionKey),
agentId: normalizeOptionalString(raw.agentId),
timeoutMs: readPositiveIntegerParam(raw, "timeoutMs"),
} as Request;
} catch (error) {
if (options.isInvalidRequest(error)) {
throw error;
}
return invalidRequest(formatErrorMessage(error));
}
};
const gatewayMethod = (action: MeetingToolAction) => `${options.gatewayMethodPrefix}.${action}`;
const callGatewayFromTool = async (params: {
action: MeetingToolAction;
config: Config;
raw: Record<string, unknown>;
runtime?: OpenClawPluginApi["runtime"];
}) => {
try {
const timeoutMs = options.resolveGatewayTimeoutMs(params.config);
if (params.runtime) {
return await params.runtime.gateway.request(gatewayMethod(params.action), params.raw, {
timeoutMs,
scopes: ["operator.admin"],
});
}
return await callGatewayFromCli(
gatewayMethod(params.action),
{ json: true, timeout: String(timeoutMs) },
params.raw,
{ progress: false, scopes: ["operator.admin"] },
);
} catch (error) {
const details = readErrorDetails(error);
if (details && typeof details === "object") {
return details;
}
throw error;
}
};
return {
id: options.id,
name: options.name,
description: options.description,
configSchema: options.configSchema,
register(api: OpenClawPluginApi) {
const config = options.configSchema.parse(api.pluginConfig) as Config;
let runtime: Runtime | undefined;
const ensureRuntime = async () => {
if (!config.enabled) {
throw new Error(options.disabledMessage);
}
runtime ??= options.createRuntime({ api, config });
return runtime;
};
const sendError = (
respond: GatewayRequestHandlerOptions["respond"],
error: unknown,
code: Parameters<typeof errorShape>[0] = ErrorCodes.UNAVAILABLE,
) => {
const payload = { error: formatErrorMessage(error) };
respond(false, payload, errorShape(code, payload.error, { details: payload }));
};
const sendRequestError = (respond: GatewayRequestHandlerOptions["respond"], error: unknown) =>
sendError(
respond,
error,
options.isInvalidRequest(error) ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE,
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.join`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
respond(true, await (await ensureRuntime()).join(joinRequest(raw)));
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.leave`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const sessionId = requireString(raw.sessionId, "sessionId");
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.leave(sessionId),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.status`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId
? await rt.statusForAgent(agentId, normalizeOptionalString(raw.sessionId))
: await rt.status(normalizeOptionalString(raw.sessionId)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.transcript`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const sinceIndex = readSinceIndex(raw);
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false }
: await rt.transcript(sessionId, sinceIndex === undefined ? {} : { sinceIndex }),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.speak`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const sessionId = requireString(raw.sessionId, "sessionId");
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
true,
agentId && !rt.ownsSession(agentId, sessionId)
? { found: false, spoken: false }
: await rt.speak(sessionId, normalizeOptionalString(raw.message)),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
api.registerGatewayMethod(
`${options.gatewayMethodPrefix}.setup`,
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
respond(
true,
await (
await ensureRuntime()
).setupStatus({
mode: normalizeMode(params?.mode),
transport: normalizeTransport(params?.transport),
}),
);
} catch (error) {
sendRequestError(respond, error);
}
},
);
for (const [method, run] of [
[
`${options.gatewayMethodPrefix}.testSpeech`,
(rt: Runtime, raw: Record<string, unknown>) =>
rt.testSpeech(joinRequest(raw, { allowTimeout: true })),
],
[
`${options.gatewayMethodPrefix}.testListen`,
(rt: Runtime, raw: Record<string, unknown>) =>
rt.testListen(joinRequest(raw, { allowTimeout: true })),
],
] as const) {
api.registerGatewayMethod(
method,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
respond(true, await run(await ensureRuntime(), raw));
} catch (error) {
sendRequestError(respond, error);
}
},
);
}
api.registerTool(
(toolContext) => ({
name: options.toolName,
label: options.toolLabel,
description: options.toolDescription,
parameters: options.toolParameters,
async execute(_toolCallId, params) {
const raw = asRecord(params);
const action = raw.action as MeetingToolAction;
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const contextAgentId =
toolContext.agentId ?? parseAgentSessionKey(requesterSessionKey)?.agentId;
const agentId = options.normalizeToolAgentId(contextAgentId);
try {
if (!(["join", "leave", "status", "transcript", "speak"] as const).includes(action)) {
throw new Error(options.unknownActionMessage);
}
const runtimeForTool = await options.resolveToolRuntime(api, agentId);
return jsonResult(
await callGatewayFromTool({
action,
config,
raw: {
...raw,
...(requesterSessionKey ? { requesterSessionKey } : {}),
...(runtimeForTool && agentId ? { agentId } : {}),
},
runtime: runtimeForTool,
}),
);
} catch (error) {
return jsonResult({ error: formatErrorMessage(error) });
}
},
}),
{ name: options.toolName },
);
if (options.registerNodeWhen(config)) {
api.registerNodeHostCommand({
command: options.nodeCommand,
cap: options.cap,
dangerous: true,
handle: (paramsJSON) => options.nodeHandler(paramsJSON),
});
api.registerNodeInvokePolicy(options.createNodePolicy(config));
}
options.registerCli(api, config);
},
};
}