diff --git a/docs/plugin.md b/docs/plugin.md index 9a934d22e..8fdb39a90 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -173,13 +173,15 @@ it’s present in your workspace/managed skills locations. ## Example plugin: Voice Call -This repo includes a voice‑call placeholder plugin: +This repo includes a voice‑call plugin (Twilio or log fallback): - Source: `extensions/voice-call` - Skill: `skills/voice-call` -- CLI: `clawdbot voicecall status` +- CLI: `clawdbot voicecall start|status` - Tool: `voice_call` -- RPC: `voicecall.status` +- RPC: `voicecall.start`, `voicecall.status` +- Config (twilio): `provider: "twilio"` + `twilio.accountSid/authToken/from` (optional `statusCallbackUrl`, `twimlUrl`) +- Config (dev): `provider: "log"` (no network) See `extensions/voice-call/README.md` for setup and usage. diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index 35e79a726..da629c924 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -1,7 +1,6 @@ -# Voice Call Plugin (Placeholder) +# Voice Call Plugin -This is a **stub** plugin used to validate the Clawdbot plugin API. -It does not place real calls yet. +Twilio-backed outbound voice calls (with a log-only fallback for dev). ## Install (local dev) @@ -19,20 +18,41 @@ Option 2: add via config: { plugins: { load: { paths: ["/absolute/path/to/extensions/voice-call"] }, - entries: { - "voice-call": { enabled: true, config: { provider: "twilio" } } - } + entries: { "voice-call": { enabled: true } } } } ``` Restart the Gateway after changes. +## Config + +Put under `plugins.entries.voice-call.config`: + +```json5 +{ + provider: "twilio", + twilio: { + accountSid: "ACxxxxxxxx", + authToken: "your_token", + from: "+15551234567", + statusCallbackUrl: "https://example.com/twilio-status", // optional + twimlUrl: "https://example.com/twiml" // optional, else auto-generates + } +} +``` + +Dev fallback (no network): + +```json5 +{ provider: "log" } +``` + ## CLI ```bash -clawdbot voicecall status -clawdbot voicecall start --to "+15555550123" --message "Hello" +clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot" +clawdbot voicecall status --sid CAxxxxxxxx ``` ## Tool @@ -40,13 +60,15 @@ clawdbot voicecall start --to "+15555550123" --message "Hello" Tool name: `voice_call` Parameters: -- `mode`: `"call" | "status"` -- `to`: target string +- `mode`: `"call" | "status"` (default: `call`) +- `to`: target string (required for call) +- `sid`: call SID (required for status) - `message`: optional intro text ## Gateway RPC -- `voicecall.status` +- `voicecall.start` (to, message?) +- `voicecall.status` (sid) ## Skill @@ -59,6 +81,5 @@ setting: ## Notes -- This plugin is a placeholder. Implement your real call flow in the tool and - RPC handlers. +- Uses Twilio REST API via fetch (no SDK). Provide valid SID/token/from. - Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency. diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 0f4870def..b0453bd3b 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -1,121 +1,298 @@ import { Type } from "@sinclair/typebox"; -const voiceCallConfigSchema = { - parse(value) { - if (value === undefined) return {}; - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("voice-call config must be an object"); +type VoiceCallConfig = + | { + provider: "twilio"; + twilio: { + accountSid: string; + authToken: string; + from: string; + statusCallbackUrl?: string; + twimlUrl?: string; + }; } - return value; + | { + provider?: "log"; + }; + +type VoiceCallStartParams = { + to: string; + message?: string; +}; + +type VoiceCallStatus = { + sid: string; + status: string; + provider: string; + to?: string; + from?: string; +}; + +const voiceCallConfigSchema = { + parse(value: unknown): VoiceCallConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { provider: "log" }; + } + const cfg = value as Record; + const provider = + cfg.provider === "twilio" || cfg.provider === "log" + ? cfg.provider + : "log"; + if (provider === "twilio") { + const twilio = cfg.twilio as Record | undefined; + if ( + !twilio || + typeof twilio.accountSid !== "string" || + typeof twilio.authToken !== "string" || + typeof twilio.from !== "string" + ) { + throw new Error( + "twilio provider requires twilio.accountSid, twilio.authToken, twilio.from", + ); + } + return { + provider: "twilio", + twilio: { + accountSid: twilio.accountSid, + authToken: twilio.authToken, + from: twilio.from, + statusCallbackUrl: + typeof twilio.statusCallbackUrl === "string" + ? twilio.statusCallbackUrl + : undefined, + twimlUrl: + typeof twilio.twimlUrl === "string" ? twilio.twimlUrl : undefined, + }, + }; + } + return { provider: "log" }; }, }; +const escapeXml = (input: string): string => + input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +async function startTwilioCall( + cfg: Exclude["twilio"], + params: VoiceCallStartParams, +): Promise { + const body = new URLSearchParams(); + body.set("To", params.to); + body.set("From", cfg.from); + + if (cfg.twimlUrl) { + body.set("Url", cfg.twimlUrl); + } else { + const say = escapeXml(params.message ?? "Hello from Clawdbot."); + body.set("Twiml", `${say}`); + } + if (cfg.statusCallbackUrl) { + body.set("StatusCallback", cfg.statusCallbackUrl); + } + + const res = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls.json`, + { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`twilio call failed: ${res.status} ${text}`); + } + const payload = (await res.json()) as Record; + return { + sid: String(payload.sid ?? ""), + status: String(payload.status ?? "unknown"), + provider: "twilio", + to: String(payload.to ?? params.to), + from: String(payload.from ?? cfg.from), + }; +} + +async function getTwilioStatus( + cfg: Exclude["twilio"], + sid: string, +): Promise { + const res = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Calls/${sid}.json`, + { + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from(`${cfg.accountSid}:${cfg.authToken}`).toString("base64")}`, + }, + }, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`twilio status failed: ${res.status} ${text}`); + } + const payload = (await res.json()) as Record; + return { + sid, + status: String(payload.status ?? "unknown"), + provider: "twilio", + to: String(payload.to ?? ""), + from: String(payload.from ?? cfg.from), + }; +} + +async function startCall( + cfg: VoiceCallConfig, + params: VoiceCallStartParams, +): Promise { + if (cfg.provider === "twilio") { + return startTwilioCall(cfg.twilio, params); + } + return { + sid: `log-${Date.now()}`, + status: "queued", + provider: "log", + to: params.to, + }; +} + +async function getStatus( + cfg: VoiceCallConfig, + sid: string, +): Promise { + if (cfg.provider === "twilio") { + return getTwilioStatus(cfg.twilio, sid); + } + return { sid, status: "mock", provider: "log" }; +} + const voiceCallPlugin = { id: "voice-call", name: "Voice Call", - description: "Voice-call plugin stub (placeholder)", + description: "Voice-call plugin with Twilio/log providers", configSchema: voiceCallConfigSchema, register(api) { - api.registerGatewayMethod("voicecall.status", ({ respond }) => { - respond(true, { - status: "idle", - provider: api.pluginConfig?.provider ?? "unset", + const cfg = voiceCallConfigSchema.parse(api.pluginConfig); + + api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => { + const to = typeof params?.to === "string" ? params.to.trim() : ""; + const message = + typeof params?.message === "string" ? params.message.trim() : undefined; + if (!to) { + respond(false, { error: "to required" }); + return; + } + try { + const result = await startCall(cfg, { to, message }); + respond(true, result); + } catch (err) { + respond(false, { error: String(err) }); + } }); - }); - api.registerTool( - { - name: "voice_call", - label: "Voice Call", - description: "Start or inspect a voice call via the voice-call plugin", - parameters: Type.Object({ - mode: Type.Optional( - Type.Union([Type.Literal("call"), Type.Literal("status")]), - ), - to: Type.Optional(Type.String({ description: "Call target" })), - message: Type.Optional( - Type.String({ description: "Optional intro message" }), - ), - }), - async execute(_toolCallId, params) { - if (params.mode === "status") { - return { - content: [ - { - type: "text", - text: JSON.stringify({ status: "idle" }, null, 2), - }, - ], - details: { status: "idle" }, - }; + api.registerGatewayMethod( + "voicecall.status", + async ({ params, respond }) => { + const sid = typeof params?.sid === "string" ? params.sid.trim() : ""; + if (!sid) { + respond(false, { error: "sid required" }); + return; + } + try { + const result = await getStatus(cfg, sid); + respond(true, result); + } catch (err) { + respond(false, { error: String(err) }); } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "not_implemented", - to: params.to ?? null, - message: params.message ?? null, - }, - null, - 2, - ), - }, - ], - details: { - status: "not_implemented", - to: params.to ?? null, - message: params.message ?? null, - }, - }; }, - }, - { name: "voice_call" }, - ); + ); - api.registerCli(({ program }) => { - const voicecall = program - .command("voicecall") - .description("Voice call plugin commands"); - - voicecall - .command("status") - .description("Show voice-call status") - .action(() => { - console.log(JSON.stringify({ status: "idle" }, null, 2)); - }); - - voicecall - .command("start") - .description("Start a voice call (placeholder)") - .option("--to ", "Target to call") - .option("--message ", "Optional intro message") - .action((opts) => { - console.log( - JSON.stringify( - { - status: "not_implemented", - to: opts.to ?? null, - message: opts.message ?? null, - }, - null, - 2, + api.registerTool( + { + name: "voice_call", + label: "Voice Call", + description: "Start or inspect a voice call via the voice-call plugin", + parameters: Type.Object({ + mode: Type.Optional( + Type.Union([Type.Literal("call"), Type.Literal("status")]), ), - ); - }); - }, { commands: ["voicecall"] }); + to: Type.Optional(Type.String({ description: "Call target" })), + sid: Type.Optional(Type.String({ description: "Call SID" })), + message: Type.Optional( + Type.String({ description: "Optional intro message" }), + ), + }), + async execute(_toolCallId, params) { + const mode = params.mode ?? "call"; + if (mode === "status") { + if (typeof params.sid !== "string") { + throw new Error("sid required for status"); + } + const status = await getStatus(cfg, params.sid.trim()); + return { + content: [{ type: "text", text: JSON.stringify(status, null, 2) }], + details: status, + }; + } + const to = + typeof params.to === "string" && params.to.trim() + ? params.to.trim() + : null; + if (!to) throw new Error("to required for call"); + const result = await startCall(cfg, { to, message: params.message }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: result, + }; + }, + }, + { name: "voice_call" }, + ); - api.registerService({ - id: "voicecall", - start: () => { - api.logger.info("voice-call service ready (placeholder)"); - }, - stop: () => { - api.logger.info("voice-call service stopped (placeholder)"); - }, - }); + api.registerCli(({ program }) => { + const voicecall = program + .command("voicecall") + .description("Voice call plugin commands"); + + voicecall + .command("start") + .description("Start a voice call") + .requiredOption("--to ", "Target to call") + .option("--message ", "Optional intro message") + .action(async (opts) => { + const result = await startCall(cfg, { + to: opts.to, + message: opts.message, + }); + console.log(JSON.stringify(result, null, 2)); + }); + + voicecall + .command("status") + .description("Show voice-call status") + .requiredOption("--sid ", "Call SID") + .action(async (opts) => { + const result = await getStatus(cfg, opts.sid); + console.log(JSON.stringify(result, null, 2)); + }); + }, { commands: ["voicecall"] }); + + api.registerService({ + id: "voicecall", + start: () => { + api.logger.info(`voice-call provider: ${cfg.provider}`); + }, + stop: () => { + api.logger.info("voice-call service stopped"); + }, + }); }, }; diff --git a/skills/voice-call/SKILL.md b/skills/voice-call/SKILL.md index dfd99d603..747379f78 100644 --- a/skills/voice-call/SKILL.md +++ b/skills/voice-call/SKILL.md @@ -6,13 +6,13 @@ metadata: {"clawdbot":{"emoji":"📞","skillKey":"voice-call","requires":{"confi # Voice Call -Use the voice-call plugin to start or inspect calls. +Use the voice-call plugin to start or inspect calls (Twilio or log fallback). ## CLI ```bash -clawdbot voicecall status -clawdbot voicecall start --to "+15555550123" --message "Hello" +clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot" +clawdbot voicecall status --sid CAxxxxxxxx ``` ## Tool @@ -20,10 +20,13 @@ clawdbot voicecall start --to "+15555550123" --message "Hello" Use `voice_call` for agent-initiated calls. Parameters: -- `to` (string): phone number or provider target +- `mode` ("call" | "status", optional, default call) +- `to` (string): phone number / target (required for call) +- `sid` (string): call SID (required for status) - `message` (string, optional): optional intro or instruction -- `mode` ("call" | "status", optional) Notes: - Requires the voice-call plugin to be enabled. - Plugin config lives under `plugins.entries.voice-call.config`. +- Twilio config: `provider: "twilio"` + `twilio.accountSid/authToken/from` (statusCallbackUrl/twimlUrl optional). +- Dev fallback: `provider: "log"` (no network). diff --git a/src/plugins/voice-call.plugin.test.ts b/src/plugins/voice-call.plugin.test.ts new file mode 100644 index 000000000..096fcfd3b --- /dev/null +++ b/src/plugins/voice-call.plugin.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import plugin from "../../extensions/voice-call/index.js"; + +const noopLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +type Registered = { + methods: Map< + string, + (ctx: Record) => Promise | unknown + >; + tools: unknown[]; +}; + +function setup(config: Record): Registered { + const methods = new Map< + string, + (ctx: Record) => Promise | unknown + >(); + const tools: unknown[] = []; + plugin.register({ + id: "voice-call", + name: "Voice Call", + description: "test", + version: "0", + source: "test", + config: {}, + pluginConfig: config, + logger: noopLogger, + registerGatewayMethod: (method, handler) => methods.set(method, handler), + registerTool: (tool) => tools.push(tool), + registerCli: () => {}, + registerService: () => {}, + resolvePath: (p: string) => p, + }); + return { methods, tools }; +} + +describe("voice-call plugin", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("starts a log provider call and returns sid", async () => { + const { methods } = setup({ provider: "log" }); + const handler = methods.get("voicecall.start"); + const respond = vi.fn(); + await handler({ params: { to: "+123", message: "Hi" }, respond }); + expect(respond).toHaveBeenCalledTimes(1); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.sid).toMatch(/^log-/); + expect(payload.status).toBe("queued"); + }); + + it("fetches status via log provider", async () => { + const { methods } = setup({ provider: "log" }); + const handler = methods.get("voicecall.status"); + const respond = vi.fn(); + await handler({ params: { sid: "log-1" }, respond }); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.status).toBe("mock"); + }); + + it("calls Twilio start endpoint", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + sid: "CA123", + status: "queued", + to: "+1555", + from: "+1444", + }), + }); + // @ts-expect-error partial global + global.fetch = fetch; + const { methods } = setup({ + provider: "twilio", + twilio: { + accountSid: "AC123", + authToken: "tok", + from: "+1444", + statusCallbackUrl: "https://callback.test/status", + }, + }); + const handler = methods.get("voicecall.start"); + const respond = vi.fn(); + await handler({ params: { to: "+1555", message: "Hello" }, respond }); + expect(fetch).toHaveBeenCalledTimes(1); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.sid).toBe("CA123"); + expect(payload.provider).toBe("twilio"); + }); + + it("fetches Twilio status", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "in-progress", to: "+1" }), + }); + // @ts-expect-error partial global + global.fetch = fetch; + const { methods } = setup({ + provider: "twilio", + twilio: { accountSid: "AC123", authToken: "tok", from: "+1444" }, + }); + const handler = methods.get("voicecall.status"); + const respond = vi.fn(); + await handler({ params: { sid: "CA123" }, respond }); + expect(fetch).toHaveBeenCalled(); + const [ok, payload] = respond.mock.calls[0]; + expect(ok).toBe(true); + expect(payload.status).toBe("in-progress"); + }); +});