feat: implement voice-call plugin
This commit is contained in:
@@ -173,13 +173,15 @@ it’s present in your workspace/managed skills locations.
|
|||||||
|
|
||||||
## Example plugin: Voice Call
|
## 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`
|
- Source: `extensions/voice-call`
|
||||||
- Skill: `skills/voice-call`
|
- Skill: `skills/voice-call`
|
||||||
- CLI: `clawdbot voicecall status`
|
- CLI: `clawdbot voicecall start|status`
|
||||||
- Tool: `voice_call`
|
- 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.
|
See `extensions/voice-call/README.md` for setup and usage.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Voice Call Plugin (Placeholder)
|
# Voice Call Plugin
|
||||||
|
|
||||||
This is a **stub** plugin used to validate the Clawdbot plugin API.
|
Twilio-backed outbound voice calls (with a log-only fallback for dev).
|
||||||
It does not place real calls yet.
|
|
||||||
|
|
||||||
## Install (local dev)
|
## Install (local dev)
|
||||||
|
|
||||||
@@ -19,20 +18,41 @@ Option 2: add via config:
|
|||||||
{
|
{
|
||||||
plugins: {
|
plugins: {
|
||||||
load: { paths: ["/absolute/path/to/extensions/voice-call"] },
|
load: { paths: ["/absolute/path/to/extensions/voice-call"] },
|
||||||
entries: {
|
entries: { "voice-call": { enabled: true } }
|
||||||
"voice-call": { enabled: true, config: { provider: "twilio" } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart the Gateway after changes.
|
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 <Say>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev fallback (no network):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{ provider: "log" }
|
||||||
|
```
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot voicecall status
|
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
|
||||||
clawdbot voicecall start --to "+15555550123" --message "Hello"
|
clawdbot voicecall status --sid CAxxxxxxxx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tool
|
## Tool
|
||||||
@@ -40,13 +60,15 @@ clawdbot voicecall start --to "+15555550123" --message "Hello"
|
|||||||
Tool name: `voice_call`
|
Tool name: `voice_call`
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- `mode`: `"call" | "status"`
|
- `mode`: `"call" | "status"` (default: `call`)
|
||||||
- `to`: target string
|
- `to`: target string (required for call)
|
||||||
|
- `sid`: call SID (required for status)
|
||||||
- `message`: optional intro text
|
- `message`: optional intro text
|
||||||
|
|
||||||
## Gateway RPC
|
## Gateway RPC
|
||||||
|
|
||||||
- `voicecall.status`
|
- `voicecall.start` (to, message?)
|
||||||
|
- `voicecall.status` (sid)
|
||||||
|
|
||||||
## Skill
|
## Skill
|
||||||
|
|
||||||
@@ -59,6 +81,5 @@ setting:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This plugin is a placeholder. Implement your real call flow in the tool and
|
- Uses Twilio REST API via fetch (no SDK). Provide valid SID/token/from.
|
||||||
RPC handlers.
|
|
||||||
- Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency.
|
- Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency.
|
||||||
|
|||||||
@@ -1,121 +1,298 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
const voiceCallConfigSchema = {
|
type VoiceCallConfig =
|
||||||
parse(value) {
|
| {
|
||||||
if (value === undefined) return {};
|
provider: "twilio";
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
twilio: {
|
||||||
throw new Error("voice-call config must be an object");
|
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<string, unknown>;
|
||||||
|
const provider =
|
||||||
|
cfg.provider === "twilio" || cfg.provider === "log"
|
||||||
|
? cfg.provider
|
||||||
|
: "log";
|
||||||
|
if (provider === "twilio") {
|
||||||
|
const twilio = cfg.twilio as Record<string, unknown> | 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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
async function startTwilioCall(
|
||||||
|
cfg: Exclude<VoiceCallConfig, { provider?: "log" }>["twilio"],
|
||||||
|
params: VoiceCallStartParams,
|
||||||
|
): Promise<VoiceCallStatus> {
|
||||||
|
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", `<Response><Say>${say}</Say></Response>`);
|
||||||
|
}
|
||||||
|
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<string, unknown>;
|
||||||
|
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<VoiceCallConfig, { provider?: "log" }>["twilio"],
|
||||||
|
sid: string,
|
||||||
|
): Promise<VoiceCallStatus> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<VoiceCallStatus> {
|
||||||
|
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<VoiceCallStatus> {
|
||||||
|
if (cfg.provider === "twilio") {
|
||||||
|
return getTwilioStatus(cfg.twilio, sid);
|
||||||
|
}
|
||||||
|
return { sid, status: "mock", provider: "log" };
|
||||||
|
}
|
||||||
|
|
||||||
const voiceCallPlugin = {
|
const voiceCallPlugin = {
|
||||||
id: "voice-call",
|
id: "voice-call",
|
||||||
name: "Voice Call",
|
name: "Voice Call",
|
||||||
description: "Voice-call plugin stub (placeholder)",
|
description: "Voice-call plugin with Twilio/log providers",
|
||||||
configSchema: voiceCallConfigSchema,
|
configSchema: voiceCallConfigSchema,
|
||||||
register(api) {
|
register(api) {
|
||||||
api.registerGatewayMethod("voicecall.status", ({ respond }) => {
|
const cfg = voiceCallConfigSchema.parse(api.pluginConfig);
|
||||||
respond(true, {
|
|
||||||
status: "idle",
|
api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
|
||||||
provider: api.pluginConfig?.provider ?? "unset",
|
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(
|
api.registerGatewayMethod(
|
||||||
{
|
"voicecall.status",
|
||||||
name: "voice_call",
|
async ({ params, respond }) => {
|
||||||
label: "Voice Call",
|
const sid = typeof params?.sid === "string" ? params.sid.trim() : "";
|
||||||
description: "Start or inspect a voice call via the voice-call plugin",
|
if (!sid) {
|
||||||
parameters: Type.Object({
|
respond(false, { error: "sid required" });
|
||||||
mode: Type.Optional(
|
return;
|
||||||
Type.Union([Type.Literal("call"), Type.Literal("status")]),
|
}
|
||||||
),
|
try {
|
||||||
to: Type.Optional(Type.String({ description: "Call target" })),
|
const result = await getStatus(cfg, sid);
|
||||||
message: Type.Optional(
|
respond(true, result);
|
||||||
Type.String({ description: "Optional intro message" }),
|
} catch (err) {
|
||||||
),
|
respond(false, { error: String(err) });
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
if (params.mode === "status") {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: JSON.stringify({ status: "idle" }, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { status: "idle" },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
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 }) => {
|
api.registerTool(
|
||||||
const voicecall = program
|
{
|
||||||
.command("voicecall")
|
name: "voice_call",
|
||||||
.description("Voice call plugin commands");
|
label: "Voice Call",
|
||||||
|
description: "Start or inspect a voice call via the voice-call plugin",
|
||||||
voicecall
|
parameters: Type.Object({
|
||||||
.command("status")
|
mode: Type.Optional(
|
||||||
.description("Show voice-call status")
|
Type.Union([Type.Literal("call"), Type.Literal("status")]),
|
||||||
.action(() => {
|
|
||||||
console.log(JSON.stringify({ status: "idle" }, null, 2));
|
|
||||||
});
|
|
||||||
|
|
||||||
voicecall
|
|
||||||
.command("start")
|
|
||||||
.description("Start a voice call (placeholder)")
|
|
||||||
.option("--to <target>", "Target to call")
|
|
||||||
.option("--message <text>", "Optional intro message")
|
|
||||||
.action((opts) => {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
status: "not_implemented",
|
|
||||||
to: opts.to ?? null,
|
|
||||||
message: opts.message ?? null,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
),
|
||||||
);
|
to: Type.Optional(Type.String({ description: "Call target" })),
|
||||||
});
|
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
||||||
}, { commands: ["voicecall"] });
|
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({
|
api.registerCli(({ program }) => {
|
||||||
id: "voicecall",
|
const voicecall = program
|
||||||
start: () => {
|
.command("voicecall")
|
||||||
api.logger.info("voice-call service ready (placeholder)");
|
.description("Voice call plugin commands");
|
||||||
},
|
|
||||||
stop: () => {
|
voicecall
|
||||||
api.logger.info("voice-call service stopped (placeholder)");
|
.command("start")
|
||||||
},
|
.description("Start a voice call")
|
||||||
});
|
.requiredOption("--to <target>", "Target to call")
|
||||||
|
.option("--message <text>", "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 <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");
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ metadata: {"clawdbot":{"emoji":"📞","skillKey":"voice-call","requires":{"confi
|
|||||||
|
|
||||||
# Voice Call
|
# 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
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot voicecall status
|
clawdbot voicecall start --to "+15555550123" --message "Hello from Clawdbot"
|
||||||
clawdbot voicecall start --to "+15555550123" --message "Hello"
|
clawdbot voicecall status --sid CAxxxxxxxx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tool
|
## Tool
|
||||||
@@ -20,10 +20,13 @@ clawdbot voicecall start --to "+15555550123" --message "Hello"
|
|||||||
Use `voice_call` for agent-initiated calls.
|
Use `voice_call` for agent-initiated calls.
|
||||||
|
|
||||||
Parameters:
|
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
|
- `message` (string, optional): optional intro or instruction
|
||||||
- `mode` ("call" | "status", optional)
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Requires the voice-call plugin to be enabled.
|
- Requires the voice-call plugin to be enabled.
|
||||||
- Plugin config lives under `plugins.entries.voice-call.config`.
|
- 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).
|
||||||
|
|||||||
125
src/plugins/voice-call.plugin.test.ts
Normal file
125
src/plugins/voice-call.plugin.test.ts
Normal file
@@ -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<string, unknown>) => Promise<unknown> | unknown
|
||||||
|
>;
|
||||||
|
tools: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(config: Record<string, unknown>): Registered {
|
||||||
|
const methods = new Map<
|
||||||
|
string,
|
||||||
|
(ctx: Record<string, unknown>) => Promise<unknown> | 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user