mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(voice-call): preserve per-call agent routing (#77763)
* fix(voice-call): preserve per-call agent routing Co-authored-by: Tran Quang <randytran8800@gmail.com> * chore: keep release notes in PR metadata --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
parent
8cb9263692
commit
19f7b72a74
37 changed files with 953 additions and 209 deletions
|
|
@ -805,6 +805,12 @@ Twilio-only config:
|
|||
|
||||
With `voiceCall.enabled: true` (the default) and Twilio transport, Voice Call places the DTMF sequence before opening the realtime media stream, then uses the saved intro text as the initial realtime greeting. If `voice-call` is not enabled, Google Meet can still validate and record the dial plan but cannot place the Twilio call.
|
||||
|
||||
Leave `voiceCall.gatewayUrl` unset to use the local trusted Gateway runtime, which preserves the
|
||||
invoking agent for the full call. A configured Gateway URL remains an explicit WebSocket target and
|
||||
cannot authenticate plugin provenance; non-default agent joins fail closed instead of silently
|
||||
using another agent. Run Google Meet and Voice Call in the same Gateway process when per-agent
|
||||
routing is required.
|
||||
|
||||
## Tool
|
||||
|
||||
Agents use the `google_meet` tool:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ summary: "api.runtime -- the injected runtime helpers available to plugins"
|
|||
title: "Plugin runtime helpers"
|
||||
sidebarTitle: "Runtime helpers"
|
||||
read_when:
|
||||
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, subagent, nodes)
|
||||
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, Gateway, subagent, nodes)
|
||||
- You want to understand what api.runtime exposes
|
||||
- You are accessing config, agent, or media helpers from plugin code
|
||||
---
|
||||
|
|
@ -214,6 +214,27 @@ two-party event loops that do not go through the shared inbound reply runner.
|
|||
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.gateway">
|
||||
Call another Gateway method in process while preserving the current plugin's trusted runtime
|
||||
identity. This is intended for bundled or trusted official plugins that compose plugin-owned
|
||||
Gateway capabilities without opening a loopback WebSocket connection.
|
||||
|
||||
```typescript
|
||||
if (await api.runtime.gateway.isAvailable()) {
|
||||
const result = await api.runtime.gateway.request<{ callId: string }>(
|
||||
"voicecall.start",
|
||||
{ to: "+15550001234", mode: "conversation" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Requests use `operator.write` scope and do not grant admin scope. Calls from arbitrary external
|
||||
plugins are rejected. Failed methods throw a `GatewayClientRequestError`, preserving structured
|
||||
`details`, retry metadata, and the Gateway error code for recovery flows. Use `isAvailable()`
|
||||
before choosing this path from tools that can also run in standalone agent processes.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.subagent">
|
||||
Launch and manage background subagent runs.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js";
|
||||
|
||||
const voiceCallMocks = vi.hoisted(() => ({
|
||||
createVoiceCallGateway: vi.fn(({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway),
|
||||
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
|
||||
callId: "call-1",
|
||||
dtmfSent: true,
|
||||
|
|
@ -46,6 +47,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
|||
});
|
||||
|
||||
vi.mock("./src/voice-call-gateway.js", () => ({
|
||||
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
|
||||
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
|
||||
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
|
||||
speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ type GoogleMeetManifestConfigSchema = JsonSchemaObject & {
|
|||
};
|
||||
|
||||
const voiceCallMocks = vi.hoisted(() => ({
|
||||
createVoiceCallGateway: vi.fn(
|
||||
({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway,
|
||||
),
|
||||
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
|
||||
callId: "call-1",
|
||||
dtmfSent: true,
|
||||
|
|
@ -99,6 +102,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
|||
});
|
||||
|
||||
vi.mock("./src/voice-call-gateway.js", () => ({
|
||||
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
|
||||
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
|
||||
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
|
||||
getMeetVoiceCallGatewayCall: voiceCallMocks.getMeetVoiceCallGatewayCall,
|
||||
|
|
@ -1451,6 +1455,104 @@ describe("google-meet plugin", () => {
|
|||
expect(gatewayJoinParams.requesterSessionKey).toBe("agent:main:discord:channel:general");
|
||||
});
|
||||
|
||||
it("keeps Twilio calls on the agent that invoked the tool", async () => {
|
||||
const { tools, gatewayRequest } = setup(
|
||||
{ defaultTransport: "twilio" },
|
||||
{
|
||||
gatewayAvailable: true,
|
||||
toolContext: { agentId: "Support", sessionKey: "agent:support:main" },
|
||||
},
|
||||
);
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", {
|
||||
action: "join",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
dialInNumber: "+15551234567",
|
||||
});
|
||||
|
||||
expect(gatewayRequest).toHaveBeenCalledWith(
|
||||
"googlemeet.join",
|
||||
expect.objectContaining({
|
||||
agentId: "support",
|
||||
requesterSessionKey: "agent:support:main",
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "support",
|
||||
sessionKey: `agent:support:google-meet:${result.details.session.id}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for standalone non-default agent routing", async () => {
|
||||
const { tools } = setup(
|
||||
{},
|
||||
{ toolContext: { agentId: "support", sessionKey: "agent:support:main" } },
|
||||
);
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", {
|
||||
action: "join",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
});
|
||||
|
||||
expect(result.details.error).toContain("requires a Gateway-hosted agent run");
|
||||
});
|
||||
|
||||
it("preserves structured recovery details from the running Gateway", async () => {
|
||||
const { tools } = setup(
|
||||
{},
|
||||
{ toolContext: { agentId: "main", sessionKey: "agent:main:main" } },
|
||||
);
|
||||
googleMeetPluginTesting.setCallGatewayFromCliForTests(async () => {
|
||||
throw Object.assign(new Error("browser login required"), {
|
||||
details: {
|
||||
manualActionRequired: true,
|
||||
reason: "not-authenticated",
|
||||
browser: { profile: "openclaw" },
|
||||
},
|
||||
});
|
||||
});
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
const result = await tool.execute("id", {
|
||||
action: "join",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
});
|
||||
|
||||
expect(result.details).toEqual({
|
||||
manualActionRequired: true,
|
||||
reason: "not-authenticated",
|
||||
browser: { profile: "openclaw" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept agent routing from an external gateway caller", async () => {
|
||||
const { methods } = setup({ defaultTransport: "twilio" });
|
||||
|
||||
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
dialInNumber: "+15551234567",
|
||||
agentId: "spoofed",
|
||||
});
|
||||
|
||||
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: undefined,
|
||||
sessionKey: expect.stringMatching(/^voice:google-meet:meet_/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("explains that Twilio joins need dial-in details", async () => {
|
||||
const { tools } = setup({ defaultTransport: "twilio" });
|
||||
const tool = tools[0] as {
|
||||
|
|
@ -1483,12 +1585,10 @@ describe("google-meet plugin", () => {
|
|||
const [endParams] = mockCall(voiceCallMocks.endMeetVoiceCallGatewayCall) as [
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(requireRecord(endParams.config, "voice-call end config").defaultTransport).toBe(
|
||||
"twilio",
|
||||
);
|
||||
expect(endParams.gateway).toBeDefined();
|
||||
expect(endParams.callId).toBe("call-1");
|
||||
expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
|
||||
config: endParams.config,
|
||||
gateway: endParams.gateway,
|
||||
callId: "call-1",
|
||||
});
|
||||
});
|
||||
|
|
@ -1543,13 +1643,11 @@ describe("google-meet plugin", () => {
|
|||
const [speakParams] = voiceCallMocks.speakMeetViaVoiceCallGateway.mock.calls.at(
|
||||
0,
|
||||
) as unknown as [Record<string, unknown>];
|
||||
expect(requireRecord(speakParams.config, "voice-call speak config").defaultTransport).toBe(
|
||||
"twilio",
|
||||
);
|
||||
expect(speakParams.gateway).toBeDefined();
|
||||
expect(speakParams.callId).toBe("call-1");
|
||||
expect(speakParams.message).toBe("Say exactly: hello after joining.");
|
||||
expect(voiceCallMocks.speakMeetViaVoiceCallGateway).toHaveBeenCalledWith({
|
||||
config: speakParams.config,
|
||||
gateway: speakParams.gateway,
|
||||
callId: "call-1",
|
||||
message: "Say exactly: hello after joining.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} 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 } 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 { Type } from "typebox";
|
||||
|
|
@ -472,8 +473,16 @@ async function callGoogleMeetGatewayFromTool(params: {
|
|||
config: GoogleMeetConfig;
|
||||
action: GoogleMeetGatewayToolAction;
|
||||
raw: Record<string, unknown>;
|
||||
runtime?: OpenClawPluginApi["runtime"];
|
||||
}): Promise<unknown> {
|
||||
try {
|
||||
if (params.runtime) {
|
||||
return await params.runtime.gateway.request(
|
||||
googleMeetGatewayMethodForToolAction(params.action),
|
||||
params.raw,
|
||||
{ timeoutMs: resolveGoogleMeetGatewayOperationTimeoutMs(params.config) },
|
||||
);
|
||||
}
|
||||
return await googleMeetToolDeps.callGatewayFromCli(
|
||||
googleMeetGatewayMethodForToolAction(params.action),
|
||||
{
|
||||
|
|
@ -492,6 +501,18 @@ async function callGoogleMeetGatewayFromTool(params: {
|
|||
}
|
||||
}
|
||||
|
||||
function keepTrustedToolAgentId(
|
||||
raw: Record<string, unknown>,
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
): Record<string, unknown> {
|
||||
const { agentId: rawAgentId, ...rest } = raw;
|
||||
if (client?.internal?.pluginRuntimeOwnerId !== "google-meet") {
|
||||
return rest;
|
||||
}
|
||||
const agentId = normalizeOptionalString(rawAgentId);
|
||||
return agentId ? { ...rest, agentId } : rest;
|
||||
}
|
||||
|
||||
async function createMeetFromParams(params: {
|
||||
config: GoogleMeetConfig;
|
||||
runtime: OpenClawPluginApi["runtime"];
|
||||
|
|
@ -724,18 +745,20 @@ export default definePluginEntry({
|
|||
|
||||
api.registerGatewayMethod(
|
||||
"googlemeet.join",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.join({
|
||||
url: resolveMeetingInput(config, params?.url),
|
||||
transport: normalizeTransport(params?.transport),
|
||||
mode: normalizeMode(params?.mode),
|
||||
dialInNumber: normalizeOptionalString(params?.dialInNumber),
|
||||
pin: normalizeOptionalString(params?.pin),
|
||||
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
||||
message: normalizeOptionalString(params?.message),
|
||||
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
|
||||
url: resolveMeetingInput(config, trustedParams.url),
|
||||
transport: normalizeTransport(trustedParams.transport),
|
||||
mode: normalizeMode(trustedParams.mode),
|
||||
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
|
||||
pin: normalizeOptionalString(trustedParams.pin),
|
||||
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
|
||||
message: normalizeOptionalString(trustedParams.message),
|
||||
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
|
||||
agentId: normalizeOptionalString(trustedParams.agentId),
|
||||
});
|
||||
respond(true, result);
|
||||
} catch (err) {
|
||||
|
|
@ -746,9 +769,9 @@ export default definePluginEntry({
|
|||
|
||||
api.registerGatewayMethod(
|
||||
"googlemeet.create",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const raw = asParamRecord(params);
|
||||
const raw = keepTrustedToolAgentId(asParamRecord(params), client);
|
||||
respond(
|
||||
true,
|
||||
shouldJoinCreatedMeet(raw)
|
||||
|
|
@ -976,18 +999,20 @@ export default definePluginEntry({
|
|||
|
||||
api.registerGatewayMethod(
|
||||
"googlemeet.testSpeech",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.testSpeech({
|
||||
url: resolveMeetingInput(config, params?.url),
|
||||
transport: normalizeTransport(params?.transport),
|
||||
mode: normalizeMode(params?.mode),
|
||||
dialInNumber: normalizeOptionalString(params?.dialInNumber),
|
||||
pin: normalizeOptionalString(params?.pin),
|
||||
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
|
||||
message: normalizeOptionalString(params?.message),
|
||||
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
|
||||
url: resolveMeetingInput(config, trustedParams.url),
|
||||
transport: normalizeTransport(trustedParams.transport),
|
||||
mode: normalizeMode(trustedParams.mode),
|
||||
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
|
||||
pin: normalizeOptionalString(trustedParams.pin),
|
||||
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
|
||||
message: normalizeOptionalString(trustedParams.message),
|
||||
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
|
||||
agentId: normalizeOptionalString(trustedParams.agentId),
|
||||
});
|
||||
respond(true, result);
|
||||
} catch (err) {
|
||||
|
|
@ -1024,8 +1049,17 @@ export default definePluginEntry({
|
|||
async execute(_toolCallId, params) {
|
||||
const raw = asParamRecord(params);
|
||||
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
|
||||
const rawWithRequester = requesterSessionKey ? { ...raw, requesterSessionKey } : raw;
|
||||
const agentId = toolContext.agentId ? normalizeAgentId(toolContext.agentId) : undefined;
|
||||
try {
|
||||
const useTrustedRuntime = agentId ? await api.runtime.gateway.isAvailable() : false;
|
||||
if (agentId && agentId !== "main" && !useTrustedRuntime) {
|
||||
throw new Error("Per-agent Google Meet routing requires a Gateway-hosted agent run.");
|
||||
}
|
||||
const rawWithRequester = {
|
||||
...raw,
|
||||
...(requesterSessionKey ? { requesterSessionKey } : {}),
|
||||
...(useTrustedRuntime ? { agentId } : {}),
|
||||
};
|
||||
assertGoogleMeetAgentToolActionSupported({ config, raw });
|
||||
switch (raw.action) {
|
||||
case "join": {
|
||||
|
|
@ -1034,6 +1068,7 @@ export default definePluginEntry({
|
|||
config,
|
||||
action: "join",
|
||||
raw: rawWithRequester,
|
||||
runtime: useTrustedRuntime ? api.runtime : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -1043,6 +1078,7 @@ export default definePluginEntry({
|
|||
config,
|
||||
action: "create",
|
||||
raw: rawWithRequester,
|
||||
runtime: useTrustedRuntime ? api.runtime : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -1052,6 +1088,7 @@ export default definePluginEntry({
|
|||
config,
|
||||
action: "test_speech",
|
||||
raw: rawWithRequester,
|
||||
runtime: useTrustedRuntime ? api.runtime : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ export async function createAndJoinMeetFromParams(params: {
|
|||
dtmfSequence: normalizeOptionalString(params.raw.dtmfSequence),
|
||||
message: normalizeOptionalString(params.raw.message),
|
||||
requesterSessionKey: normalizeOptionalString(params.raw.requesterSessionKey),
|
||||
agentId: normalizeOptionalString(params.raw.agentId),
|
||||
});
|
||||
return {
|
||||
...created,
|
||||
|
|
|
|||
|
|
@ -33,11 +33,13 @@ import type {
|
|||
GoogleMeetSession,
|
||||
} from "./transports/types.js";
|
||||
import {
|
||||
createVoiceCallGateway,
|
||||
endMeetVoiceCallGatewayCall,
|
||||
getMeetVoiceCallGatewayCall,
|
||||
isVoiceCallMissingError,
|
||||
joinMeetViaVoiceCallGateway,
|
||||
speakMeetViaVoiceCallGateway,
|
||||
type VoiceCallGateway,
|
||||
} from "./voice-call-gateway.js";
|
||||
|
||||
type ChromeAudioBridgeResult = NonNullable<
|
||||
|
|
@ -224,6 +226,7 @@ export class GoogleMeetRuntime {
|
|||
readonly #sessionStops = new Map<string, () => Promise<void>>();
|
||||
readonly #sessionSpeakers = new Map<string, (instructions?: string) => void>();
|
||||
readonly #sessionHealth = new Map<string, () => GoogleMeetChromeHealth>();
|
||||
readonly #voiceCallGateway: VoiceCallGateway;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
|
|
@ -232,7 +235,9 @@ export class GoogleMeetRuntime {
|
|||
runtime: PluginRuntime;
|
||||
logger: RuntimeLogger;
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.#voiceCallGateway = createVoiceCallGateway(params);
|
||||
}
|
||||
|
||||
list(): GoogleMeetSession[] {
|
||||
this.#refreshHealth();
|
||||
|
|
@ -434,11 +439,19 @@ export class GoogleMeetRuntime {
|
|||
|
||||
try {
|
||||
if (transport === "chrome" || transport === "chrome-node") {
|
||||
// Freeze the invoking agent into the bridge config so every later consult
|
||||
// stays on the same workspace even if plugin defaults change mid-call.
|
||||
const chromeConfig = request.agentId
|
||||
? {
|
||||
...this.params.config,
|
||||
realtime: { ...this.params.config.realtime, agentId: request.agentId },
|
||||
}
|
||||
: this.params.config;
|
||||
const result =
|
||||
transport === "chrome-node"
|
||||
? await launchChromeMeetOnNode({
|
||||
runtime: this.params.runtime,
|
||||
config: this.params.config,
|
||||
config: chromeConfig,
|
||||
fullConfig: this.params.fullConfig,
|
||||
meetingSessionId: session.id,
|
||||
requesterSessionKey: request.requesterSessionKey,
|
||||
|
|
@ -448,7 +461,7 @@ export class GoogleMeetRuntime {
|
|||
})
|
||||
: await launchChromeMeet({
|
||||
runtime: this.params.runtime,
|
||||
config: this.params.config,
|
||||
config: chromeConfig,
|
||||
fullConfig: this.params.fullConfig,
|
||||
meetingSessionId: session.id,
|
||||
requesterSessionKey: request.requesterSessionKey,
|
||||
|
|
@ -494,13 +507,17 @@ export class GoogleMeetRuntime {
|
|||
const voiceCallResult = this.params.config.voiceCall.enabled
|
||||
? await joinMeetViaVoiceCallGateway({
|
||||
config: this.params.config,
|
||||
gateway: this.#voiceCallGateway,
|
||||
dialInNumber,
|
||||
dtmfSequence,
|
||||
logger: this.params.logger,
|
||||
...(request.requesterSessionKey
|
||||
? { requesterSessionKey: request.requesterSessionKey }
|
||||
: {}),
|
||||
sessionKey: buildTwilioVoiceCallSessionKey(session.id),
|
||||
agentId: request.agentId,
|
||||
sessionKey: request.agentId
|
||||
? `agent:${request.agentId}:google-meet:${session.id}`
|
||||
: buildTwilioVoiceCallSessionKey(session.id),
|
||||
message: isGoogleMeetTalkBackMode(mode)
|
||||
? (request.message ??
|
||||
this.params.config.voiceCall.introMessage ??
|
||||
|
|
@ -520,7 +537,7 @@ export class GoogleMeetRuntime {
|
|||
if (voiceCallResult?.callId) {
|
||||
this.#sessionStops.set(session.id, async () => {
|
||||
await endMeetVoiceCallGatewayCall({
|
||||
config: this.params.config,
|
||||
gateway: this.#voiceCallGateway,
|
||||
callId: voiceCallResult.callId,
|
||||
});
|
||||
});
|
||||
|
|
@ -581,7 +598,7 @@ export class GoogleMeetRuntime {
|
|||
if (session.transport === "twilio" && session.twilio?.voiceCallId) {
|
||||
try {
|
||||
await speakMeetViaVoiceCallGateway({
|
||||
config: this.params.config,
|
||||
gateway: this.#voiceCallGateway,
|
||||
callId: session.twilio.voiceCallId,
|
||||
message:
|
||||
instructions ||
|
||||
|
|
@ -856,7 +873,7 @@ export class GoogleMeetRuntime {
|
|||
}
|
||||
try {
|
||||
const status = await getMeetVoiceCallGatewayCall({
|
||||
config: this.params.config,
|
||||
gateway: this.#voiceCallGateway,
|
||||
callId,
|
||||
});
|
||||
if (status.found === false) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function setupGoogleMeetPlugin(
|
|||
config: Record<string, unknown> = {},
|
||||
options: {
|
||||
fullConfig?: Record<string, unknown>;
|
||||
gatewayAvailable?: boolean;
|
||||
nodesListResult?: GoogleMeetTestNodeListResult;
|
||||
nodesInvokeResult?: unknown;
|
||||
browserActResult?: Record<string, unknown>;
|
||||
|
|
@ -137,6 +138,9 @@ export function setupGoogleMeetPlugin(
|
|||
return { code: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
);
|
||||
const gatewayRequest = vi.fn((method: string, params?: Record<string, unknown>) =>
|
||||
invokeGoogleMeetGatewayMethodForTest(methods, method, params, "google-meet"),
|
||||
);
|
||||
const api = createTestPluginApi({
|
||||
id: "google-meet",
|
||||
name: "Google Meet",
|
||||
|
|
@ -146,6 +150,10 @@ export function setupGoogleMeetPlugin(
|
|||
config: options.fullConfig ?? {},
|
||||
pluginConfig: config,
|
||||
runtime: {
|
||||
gateway: {
|
||||
isAvailable: vi.fn(async () => options.gatewayAvailable === true),
|
||||
request: gatewayRequest,
|
||||
},
|
||||
system: {
|
||||
runCommandWithTimeout,
|
||||
formatNativeDependencyHint: vi.fn(() => "Install with brew install blackhole-2ch."),
|
||||
|
|
@ -187,6 +195,7 @@ export function setupGoogleMeetPlugin(
|
|||
nodesInvoke,
|
||||
nodeHostCommands,
|
||||
nodeInvokePolicies,
|
||||
gatewayRequest,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -194,10 +203,12 @@ export async function invokeGoogleMeetGatewayMethodForTest(
|
|||
methods: Map<string, unknown>,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
pluginRuntimeOwnerId?: string,
|
||||
): Promise<unknown> {
|
||||
const handler = methods.get(method) as
|
||||
| ((opts: {
|
||||
params: Record<string, unknown>;
|
||||
client?: { internal?: { pluginRuntimeOwnerId?: string } };
|
||||
respond: (
|
||||
ok: boolean,
|
||||
payload?: unknown,
|
||||
|
|
@ -229,6 +240,7 @@ export async function invokeGoogleMeetGatewayMethodForTest(
|
|||
params: (params && typeof params === "object" && !Array.isArray(params)
|
||||
? params
|
||||
: {}) as Record<string, unknown>,
|
||||
...(pluginRuntimeOwnerId ? { client: { internal: { pluginRuntimeOwnerId } } } : {}),
|
||||
respond,
|
||||
}),
|
||||
).catch(reject);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export type GoogleMeetJoinRequest = {
|
|||
mode?: GoogleMeetModeInput;
|
||||
message?: string;
|
||||
requesterSessionKey?: string;
|
||||
/** Agent selected by the calling tool context. */
|
||||
agentId?: string;
|
||||
timeoutMs?: number;
|
||||
dialInNumber?: string;
|
||||
pin?: string;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveGoogleMeetConfig } from "./config.js";
|
||||
import {
|
||||
createVoiceCallGateway,
|
||||
endMeetVoiceCallGatewayCall,
|
||||
getMeetVoiceCallGatewayCall,
|
||||
joinMeetViaVoiceCallGateway,
|
||||
} from "./voice-call-gateway.js";
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
runtimeRequest: vi.fn(),
|
||||
request: vi.fn(),
|
||||
stopAndWait: vi.fn(async () => {}),
|
||||
startGatewayClientWhenEventLoopReady: vi.fn(async () => ({ ready: true, aborted: false })),
|
||||
|
|
@ -28,7 +30,9 @@ describe("Google Meet voice-call gateway", () => {
|
|||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
gatewayMocks.request.mockReset();
|
||||
gatewayMocks.request.mockResolvedValue({ callId: "call-1" });
|
||||
gatewayMocks.request.mockResolvedValue({ success: true });
|
||||
gatewayMocks.runtimeRequest.mockReset();
|
||||
gatewayMocks.runtimeRequest.mockResolvedValue({ callId: "call-1" });
|
||||
gatewayMocks.stopAndWait.mockClear();
|
||||
gatewayMocks.startGatewayClientWhenEventLoopReady.mockClear();
|
||||
});
|
||||
|
|
@ -52,8 +56,16 @@ describe("Google Meet voice-call gateway", () => {
|
|||
realtime: { introMessage: "Say exactly: I'm here and listening." },
|
||||
});
|
||||
|
||||
gatewayMocks.request
|
||||
.mockResolvedValueOnce({ callId: "call-1" })
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
const join = joinMeetViaVoiceCallGateway({
|
||||
config,
|
||||
gateway,
|
||||
dialInNumber: "+15551234567",
|
||||
dtmfSequence: "123456#",
|
||||
message: "Say exactly: I'm here and listening.",
|
||||
|
|
@ -86,23 +98,30 @@ describe("Google Meet voice-call gateway", () => {
|
|||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
expect(gatewayMocks.request).toHaveBeenCalledTimes(2);
|
||||
expect(gatewayMocks.runtimeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the intro without failing when the realtime bridge is not ready", async () => {
|
||||
gatewayMocks.request
|
||||
.mockResolvedValueOnce({ callId: "call-1" })
|
||||
.mockResolvedValueOnce({ success: false, error: "No active realtime bridge for call" });
|
||||
gatewayMocks.request.mockResolvedValueOnce({ callId: "call-1" }).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: "No active realtime bridge for call",
|
||||
});
|
||||
const config = resolveGoogleMeetConfig({
|
||||
voiceCall: {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
gatewayUrl: "wss://voice.example.test",
|
||||
dtmfDelayMs: 1,
|
||||
postDtmfSpeechDelayMs: 1,
|
||||
},
|
||||
});
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
const result = await joinMeetViaVoiceCallGateway({
|
||||
config,
|
||||
gateway,
|
||||
dialInNumber: "+15551234567",
|
||||
dtmfSequence: "123456#",
|
||||
logger,
|
||||
|
|
@ -117,14 +136,64 @@ describe("Google Meet voice-call gateway", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("treats missing delegated calls as already ended", async () => {
|
||||
gatewayMocks.request.mockRejectedValueOnce(new Error("Call not found"));
|
||||
it("routes the call through the originating agent", async () => {
|
||||
const config = resolveGoogleMeetConfig({});
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
|
||||
await joinMeetViaVoiceCallGateway({
|
||||
config,
|
||||
gateway,
|
||||
dialInNumber: "+15551234567",
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:google-meet:meet-1",
|
||||
});
|
||||
|
||||
expect(gatewayMocks.runtimeRequest).toHaveBeenCalledWith(
|
||||
"voicecall.start",
|
||||
expect.objectContaining({
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:google-meet:meet-1",
|
||||
}),
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects per-agent routing through an external Voice Call gateway", async () => {
|
||||
const config = resolveGoogleMeetConfig({
|
||||
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
|
||||
voiceCall: { gatewayUrl: "wss://voice.example.test" },
|
||||
});
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
endMeetVoiceCallGatewayCall({ config, callId: "call-1" }),
|
||||
joinMeetViaVoiceCallGateway({
|
||||
config,
|
||||
gateway,
|
||||
dialInNumber: "+15551234567",
|
||||
agentId: "support",
|
||||
}),
|
||||
).rejects.toThrow("requires the local Gateway runtime");
|
||||
expect(gatewayMocks.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats missing delegated calls as already ended", async () => {
|
||||
gatewayMocks.request.mockRejectedValueOnce(new Error("Call not found"));
|
||||
const config = resolveGoogleMeetConfig({
|
||||
voiceCall: { gatewayUrl: "wss://voice.example.test" },
|
||||
});
|
||||
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
endMeetVoiceCallGatewayCall({ gateway, callId: "call-1" }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(gatewayMocks.request).toHaveBeenCalledWith(
|
||||
|
|
@ -137,10 +206,15 @@ describe("Google Meet voice-call gateway", () => {
|
|||
it("reads delegated call status from the gateway", async () => {
|
||||
gatewayMocks.request.mockResolvedValueOnce({ found: false });
|
||||
const config = resolveGoogleMeetConfig({
|
||||
voiceCall: { gatewayUrl: "ws://127.0.0.1:18789" },
|
||||
voiceCall: { gatewayUrl: "wss://voice.example.test" },
|
||||
});
|
||||
|
||||
await expect(getMeetVoiceCallGatewayCall({ config, callId: "call-1" })).resolves.toEqual({
|
||||
const gateway = createVoiceCallGateway({
|
||||
config,
|
||||
runtime: { gateway: { request: gatewayMocks.runtimeRequest } } as never,
|
||||
});
|
||||
|
||||
await expect(getMeetVoiceCallGatewayCall({ gateway, callId: "call-1" })).resolves.toEqual({
|
||||
found: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ import {
|
|||
GatewayClient,
|
||||
startGatewayClientWhenEventLoopReady,
|
||||
} from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { GoogleMeetConfig } from "./config.js";
|
||||
|
||||
type VoiceCallGatewayClient = InstanceType<typeof GatewayClient>;
|
||||
|
||||
export type VoiceCallGateway = {
|
||||
trustedPluginIdentity: boolean;
|
||||
request: <T>(method: string, params: Record<string, unknown>) => Promise<T>;
|
||||
};
|
||||
|
||||
type VoiceCallStartResult = {
|
||||
callId?: string;
|
||||
initiated?: boolean;
|
||||
|
|
@ -77,6 +82,34 @@ async function createConnectedGatewayClient(
|
|||
return client!;
|
||||
}
|
||||
|
||||
export function createVoiceCallGateway(params: {
|
||||
config: GoogleMeetConfig;
|
||||
runtime: PluginRuntime;
|
||||
}): VoiceCallGateway {
|
||||
if (!params.config.voiceCall.gatewayUrl) {
|
||||
return {
|
||||
trustedPluginIdentity: true,
|
||||
request: (method, requestParams) =>
|
||||
params.runtime.gateway.request(method, requestParams, {
|
||||
timeoutMs: params.config.voiceCall.requestTimeoutMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
trustedPluginIdentity: false,
|
||||
async request<T>(method: string, requestParams: Record<string, unknown>): Promise<T> {
|
||||
const client = await createConnectedGatewayClient(params.config);
|
||||
try {
|
||||
return (await client.request(method, requestParams, {
|
||||
timeoutMs: params.config.voiceCall.requestTimeoutMs,
|
||||
})) as T;
|
||||
} finally {
|
||||
await client.stopAndWait({ timeoutMs: 1_000 });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isVoiceCallMissingError(error: unknown): boolean {
|
||||
const message = formatErrorMessage(error).toLowerCase();
|
||||
return message.includes("call not found") || message.includes("call is not active");
|
||||
|
|
@ -84,159 +117,116 @@ export function isVoiceCallMissingError(error: unknown): boolean {
|
|||
|
||||
export async function joinMeetViaVoiceCallGateway(params: {
|
||||
config: GoogleMeetConfig;
|
||||
gateway: VoiceCallGateway;
|
||||
dialInNumber: string;
|
||||
dtmfSequence?: string;
|
||||
logger?: RuntimeLogger;
|
||||
message?: string;
|
||||
requesterSessionKey?: string;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<VoiceCallMeetJoinResult> {
|
||||
let client: VoiceCallGatewayClient | undefined;
|
||||
|
||||
try {
|
||||
client = await createConnectedGatewayClient(params.config);
|
||||
params.logger?.info(
|
||||
`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
|
||||
const requiresTrustedAgentRouting = params.agentId && params.agentId !== "main";
|
||||
if (requiresTrustedAgentRouting && !params.gateway.trustedPluginIdentity) {
|
||||
throw new Error(
|
||||
"Per-agent Voice Call routing requires the local Gateway runtime. Remove google-meet voiceCall.gatewayUrl or omit agent routing.",
|
||||
);
|
||||
const start = (await client.request(
|
||||
"voicecall.start",
|
||||
{
|
||||
to: params.dialInNumber,
|
||||
mode: "conversation",
|
||||
...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
|
||||
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
},
|
||||
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
|
||||
)) as VoiceCallStartResult;
|
||||
if (!start.callId) {
|
||||
throw new Error(start.error || "voicecall.start did not return callId");
|
||||
}
|
||||
}
|
||||
params.logger?.info(
|
||||
`[google-meet] Delegating Twilio join to Voice Call (dtmf=${params.dtmfSequence ? "pre-connect" : "none"}, intro=${params.message ? "delayed" : "none"})`,
|
||||
);
|
||||
const start = await params.gateway.request<VoiceCallStartResult>("voicecall.start", {
|
||||
to: params.dialInNumber,
|
||||
mode: "conversation",
|
||||
...(params.dtmfSequence ? { dtmfSequence: params.dtmfSequence } : {}),
|
||||
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
|
||||
...(params.agentId && params.gateway.trustedPluginIdentity ? { agentId: params.agentId } : {}),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
});
|
||||
if (!start.callId) {
|
||||
throw new Error(start.error || "voicecall.start did not return callId");
|
||||
}
|
||||
params.logger?.info(`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`);
|
||||
const dtmfSent = Boolean(params.dtmfSequence);
|
||||
if (dtmfSent) {
|
||||
params.logger?.info(
|
||||
`[google-meet] Voice Call Twilio phone leg started: callId=${start.callId}`,
|
||||
`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
|
||||
);
|
||||
const dtmfSent = Boolean(params.dtmfSequence);
|
||||
if (dtmfSent) {
|
||||
}
|
||||
let introSent = false;
|
||||
if (params.message) {
|
||||
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
|
||||
if (delayMs > 0) {
|
||||
params.logger?.info(
|
||||
`[google-meet] Meet DTMF queued before realtime connect: callId=${start.callId} digits=${params.dtmfSequence?.length ?? 0}`,
|
||||
`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
let spoken: VoiceCallSpeakResult;
|
||||
try {
|
||||
spoken = await params.gateway.request<VoiceCallSpeakResult>("voicecall.speak", {
|
||||
callId: start.callId,
|
||||
allowTwimlFallback: false,
|
||||
message: params.message,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.(
|
||||
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
spoken = { success: false };
|
||||
}
|
||||
if (spoken.success === false) {
|
||||
params.logger?.warn?.(
|
||||
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${
|
||||
spoken.error || "voicecall.speak failed"
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
introSent = true;
|
||||
params.logger?.info(
|
||||
`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
|
||||
);
|
||||
}
|
||||
let introSent = false;
|
||||
if (params.message) {
|
||||
const delayMs = params.dtmfSequence ? params.config.voiceCall.postDtmfSpeechDelayMs : 0;
|
||||
if (delayMs > 0) {
|
||||
params.logger?.info(
|
||||
`[google-meet] Waiting ${delayMs}ms after Meet DTMF before speaking intro for callId=${start.callId}`,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
let spoken: VoiceCallSpeakResult;
|
||||
try {
|
||||
spoken = (await client.request(
|
||||
"voicecall.speak",
|
||||
{
|
||||
callId: start.callId,
|
||||
allowTwimlFallback: false,
|
||||
message: params.message,
|
||||
},
|
||||
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
|
||||
)) as VoiceCallSpeakResult;
|
||||
} catch (err) {
|
||||
params.logger?.warn?.(
|
||||
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
spoken = { success: false };
|
||||
}
|
||||
if (spoken.success === false) {
|
||||
params.logger?.warn?.(
|
||||
`[google-meet] Skipped intro speech because realtime bridge was not ready: ${
|
||||
spoken.error || "voicecall.speak failed"
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
introSent = true;
|
||||
params.logger?.info(
|
||||
`[google-meet] Intro speech requested after Meet dial sequence: callId=${start.callId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
callId: start.callId,
|
||||
dtmfSent,
|
||||
introSent,
|
||||
};
|
||||
} finally {
|
||||
await client?.stopAndWait({ timeoutMs: 1_000 });
|
||||
}
|
||||
return {
|
||||
callId: start.callId,
|
||||
dtmfSent,
|
||||
introSent,
|
||||
};
|
||||
}
|
||||
|
||||
export async function endMeetVoiceCallGatewayCall(params: {
|
||||
config: GoogleMeetConfig;
|
||||
gateway: VoiceCallGateway;
|
||||
callId: string;
|
||||
}): Promise<void> {
|
||||
let client: VoiceCallGatewayClient | undefined;
|
||||
|
||||
try {
|
||||
client = await createConnectedGatewayClient(params.config);
|
||||
try {
|
||||
await client.request(
|
||||
"voicecall.end",
|
||||
{
|
||||
callId: params.callId,
|
||||
},
|
||||
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isVoiceCallMissingError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await params.gateway.request("voicecall.end", { callId: params.callId });
|
||||
} catch (err) {
|
||||
if (!isVoiceCallMissingError(err)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await client?.stopAndWait({ timeoutMs: 1_000 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMeetVoiceCallGatewayCall(params: {
|
||||
config: GoogleMeetConfig;
|
||||
gateway: VoiceCallGateway;
|
||||
callId: string;
|
||||
}): Promise<VoiceCallStatusResult> {
|
||||
let client: VoiceCallGatewayClient | undefined;
|
||||
|
||||
try {
|
||||
client = await createConnectedGatewayClient(params.config);
|
||||
return (await client.request(
|
||||
"voicecall.status",
|
||||
{
|
||||
callId: params.callId,
|
||||
},
|
||||
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
|
||||
)) as VoiceCallStatusResult;
|
||||
} finally {
|
||||
await client?.stopAndWait({ timeoutMs: 1_000 });
|
||||
}
|
||||
return await params.gateway.request<VoiceCallStatusResult>("voicecall.status", {
|
||||
callId: params.callId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function speakMeetViaVoiceCallGateway(params: {
|
||||
config: GoogleMeetConfig;
|
||||
gateway: VoiceCallGateway;
|
||||
callId: string;
|
||||
message: string;
|
||||
}): Promise<void> {
|
||||
let client: VoiceCallGatewayClient | undefined;
|
||||
|
||||
try {
|
||||
client = await createConnectedGatewayClient(params.config);
|
||||
const spoken = (await client.request(
|
||||
"voicecall.speak",
|
||||
{
|
||||
callId: params.callId,
|
||||
message: params.message,
|
||||
},
|
||||
{ timeoutMs: params.config.voiceCall.requestTimeoutMs },
|
||||
)) as VoiceCallSpeakResult;
|
||||
if (spoken.success === false) {
|
||||
throw new Error(spoken.error || "voicecall.speak failed");
|
||||
}
|
||||
} finally {
|
||||
await client?.stopAndWait({ timeoutMs: 1_000 });
|
||||
const spoken = await params.gateway.request<VoiceCallSpeakResult>("voicecall.speak", {
|
||||
callId: params.callId,
|
||||
message: params.message,
|
||||
});
|
||||
if (spoken.success === false) {
|
||||
throw new Error(spoken.error || "voicecall.speak failed");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ function createServiceContext(): Parameters<NonNullable<Registered["service"]>["
|
|||
} as Parameters<NonNullable<Registered["service"]>["start"]>[0];
|
||||
}
|
||||
|
||||
function setup(config: Record<string, unknown>): Registered {
|
||||
function setup(
|
||||
config: Record<string, unknown>,
|
||||
toolContext: Record<string, unknown> = {},
|
||||
): Registered {
|
||||
const methods = new Map<string, unknown>();
|
||||
const methodScopes = new Map<string, string | undefined>();
|
||||
const tools: unknown[] = [];
|
||||
|
|
@ -141,7 +144,12 @@ function setup(config: Record<string, unknown>): Registered {
|
|||
methods.set(method, handler);
|
||||
methodScopes.set(method, opts?.scope);
|
||||
},
|
||||
registerTool: (tool: unknown) => tools.push(tool),
|
||||
registerTool: (tool: unknown) =>
|
||||
tools.push(
|
||||
typeof tool === "function"
|
||||
? (tool as (context: Record<string, unknown>) => unknown)(toolContext)
|
||||
: tool,
|
||||
),
|
||||
registerCli: () => {},
|
||||
registerService: (registeredService) => {
|
||||
service = registeredService;
|
||||
|
|
@ -492,6 +500,47 @@ describe("voice-call plugin", () => {
|
|||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts per-call agent routing only from plugin runtime", async () => {
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
const handler = methods.get("voicecall.start") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
client?: { internal?: { pluginRuntimeOwnerId?: string } };
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
|
||||
await handler?.({
|
||||
params: { to: "+15550001234", agentId: "support" },
|
||||
client: { internal: { pluginRuntimeOwnerId: "google-meet" } },
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
|
||||
"+15550001234",
|
||||
undefined,
|
||||
expect.objectContaining({ agentId: "support" }),
|
||||
);
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects external per-call agent routing", async () => {
|
||||
const { methods } = setup({ provider: "mock" });
|
||||
const handler = methods.get("voicecall.start") as
|
||||
| ((ctx: {
|
||||
params: Record<string, unknown>;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
}) => Promise<void>)
|
||||
| undefined;
|
||||
const respond = vi.fn();
|
||||
|
||||
await handler?.({ params: { to: "+15550001234", agentId: "spoofed" }, respond });
|
||||
|
||||
expect(runtimeStub.manager["initiateCall"]).not.toHaveBeenCalled();
|
||||
expect(firstRespondCall(respond)[2]?.code).toBe("INVALID_REQUEST");
|
||||
});
|
||||
|
||||
it("returns redacted call status", async () => {
|
||||
const call = createCallRecord({
|
||||
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
|
||||
|
|
@ -715,6 +764,25 @@ describe("voice-call plugin", () => {
|
|||
expectWarningIncludes('Run "openclaw doctor --fix"');
|
||||
});
|
||||
|
||||
it("freezes the invoking agent on tool-created calls", async () => {
|
||||
const { tools } = setup({ provider: "mock" }, { agentId: "support" });
|
||||
const tool = tools[0] as {
|
||||
execute: (id: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await tool.execute("id", {
|
||||
action: "initiate_call",
|
||||
to: "+15550001234",
|
||||
message: "Hello",
|
||||
});
|
||||
|
||||
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
|
||||
"+15550001234",
|
||||
undefined,
|
||||
expect.objectContaining({ agentId: "support", message: "Hello" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tool get_status returns json payload", async () => {
|
||||
const call = createCallRecord({
|
||||
metadata: { requesterSessionKey: "agent:main:discord:channel:general" },
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAgentId } 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 { Type } from "typebox";
|
||||
|
|
@ -409,12 +410,14 @@ export default definePluginEntry({
|
|||
dtmfSequence?: string;
|
||||
sessionKey?: string;
|
||||
requesterSessionKey?: string;
|
||||
agentId?: string;
|
||||
}) => {
|
||||
const result = await params.rt.manager.initiateCall(params.to, params.sessionKey, {
|
||||
message: params.message,
|
||||
mode: params.mode,
|
||||
dtmfSequence: params.dtmfSequence,
|
||||
...(params.requesterSessionKey ? { requesterSessionKey: params.requesterSessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
});
|
||||
if (!result.success) {
|
||||
respondError(params.respond, result.error || "initiate failed");
|
||||
|
|
@ -671,13 +674,29 @@ export default definePluginEntry({
|
|||
|
||||
api.registerGatewayMethod(
|
||||
"voicecall.start",
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
const to = normalizeOptionalString(params?.to) ?? "";
|
||||
const message = normalizeOptionalString(params?.message) ?? "";
|
||||
const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
|
||||
const sessionKey = normalizeOptionalString(params?.sessionKey);
|
||||
const requesterSessionKey = normalizeOptionalString(params?.requesterSessionKey);
|
||||
const requestedAgentId = normalizeOptionalString(params?.agentId);
|
||||
const normalizedAgentId = requestedAgentId
|
||||
? normalizeAgentId(requestedAgentId)
|
||||
: undefined;
|
||||
const pluginOwnerId = normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId);
|
||||
if (
|
||||
requestedAgentId &&
|
||||
(!pluginOwnerId || normalizedAgentId !== requestedAgentId.toLowerCase())
|
||||
) {
|
||||
respondError(
|
||||
respond,
|
||||
"agentId requires a trusted plugin caller and a valid agent id",
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!to) {
|
||||
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
||||
return;
|
||||
|
|
@ -694,6 +713,7 @@ export default definePluginEntry({
|
|||
dtmfSequence,
|
||||
sessionKey,
|
||||
...(requesterSessionKey ? { requesterSessionKey } : {}),
|
||||
...(normalizedAgentId ? { agentId: normalizedAgentId } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
|
|
@ -702,13 +722,14 @@ export default definePluginEntry({
|
|||
VOICE_CALL_WRITE_METHOD_SCOPE,
|
||||
);
|
||||
|
||||
api.registerTool({
|
||||
api.registerTool((toolContext) => ({
|
||||
name: "voice_call",
|
||||
label: "Voice Call",
|
||||
description: "Make phone calls and have voice conversations via the voice-call plugin.",
|
||||
parameters: VoiceCallToolSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const rawParams = asParamRecord(params);
|
||||
const agentId = normalizeOptionalString(toolContext.agentId);
|
||||
try {
|
||||
const rt = await ensureRuntime();
|
||||
|
||||
|
|
@ -730,6 +751,7 @@ export default definePluginEntry({
|
|||
rawParams.mode === "notify" || rawParams.mode === "conversation"
|
||||
? rawParams.mode
|
||||
: undefined,
|
||||
...(agentId ? { agentId } : {}),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "initiate failed");
|
||||
|
|
@ -816,6 +838,7 @@ export default definePluginEntry({
|
|||
{
|
||||
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
||||
message: normalizeOptionalString(rawParams.message),
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(normalizeOptionalString(rawParams.requesterSessionKey)
|
||||
? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) }
|
||||
: {}),
|
||||
|
|
@ -831,7 +854,7 @@ export default definePluginEntry({
|
|||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
api.registerCli(
|
||||
({ program }) =>
|
||||
|
|
|
|||
|
|
@ -664,6 +664,7 @@ describe("processEvent (functional)", () => {
|
|||
inboundGreeting: "Hello from global.",
|
||||
numbers: {
|
||||
"+15550002222": {
|
||||
agentId: "cards",
|
||||
inboundGreeting: "Silver Fox Cards, how can I help?",
|
||||
},
|
||||
},
|
||||
|
|
@ -685,6 +686,7 @@ describe("processEvent (functional)", () => {
|
|||
const call = requireFirstActiveCall(ctx);
|
||||
expect(call.metadata?.initialMessage).toBe("Silver Fox Cards, how can I help?");
|
||||
expect(call.metadata?.numberRouteKey).toBe("+15550002222");
|
||||
expect(call.agentId).toBe("cards");
|
||||
});
|
||||
|
||||
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Voice Call plugin module implements events behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
||||
import { resolveVoiceCallEffectiveConfig, resolveVoiceCallSessionKey } from "../config.js";
|
||||
import type { CallRecord, NormalizedEvent } from "../types.js";
|
||||
|
|
@ -100,6 +101,7 @@ function createWebhookCall(params: {
|
|||
callId,
|
||||
phone: params.direction === "outbound" ? params.to : params.from,
|
||||
}),
|
||||
agentId: normalizeAgentId(effectiveConfig.agentId),
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
|
|
|
|||
|
|
@ -207,6 +207,38 @@ describe("voice-call outbound helpers", () => {
|
|||
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
|
||||
`agent:main:voice:call:${result.callId}`,
|
||||
);
|
||||
expect(ctx.activeCalls.get(result.callId)?.agentId).toBe("main");
|
||||
});
|
||||
|
||||
it("uses the per-call agent for explicit session normalization", async () => {
|
||||
const ctx = {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: {
|
||||
name: "twilio",
|
||||
initiateCall: vi.fn(async () => ({ providerCallId: "provider-1" })),
|
||||
},
|
||||
config: {
|
||||
agentId: "main",
|
||||
maxConcurrentCalls: 3,
|
||||
outbound: { defaultMode: "conversation" },
|
||||
fromNumber: "+14155550100",
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
};
|
||||
|
||||
const result = await initiateCall(
|
||||
ctx as never,
|
||||
"+14155550123",
|
||||
"agent:support:google-meet:meet-1",
|
||||
{ agentId: "Support" },
|
||||
);
|
||||
|
||||
expect(ctx.activeCalls.get(result.callId)).toMatchObject({
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:google-meet:meet-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Voice Call plugin module implements outbound behavior.
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKeyForCall,
|
||||
|
|
@ -144,6 +145,7 @@ export async function initiateCall(
|
|||
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
|
||||
const dtmfSequence = opts.dtmfSequence;
|
||||
const requesterSessionKey = opts.requesterSessionKey?.trim();
|
||||
const agentId = normalizeAgentId(opts.agentId ?? ctx.config.agentId);
|
||||
if (dtmfSequence) {
|
||||
const validationError = validateDtmfDigits(dtmfSequence);
|
||||
if (validationError) {
|
||||
|
|
@ -188,12 +190,13 @@ export async function initiateCall(
|
|||
from,
|
||||
to,
|
||||
sessionKey: resolveVoiceCallSessionKey({
|
||||
config: ctx.config,
|
||||
config: { ...ctx.config, agentId },
|
||||
callId,
|
||||
phone: to,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: ctx.coreSession,
|
||||
}),
|
||||
agentId,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { CoreAgentDeps } from "./core-bridge.js";
|
||||
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
|
||||
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ describe("buildRealtimeVoiceInstructions", () => {
|
|||
await writeFile(path.join(workspaceDir, "IDENTITY.md"), "Name: Claw Voice\nVibe: snappy\n");
|
||||
await writeFile(path.join(workspaceDir, "SECRET.md"), "do not include\n");
|
||||
|
||||
const coreConfig = { agents: { list: [{ id: "voice" }] } } as CoreConfig;
|
||||
const coreConfig = { agents: { list: [{ id: "voice" }] } } as OpenClawConfig;
|
||||
|
||||
const instructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: "Base voice instructions.",
|
||||
|
|
@ -111,7 +112,7 @@ describe("buildRealtimeVoiceInstructions", () => {
|
|||
const instructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: "Base voice instructions.",
|
||||
config,
|
||||
coreConfig: { agents: { list: [{ id: agentId }] } } as CoreConfig,
|
||||
coreConfig: { agents: { list: [{ id: agentId }] } } as OpenClawConfig,
|
||||
agentRuntime: createAgentRuntime("/unused"),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { root } from "openclaw/plugin-sdk/security-runtime";
|
|||
import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import type { CoreAgentDeps } from "./core-bridge.js";
|
||||
|
||||
// Builds compact agent context injected into realtime voice sessions.
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ async function readWorkspaceVoiceContextFiles(params: {
|
|||
export async function buildRealtimeVoiceInstructions(params: {
|
||||
baseInstructions: string;
|
||||
config: VoiceCallConfig;
|
||||
coreConfig: CoreConfig;
|
||||
coreConfig: OpenClawConfig;
|
||||
agentRuntime: CoreAgentDeps;
|
||||
}): Promise<string> {
|
||||
const { config } = params;
|
||||
|
|
@ -83,10 +83,9 @@ export async function buildRealtimeVoiceInstructions(params: {
|
|||
];
|
||||
|
||||
if (contextConfig.includeIdentity) {
|
||||
const identity = params.agentRuntime.resolveAgentIdentity(
|
||||
params.coreConfig as OpenClawConfig,
|
||||
agentId,
|
||||
) as VoiceIdentityLike | undefined;
|
||||
const identity = params.agentRuntime.resolveAgentIdentity(params.coreConfig, agentId) as
|
||||
| VoiceIdentityLike
|
||||
| undefined;
|
||||
const identityLines = [
|
||||
normalizeString(identity?.name) ? `- Name: ${normalizeString(identity?.name)}` : undefined,
|
||||
normalizeString(identity?.emoji) ? `- Emoji: ${normalizeString(identity?.emoji)}` : undefined,
|
||||
|
|
@ -102,10 +101,7 @@ export async function buildRealtimeVoiceInstructions(params: {
|
|||
}
|
||||
|
||||
if (contextConfig.includeWorkspaceFiles) {
|
||||
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(
|
||||
params.coreConfig as OpenClawConfig,
|
||||
agentId,
|
||||
);
|
||||
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.coreConfig, agentId);
|
||||
// Workspace reads stay under the agent root; missing or unreadable context files are omitted.
|
||||
const fileSections = await readWorkspaceVoiceContextFiles({
|
||||
workspaceDir,
|
||||
|
|
|
|||
11
extensions/voice-call/src/resolve-call-agent-id.ts
Normal file
11
extensions/voice-call/src/resolve-call-agent-id.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import type { CallRecord } from "./types.js";
|
||||
|
||||
/** Keep one agent owner for the full call, including legacy stored records. */
|
||||
export function resolveCallAgentId(
|
||||
call: Pick<CallRecord, "agentId">,
|
||||
config: Pick<VoiceCallConfig, "agentId">,
|
||||
): string {
|
||||
return normalizeAgentId(call.agentId ?? config.agentId);
|
||||
}
|
||||
|
|
@ -768,6 +768,30 @@ describe("generateVoiceResponse", () => {
|
|||
expect(args.sessionFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers the agent frozen on the call", async () => {
|
||||
const { runtime, runEmbeddedAgent, resolveStorePath } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Support agent."}' },
|
||||
]);
|
||||
|
||||
await generateVoiceResponse({
|
||||
voiceConfig: VoiceCallConfigSchema.parse({ agentId: "voice", responseTimeoutMs: 5000 }),
|
||||
coreConfig: {} as CoreConfig,
|
||||
agentRuntime: runtime,
|
||||
callId: "call-123",
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:google-meet:meet-1",
|
||||
from: "+15550001111",
|
||||
transcript: [],
|
||||
userMessage: "hello there",
|
||||
});
|
||||
|
||||
expect(resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "support" });
|
||||
expect(requireEmbeddedAgentArgs(runEmbeddedAgent)).toMatchObject({
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:google-meet:meet-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the routed voice agent explicit tool allowlist to the embedded run", async () => {
|
||||
const { runtime, runEmbeddedAgent } = createAgentRuntime([
|
||||
{ text: '{"spoken":"No tools needed."}' },
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
|
||||
import { resolveVoiceResponseModel } from "./response-model.js";
|
||||
|
||||
export type VoiceResponseParams = {
|
||||
|
|
@ -27,6 +28,8 @@ export type VoiceResponseParams = {
|
|||
sessionKey?: string;
|
||||
/** Caller's phone number */
|
||||
from: string;
|
||||
/** Agent frozen on the call record. */
|
||||
agentId?: string;
|
||||
/** Conversation transcript */
|
||||
transcript: Array<{ speaker: "user" | "bot"; text: string }>;
|
||||
/** Latest user message */
|
||||
|
|
@ -249,15 +252,15 @@ export async function generateVoiceResponse(
|
|||
};
|
||||
}
|
||||
const cfg = coreConfig;
|
||||
const agentId = resolveCallAgentId({ agentId: params.agentId }, voiceConfig);
|
||||
|
||||
const resolvedSessionKey = resolveVoiceCallSessionKey({
|
||||
config: voiceConfig,
|
||||
config: { ...voiceConfig, agentId },
|
||||
callId,
|
||||
phone: from,
|
||||
explicitSessionKey: sessionKey,
|
||||
coreSession: coreConfig.session,
|
||||
});
|
||||
const agentId = voiceConfig.agentId ?? "main";
|
||||
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
|
||||
|
||||
// Resolve paths
|
||||
|
|
|
|||
|
|
@ -323,6 +323,57 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
|||
expect(mocks.webhookCtorArgs[0]?.[4]).toBe(fullConfig);
|
||||
});
|
||||
|
||||
it("builds realtime instructions for the agent frozen on each call", async () => {
|
||||
const config = createBaseConfig();
|
||||
config.realtime.enabled = true;
|
||||
config.realtime.agentContext = {
|
||||
enabled: true,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeWorkspaceFiles: false,
|
||||
files: ["SOUL.md"],
|
||||
};
|
||||
const fullConfig = {
|
||||
agents: { list: [{ id: "main" }, { id: "support" }] },
|
||||
} as OpenClawConfig;
|
||||
const resolveAgentIdentity = vi.fn((_cfg: OpenClawConfig, agentId: string) => ({
|
||||
name: agentId === "support" ? "Support Voice" : "Main Voice",
|
||||
}));
|
||||
|
||||
await createVoiceCallRuntime({
|
||||
config,
|
||||
coreConfig: {} as CoreConfig,
|
||||
fullConfig,
|
||||
agentRuntime: {
|
||||
resolveAgentIdentity,
|
||||
} as never,
|
||||
});
|
||||
|
||||
const resolveInstructions = mocks.realtimeHandlerCtorArgs[0]?.[7];
|
||||
if (typeof resolveInstructions !== "function") {
|
||||
throw new Error("expected per-call realtime instruction resolver");
|
||||
}
|
||||
const supportInstructions = resolveInstructions({
|
||||
callId: "call-support",
|
||||
agentId: "support",
|
||||
direction: "outbound",
|
||||
from: "+15550001111",
|
||||
to: "+15550002222",
|
||||
});
|
||||
expect(supportInstructions).toContain("- Agent id: support");
|
||||
expect(supportInstructions).toContain("- Name: Support Voice");
|
||||
expect(supportInstructions).not.toContain("Main Voice");
|
||||
|
||||
const unknownInstructions = resolveInstructions({
|
||||
callId: "call-unknown",
|
||||
agentId: "unknown",
|
||||
direction: "outbound",
|
||||
from: "+15550001111",
|
||||
to: "+15550002222",
|
||||
});
|
||||
expect(unknownInstructions).not.toContain("OpenClaw agent voice context:");
|
||||
});
|
||||
|
||||
it.each(["twilio", "telnyx", "plivo"] as const)(
|
||||
"fails closed when %s falls back to a local-only webhook",
|
||||
async (provider) => {
|
||||
|
|
@ -432,6 +483,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
|||
};
|
||||
mocks.managerGetCall.mockReturnValue({
|
||||
callId: "call-1",
|
||||
agentId: "support",
|
||||
direction: "outbound",
|
||||
from: "+15550001234",
|
||||
to: "+15550009999",
|
||||
|
|
@ -470,7 +522,8 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
|||
firstCallParam(runEmbeddedAgent.mock.calls as unknown[][], "embedded OpenClaw consult"),
|
||||
"embedded OpenClaw consult params",
|
||||
);
|
||||
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
|
||||
expect(consultParams.agentId).toBe("support");
|
||||
expect(consultParams.sessionKey).toBe("agent:support:voice:15550009999");
|
||||
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
|
||||
expect(consultParams.messageProvider).toBe("voice");
|
||||
expect(consultParams.lane).toBe("voice");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type RealtimeVoiceAgentConsultTranscriptEntry,
|
||||
type ResolvedRealtimeVoiceProvider,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
|
|
@ -26,11 +27,13 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||
import type { TwilioProvider } from "./providers/twilio.js";
|
||||
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
|
||||
import { resolveRealtimeFastContextConsult } from "./realtime-fast-context.js";
|
||||
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
|
||||
import { resolveVoiceResponseModel } from "./response-model.js";
|
||||
import { setVoiceCallStateRuntime, type VoiceCallStateRuntime } from "./runtime-state.js";
|
||||
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
|
||||
import { createTelephonyTtsProvider } from "./telephony-tts.js";
|
||||
import { startTunnel, type TunnelResult } from "./tunnel.js";
|
||||
import type { CallRecord } from "./types.js";
|
||||
import {
|
||||
isProviderUnreachableWebhookUrl,
|
||||
providerRequiresPublicWebhook,
|
||||
|
|
@ -234,6 +237,58 @@ async function resolveRealtimeProvider(params: {
|
|||
});
|
||||
}
|
||||
|
||||
function listRealtimeAgentIds(config: VoiceCallConfig, coreConfig: OpenClawConfig): string[] {
|
||||
const agentIds = new Set<string>([normalizeAgentId(config.agentId)]);
|
||||
for (const agent of coreConfig.agents?.list ?? []) {
|
||||
agentIds.add(normalizeAgentId(agent.id));
|
||||
}
|
||||
for (const route of Object.values(config.numbers)) {
|
||||
if (route.agentId) {
|
||||
agentIds.add(normalizeAgentId(route.agentId));
|
||||
}
|
||||
}
|
||||
return [...agentIds];
|
||||
}
|
||||
|
||||
async function createRealtimeInstructionsResolver(params: {
|
||||
config: VoiceCallConfig;
|
||||
coreConfig: OpenClawConfig;
|
||||
agentRuntime: CoreAgentDeps;
|
||||
}): Promise<(call: CallRecord) => string> {
|
||||
const genericConfig: VoiceCallConfig = {
|
||||
...params.config,
|
||||
realtime: {
|
||||
...params.config.realtime,
|
||||
agentContext: { ...params.config.realtime.agentContext, enabled: false },
|
||||
},
|
||||
};
|
||||
const genericInstructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: params.config.realtime.instructions,
|
||||
config: genericConfig,
|
||||
coreConfig: params.coreConfig,
|
||||
agentRuntime: params.agentRuntime,
|
||||
});
|
||||
const entries = await Promise.all(
|
||||
listRealtimeAgentIds(params.config, params.coreConfig).map(async (agentId) => {
|
||||
const instructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: params.config.realtime.instructions,
|
||||
config: { ...params.config, agentId },
|
||||
coreConfig: params.coreConfig,
|
||||
agentRuntime: params.agentRuntime,
|
||||
});
|
||||
return [agentId, instructions] as const;
|
||||
}),
|
||||
);
|
||||
const instructionsByAgentId = new Map(entries);
|
||||
return (call) => {
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(params.config, numberRouteKey).config;
|
||||
return (
|
||||
instructionsByAgentId.get(resolveCallAgentId(call, effectiveConfig)) ?? genericInstructions
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function createVoiceCallRuntime(params: {
|
||||
config: VoiceCallConfig;
|
||||
coreConfig: CoreConfig;
|
||||
|
|
@ -299,15 +354,13 @@ export async function createVoiceCallRuntime(params: {
|
|||
);
|
||||
if (realtimeProvider) {
|
||||
const { RealtimeCallHandler } = await loadRealtimeHandler();
|
||||
const realtimeInstructions = await buildRealtimeVoiceInstructions({
|
||||
baseInstructions: config.realtime.instructions,
|
||||
const resolveRealtimeInstructions = await createRealtimeInstructionsResolver({
|
||||
config,
|
||||
coreConfig,
|
||||
coreConfig: cfg,
|
||||
agentRuntime,
|
||||
});
|
||||
const realtimeConfig = {
|
||||
...config.realtime,
|
||||
instructions: realtimeInstructions,
|
||||
tools: resolveRealtimeVoiceAgentConsultTools(
|
||||
config.realtime.toolPolicy,
|
||||
config.realtime.tools,
|
||||
|
|
@ -321,6 +374,7 @@ export async function createVoiceCallRuntime(params: {
|
|||
realtimeProvider.providerConfig,
|
||||
config.serve.path,
|
||||
cfg,
|
||||
resolveRealtimeInstructions,
|
||||
);
|
||||
if (config.realtime.toolPolicy !== "none") {
|
||||
realtimeHandler.registerToolHandler(
|
||||
|
|
@ -332,10 +386,10 @@ export async function createVoiceCallRuntime(params: {
|
|||
}
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKeyForCall(call);
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
||||
const agentId = effectiveConfig.agentId ?? "main";
|
||||
const agentId = resolveCallAgentId(call, effectiveConfig);
|
||||
const sessionKey = resolveVoiceCallConsultSessionKey({
|
||||
...call,
|
||||
config: effectiveConfig,
|
||||
config: { ...effectiveConfig, agentId },
|
||||
coreSession: cfg.session,
|
||||
});
|
||||
const requesterSessionKey =
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ export const CallRecordSchema = z.object({
|
|||
from: z.string(),
|
||||
to: z.string(),
|
||||
sessionKey: z.string().optional(),
|
||||
/** Agent selected when the call was created. Optional for legacy records. */
|
||||
agentId: z.string().optional(),
|
||||
startedAt: z.number(),
|
||||
answeredAt: z.number().optional(),
|
||||
endedAt: z.number().optional(),
|
||||
|
|
@ -315,4 +317,6 @@ export type OutboundCallOptions = {
|
|||
dtmfSequence?: string;
|
||||
/** Session that initiated the call, used for agent context/delegated message routing */
|
||||
requesterSessionKey?: string;
|
||||
/** Agent selected for this call instead of the plugin default. */
|
||||
agentId?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1659,8 +1659,9 @@ describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
|
|||
});
|
||||
|
||||
describe("VoiceCallWebhookServer classic response routing", () => {
|
||||
it("keeps outbound calls on the top-level agent when the dialed number has an inbound route", async () => {
|
||||
it("keeps outbound calls on their frozen agent when the dialed number has an inbound route", async () => {
|
||||
const call = createCall(Date.now());
|
||||
call.agentId = "support";
|
||||
call.direction = "outbound";
|
||||
call.to = "+15550001111";
|
||||
call.sessionKey = "agent:top:voice:15550001111";
|
||||
|
|
@ -1696,8 +1697,9 @@ describe("VoiceCallWebhookServer classic response routing", () => {
|
|||
const params = requireFirstMockCall(
|
||||
mocks.generateVoiceResponse.mock.calls,
|
||||
"classic voice response",
|
||||
)[0] as { voiceConfig?: VoiceCallConfig } | undefined;
|
||||
)[0] as { agentId?: string; voiceConfig?: VoiceCallConfig } | undefined;
|
||||
expect(params?.voiceConfig?.agentId).toBe("top");
|
||||
expect(params?.agentId).toBe("support");
|
||||
expect(speak).toHaveBeenCalledWith(call.callId, "Hello back", {
|
||||
listenAfterPlayback: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
|
|||
import { isProviderStatusTerminal } from "./providers/shared/call-status.js";
|
||||
import type { TwilioProvider } from "./providers/twilio.js";
|
||||
import { normalizeProxyIp } from "./proxy-ip.js";
|
||||
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
|
||||
import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js";
|
||||
import type { WebhookResponsePayload } from "./webhook.types.js";
|
||||
import type { RealtimeCallHandler } from "./webhook/realtime-handler.js";
|
||||
|
|
@ -1013,6 +1014,7 @@ export class VoiceCallWebhookServer {
|
|||
callId,
|
||||
sessionKey: call.sessionKey,
|
||||
from: call.from,
|
||||
agentId: resolveCallAgentId(call, effectiveConfig),
|
||||
transcript: call.transcript,
|
||||
userMessage,
|
||||
onEarlyText: async (text) => {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ function makeHandler(
|
|||
provider?: Partial<VoiceCallProvider>;
|
||||
providerConfig?: Record<string, unknown>;
|
||||
realtimeProvider?: RealtimeVoiceProviderPlugin;
|
||||
resolveInstructions?: (call: CallRecord) => string;
|
||||
},
|
||||
) {
|
||||
const config: VoiceCallRealtimeConfig = {
|
||||
|
|
@ -124,6 +125,8 @@ function makeHandler(
|
|||
deps?.realtimeProvider ?? makeRealtimeProvider(() => makeBridge()),
|
||||
deps?.providerConfig ?? { apiKey: "test-key" },
|
||||
"/voice/webhook",
|
||||
undefined,
|
||||
deps?.resolveInstructions,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -406,9 +409,11 @@ describe("RealtimeCallHandler path routing", () => {
|
|||
|
||||
it("joins Telnyx realtime streams to the token-bound call", async () => {
|
||||
const processEvent = vi.fn();
|
||||
const resolveInstructions = vi.fn((call: CallRecord) => `instructions:${call.agentId}`);
|
||||
const getCall = vi.fn(
|
||||
(): CallRecord => ({
|
||||
callId: "call-1",
|
||||
agentId: "support",
|
||||
providerCallId: "v3:call-1",
|
||||
provider: "telnyx",
|
||||
direction: "inbound",
|
||||
|
|
@ -421,7 +426,7 @@ describe("RealtimeCallHandler path routing", () => {
|
|||
metadata: { initialMessage: "hello" },
|
||||
}),
|
||||
);
|
||||
const createBridge = vi.fn(() => makeBridge());
|
||||
const createBridge = vi.fn((_request: RealtimeBridgeRequest) => makeBridge());
|
||||
const handler = makeHandler(undefined, {
|
||||
manager: {
|
||||
processEvent,
|
||||
|
|
@ -431,6 +436,7 @@ describe("RealtimeCallHandler path routing", () => {
|
|||
name: "telnyx",
|
||||
},
|
||||
realtimeProvider: makeRealtimeProvider(createBridge),
|
||||
resolveInstructions,
|
||||
});
|
||||
handler.setPublicUrl("https://public.example/voice/webhook");
|
||||
const session = handler.issueStreamSession({
|
||||
|
|
@ -463,6 +469,13 @@ describe("RealtimeCallHandler path routing", () => {
|
|||
expect((processEvent.mock.calls[0]?.[0] as NormalizedEvent | undefined)?.callId).toBe(
|
||||
"call-1",
|
||||
);
|
||||
expect(resolveInstructions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
callId: "call-1",
|
||||
agentId: "support",
|
||||
}),
|
||||
);
|
||||
expect(createBridge.mock.calls[0]?.[0].instructions).toBe("instructions:support");
|
||||
} finally {
|
||||
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||
ws.close();
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export type StreamSession = {
|
|||
|
||||
type CallRegistration = {
|
||||
callId: string;
|
||||
instructions: string;
|
||||
initialGreetingInstructions?: string;
|
||||
};
|
||||
|
||||
|
|
@ -345,6 +346,7 @@ export class RealtimeCallHandler {
|
|||
private readonly providerConfig: RealtimeVoiceProviderConfig,
|
||||
private readonly servePath: string,
|
||||
private readonly coreConfig?: OpenClawConfig,
|
||||
private readonly resolveInstructions?: (call: CallRecord) => string,
|
||||
) {}
|
||||
|
||||
setPublicUrl(url: string): void {
|
||||
|
|
@ -574,7 +576,7 @@ export class RealtimeCallHandler {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { callId, initialGreetingInstructions } = registration;
|
||||
const { callId, instructions, initialGreetingInstructions } = registration;
|
||||
const callRecord = this.manager.getCallByProviderCallId(callSid);
|
||||
const talk: TalkSessionController = createTalkSessionController(
|
||||
{
|
||||
|
|
@ -721,7 +723,7 @@ export class RealtimeCallHandler {
|
|||
cfg: this.coreConfig,
|
||||
providerConfig: this.providerConfig,
|
||||
interruptResponseOnInputAudio,
|
||||
instructions: this.config.instructions,
|
||||
instructions,
|
||||
tools: this.config.tools,
|
||||
initialGreetingInstructions,
|
||||
triggerGreetingOnReady: Boolean(initialGreetingInstructions),
|
||||
|
|
@ -1269,12 +1271,11 @@ export class RealtimeCallHandler {
|
|||
...baseFields,
|
||||
});
|
||||
|
||||
const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
|
||||
return {
|
||||
callId: callRecord.callId,
|
||||
initialGreetingInstructions: buildGreetingInstructions(
|
||||
this.config.instructions,
|
||||
initialGreeting,
|
||||
),
|
||||
instructions,
|
||||
initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -921,6 +921,98 @@ describe("loadGatewayPlugins", () => {
|
|||
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
|
||||
});
|
||||
|
||||
test("dispatches gateway methods with the trusted plugin identity", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
|
||||
loadGatewayStartupPluginsForTest();
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-request"));
|
||||
const runtime = runtimeModule.createPluginRuntime();
|
||||
|
||||
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "google-meet", pluginOrigin: "bundled" },
|
||||
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
|
||||
);
|
||||
|
||||
expect(getLastDispatchedParams()).toEqual({ to: "+15550001234" });
|
||||
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
|
||||
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
|
||||
});
|
||||
|
||||
test("reports whether trusted in-process Gateway dispatch is available", async () => {
|
||||
const runtime = runtimeModule.createPluginRuntime();
|
||||
|
||||
expect(await runtime.gateway.isAvailable()).toBe(false);
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-available"));
|
||||
expect(await runtime.gateway.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
test("does not inherit admin scope for trusted plugin gateway requests", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
|
||||
loadGatewayStartupPluginsForTest();
|
||||
const scope = {
|
||||
context: createTestContext("plugin-gateway-request-admin-caller"),
|
||||
client: {
|
||||
connect: {
|
||||
scopes: ["operator.admin"],
|
||||
},
|
||||
} as GatewayRequestOptions["client"],
|
||||
isWebchatConnect: () => false,
|
||||
} satisfies PluginRuntimeGatewayRequestScope;
|
||||
const runtime = runtimeModule.createPluginRuntime();
|
||||
|
||||
await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () =>
|
||||
gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "google-meet", pluginOrigin: "bundled" },
|
||||
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]);
|
||||
expect(getLastDispatchedClientScopes()).not.toContain("operator.admin");
|
||||
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("google-meet");
|
||||
});
|
||||
|
||||
test("preserves structured errors from trusted plugin gateway requests", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue(addLoadedPlugin(createRegistry([]), { id: "google-meet" }));
|
||||
loadGatewayStartupPluginsForTest();
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-error"));
|
||||
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
|
||||
opts.respond(false, undefined, {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "browser login required",
|
||||
details: { manualActionRequired: true, reason: "not-authenticated" },
|
||||
});
|
||||
});
|
||||
const runtime = runtimeModule.createPluginRuntime();
|
||||
|
||||
const request = gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "google-meet", pluginOrigin: "bundled" },
|
||||
() => runtime.gateway.request("googlemeet.join", { url: "https://meet.google.com/abc" }),
|
||||
);
|
||||
|
||||
await expect(request).rejects.toMatchObject({
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "INVALID_REQUEST",
|
||||
details: { manualActionRequired: true, reason: "not-authenticated" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects gateway dispatch from arbitrary plugins", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue(
|
||||
addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }),
|
||||
);
|
||||
loadGatewayStartupPluginsForTest();
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("plugin-gateway-rejected"));
|
||||
const runtime = runtimeModule.createPluginRuntime();
|
||||
|
||||
await expect(
|
||||
gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "third-party", pluginOrigin: "global" },
|
||||
() => runtime.gateway.request("voicecall.start", { to: "+15550001234" }),
|
||||
),
|
||||
).rejects.toThrow("bundled or trusted official plugins");
|
||||
expect(handleGatewayRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not let arbitrary plugin nodes runtime mint admin scope for browser proxy", async () => {
|
||||
loadOpenClawPlugins.mockReturnValue(
|
||||
addLoadedPlugin(createRegistry([]), { id: "third-party", origin: "global" }),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
|
|||
import { performance } from "node:perf_hooks";
|
||||
import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
|
|
@ -22,7 +23,7 @@ import type { PluginRegistryParams } from "../plugins/registry-types.js";
|
|||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
|
||||
import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
import type { PluginRuntime, RuntimeGatewayRequestOptions } from "../plugins/runtime/types.js";
|
||||
import type { PluginLogger, PluginOrigin } from "../plugins/types.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
|
||||
|
|
@ -357,7 +358,13 @@ function unwrapGatewayMethodDispatchResponse(
|
|||
response: GatewayMethodDispatchResponse,
|
||||
): unknown {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message ?? `Gateway method "${method}" failed.`);
|
||||
throw new GatewayClientRequestError({
|
||||
code: response.error?.code,
|
||||
message: response.error?.message ?? `Gateway method "${method}" failed.`,
|
||||
details: response.error?.details,
|
||||
retryable: response.error?.retryable,
|
||||
retryAfterMs: response.error?.retryAfterMs,
|
||||
});
|
||||
}
|
||||
return response.payload;
|
||||
}
|
||||
|
|
@ -564,6 +571,23 @@ export async function dispatchGatewayMethodInProcess<T>(
|
|||
return await dispatchGatewayMethod<T>(method, params, options);
|
||||
}
|
||||
|
||||
export async function dispatchTrustedPluginGatewayMethod<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options?: RuntimeGatewayRequestOptions,
|
||||
): Promise<T> {
|
||||
const scope = getPluginRuntimeGatewayRequestScope();
|
||||
const pluginId = scope?.pluginId?.trim();
|
||||
if (!canTrustedOfficialPluginRequestScopes(scope ?? {})) {
|
||||
throw new Error("Gateway requests are only available to bundled or trusted official plugins.");
|
||||
}
|
||||
return await dispatchGatewayMethod<T>(method, params, {
|
||||
forceSyntheticClient: true,
|
||||
pluginRuntimeOwnerId: pluginId,
|
||||
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000;
|
||||
|
||||
export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
|
||||
|
|
|
|||
|
|
@ -367,6 +367,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
|||
};
|
||||
const base: PluginRuntime = {
|
||||
version: "1.0.0-test",
|
||||
gateway: {
|
||||
isAvailable: vi.fn(async () => false),
|
||||
request: vi.fn(),
|
||||
},
|
||||
config: {
|
||||
current: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["current"],
|
||||
mutateConfigFile: vi.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ const fullWorkspacePluginLoaderCacheState = new PluginLoaderCacheState<CachedPlu
|
|||
);
|
||||
const LAZY_RUNTIME_REFLECTION_KEYS = [
|
||||
"version",
|
||||
"gateway",
|
||||
"config",
|
||||
"agent",
|
||||
"subagent",
|
||||
|
|
|
|||
|
|
@ -169,4 +169,34 @@ describe("plugin registry runtime config scope", () => {
|
|||
pluginSource: "/plugins/google-meet/index.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs gateway requests with the owning plugin scope", async () => {
|
||||
let requestScope = getPluginRuntimeGatewayRequestScope();
|
||||
const runtime = createPluginRuntime();
|
||||
runtime.gateway = {
|
||||
isAvailable: async () => true,
|
||||
request: async <T>() => {
|
||||
requestScope = getPluginRuntimeGatewayRequestScope();
|
||||
return { ok: true } as T;
|
||||
},
|
||||
};
|
||||
const pluginRegistry = createTestRegistry(runtime);
|
||||
const record = createPluginRecord({
|
||||
id: "google-meet",
|
||||
name: "Google Meet",
|
||||
source: "/plugins/google-meet/index.js",
|
||||
origin: "bundled",
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
});
|
||||
const api = pluginRegistry.createApi(record, { config: {} as OpenClawConfig });
|
||||
|
||||
await api.runtime.gateway.request("voicecall.start", { to: "+15550001234" });
|
||||
|
||||
expect(requestScope).toMatchObject({
|
||||
pluginId: "google-meet",
|
||||
pluginOrigin: "bundled",
|
||||
pluginSource: "/plugins/google-meet/index.js",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2757,6 +2757,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
withPluginRuntimePluginIdScope(pluginId, () => llm.complete(params)),
|
||||
} satisfies PluginRuntime["llm"];
|
||||
}
|
||||
if (prop === "gateway") {
|
||||
const gateway = getRuntimeProperty();
|
||||
return {
|
||||
isAvailable: () => runWithPluginScope(() => gateway.isAvailable()),
|
||||
request: (method, params, options) =>
|
||||
runWithPluginScope(() => gateway.request(method, params, options)),
|
||||
} satisfies PluginRuntime["gateway"];
|
||||
}
|
||||
if (prop === "nodes") {
|
||||
const nodes = getRuntimeProperty();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,22 @@ const loadMediaUnderstandingRuntime = createLazyRuntimeModule(
|
|||
const loadModelAuthRuntime = createLazyRuntimeModule(
|
||||
() => import("./runtime-model-auth.runtime.js"),
|
||||
);
|
||||
const loadGatewayPluginRuntime = createLazyRuntimeModule(
|
||||
() => import("../../gateway/server-plugins.js"),
|
||||
);
|
||||
|
||||
function createRuntimeGateway(): PluginRuntime["gateway"] {
|
||||
return {
|
||||
isAvailable: async () => {
|
||||
const runtime = await loadGatewayPluginRuntime();
|
||||
return runtime.hasInProcessGatewayContext();
|
||||
},
|
||||
request: async (method, params, options) => {
|
||||
const runtime = await loadGatewayPluginRuntime();
|
||||
return runtime.dispatchTrustedPluginGatewayMethod(method, params, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeTts(): PluginRuntime["tts"] {
|
||||
const bindTtsRuntime = createLazyRuntimeMethodBinder(loadTtsRuntime);
|
||||
|
|
@ -253,6 +269,7 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
|
|||
// Sourced from the shared OpenClaw version resolver (#52899) so plugins
|
||||
// always see the same version the CLI reports, avoiding API-version drift.
|
||||
version: VERSION,
|
||||
gateway: createRuntimeGateway(),
|
||||
config: createRuntimeConfig(),
|
||||
agent: createRuntimeAgent(),
|
||||
subagent: createLateBindingSubagent(
|
||||
|
|
|
|||
|
|
@ -86,8 +86,22 @@ export type RuntimeNodeInvokeParams = {
|
|||
scopes?: OperatorScope[];
|
||||
};
|
||||
|
||||
export type RuntimeGatewayRequestOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Trusted in-process runtime surface injected into native plugins. */
|
||||
export type PluginRuntime = PluginRuntimeCore & {
|
||||
gateway: {
|
||||
/** Whether this process owns an active Gateway request context. */
|
||||
isAvailable: () => Promise<boolean>;
|
||||
/** Dispatch a Gateway method as the current trusted plugin. */
|
||||
request: <T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: RuntimeGatewayRequestOptions,
|
||||
) => Promise<T>;
|
||||
};
|
||||
subagent: {
|
||||
run: (params: SubagentRunParams) => Promise<SubagentRunResult>;
|
||||
waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue