diff --git a/extensions/teams-meetings/index.ts b/extensions/teams-meetings/index.ts index c1fe91f43e51..50654f5f7b22 100644 --- a/extensions/teams-meetings/index.ts +++ b/extensions/teams-meetings/index.ts @@ -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 { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - 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): number | undefined { - try { - return readNonNegativeIntegerParam(raw, "sinceIndex"); - } catch (error) { - throw invalidRequest(formatErrorMessage(error)); - } -} - -function keepTrustedToolAgentId( - raw: Record, - client: GatewayRequestHandlerOptions["client"], -): Record { - 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, - client: GatewayRequestHandlerOptions["client"], -): string | undefined { - return normalizeOptionalString(keepTrustedToolAgentId(raw, client).agentId); -} - -function joinRequest(raw: Record, 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; - 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[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) => - rt.testSpeech(joinRequest(raw, { allowTimeout: true })), - ], - [ - "teamsmeetings.testListen", - (rt: TeamsMeetingsRuntime, raw: Record) => - 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, + }, + ], + }, + ); + }, + }), +); diff --git a/extensions/zoom-meetings/index.ts b/extensions/zoom-meetings/index.ts index 030f6c8c68d9..a02473f082d4 100644 --- a/extensions/zoom-meetings/index.ts +++ b/extensions/zoom-meetings/index.ts @@ -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 { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -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): number | undefined { - try { - return readNonNegativeIntegerParam(raw, "sinceIndex"); - } catch (error) { - throw invalidRequest(formatErrorMessage(error)); - } -} - -function keepTrustedToolContext( - raw: Record, - client: GatewayRequestHandlerOptions["client"], -): Record { - 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, - client: GatewayRequestHandlerOptions["client"], -): string | undefined { - return normalizeOptionalString(keepTrustedToolContext(raw, client).agentId); -} - -function joinRequest(raw: Record, 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; - 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[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) => - rt.testSpeech(joinRequest(raw, { allowTimeout: true })), - ], - [ - "zoommeetings.testListen", - (rt: ZoomMeetingsRuntime, raw: Record) => - 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, + }, + ], + }, + ); + }, + }), +); diff --git a/src/meeting-bot/platform-adapter.ts b/src/meeting-bot/platform-adapter.ts index 5883f10c80fa..8d73fc9f6a72 100644 --- a/src/meeting-bot/platform-adapter.ts +++ b/src/meeting-bot/platform-adapter.ts @@ -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, }; diff --git a/src/meeting-bot/plugin-entry.ts b/src/meeting-bot/plugin-entry.ts new file mode 100644 index 000000000000..146eb53e9d52 --- /dev/null +++ b/src/meeting-bot/plugin-entry.ts @@ -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 = { + join(request: Request): Promise; + leave(sessionId: string): Promise; + ownsSession(agentId: string, sessionId: string): boolean; + setupStatus(params: { mode?: MeetingMode; transport?: MeetingTransport }): Promise; + speak(sessionId: string, message?: string): Promise; + status(sessionId?: string): Promise; + statusForAgent(agentId: string, sessionId?: string): Promise; + testListen(request: Request): Promise; + testSpeech(request: Request): Promise; + transcript(sessionId: string, options: { sinceIndex?: number }): Promise; +}; + +type MeetingPluginEntryOptions< + Config extends MeetingPluginConfig, + Request extends MeetingJoinRequest, + Runtime extends MeetingPluginRuntime, +> = { + 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; + 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; + toolDescription: string; + toolLabel: string; + toolName: string; + toolParameters: TObject; + unknownActionMessage: string; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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, +>(options: MeetingPluginEntryOptions) { + 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): number | undefined => { + try { + return readNonNegativeIntegerParam(raw, "sinceIndex"); + } catch (error) { + return invalidRequest(formatErrorMessage(error)); + } + }; + const keepTrustedToolContext = ( + raw: Record, + client: GatewayRequestHandlerOptions["client"], + ): Record => { + 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, + client: GatewayRequestHandlerOptions["client"], + ) => normalizeOptionalString(keepTrustedToolContext(raw, client).agentId); + const joinRequest = ( + raw: Record, + 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; + 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[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) => + rt.testSpeech(joinRequest(raw, { allowTimeout: true })), + ], + [ + `${options.gatewayMethodPrefix}.testListen`, + (rt: Runtime, raw: Record) => + 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); + }, + }; +}