@openclaw/voice-call 2026.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/README.md +135 -0
- package/index.ts +497 -0
- package/openclaw.plugin.json +601 -0
- package/package.json +16 -0
- package/src/cli.ts +312 -0
- package/src/config.test.ts +204 -0
- package/src/config.ts +502 -0
- package/src/core-bridge.ts +198 -0
- package/src/manager/context.ts +21 -0
- package/src/manager/events.ts +177 -0
- package/src/manager/lookup.ts +33 -0
- package/src/manager/outbound.ts +248 -0
- package/src/manager/state.ts +50 -0
- package/src/manager/store.ts +88 -0
- package/src/manager/timers.ts +86 -0
- package/src/manager/twiml.ts +9 -0
- package/src/manager.test.ts +108 -0
- package/src/manager.ts +888 -0
- package/src/media-stream.test.ts +97 -0
- package/src/media-stream.ts +393 -0
- package/src/providers/base.ts +67 -0
- package/src/providers/index.ts +10 -0
- package/src/providers/mock.ts +168 -0
- package/src/providers/plivo.test.ts +28 -0
- package/src/providers/plivo.ts +504 -0
- package/src/providers/stt-openai-realtime.ts +311 -0
- package/src/providers/telnyx.ts +364 -0
- package/src/providers/tts-openai.ts +264 -0
- package/src/providers/twilio/api.ts +45 -0
- package/src/providers/twilio/webhook.ts +30 -0
- package/src/providers/twilio.test.ts +64 -0
- package/src/providers/twilio.ts +595 -0
- package/src/response-generator.ts +171 -0
- package/src/runtime.ts +217 -0
- package/src/telephony-audio.ts +88 -0
- package/src/telephony-tts.ts +95 -0
- package/src/tunnel.ts +331 -0
- package/src/types.ts +273 -0
- package/src/utils.ts +12 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook-security.test.ts +260 -0
- package/src/webhook-security.ts +469 -0
- package/src/webhook.ts +491 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
EndReason,
|
|
5
|
+
HangupCallInput,
|
|
6
|
+
InitiateCallInput,
|
|
7
|
+
InitiateCallResult,
|
|
8
|
+
NormalizedEvent,
|
|
9
|
+
PlayTtsInput,
|
|
10
|
+
ProviderWebhookParseResult,
|
|
11
|
+
StartListeningInput,
|
|
12
|
+
StopListeningInput,
|
|
13
|
+
WebhookContext,
|
|
14
|
+
WebhookVerificationResult,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
import type { VoiceCallProvider } from "./base.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mock voice call provider for local testing.
|
|
20
|
+
*
|
|
21
|
+
* Events are driven via webhook POST with JSON body:
|
|
22
|
+
* - { events: NormalizedEvent[] } for bulk events
|
|
23
|
+
* - { event: NormalizedEvent } for single event
|
|
24
|
+
*/
|
|
25
|
+
export class MockProvider implements VoiceCallProvider {
|
|
26
|
+
readonly name = "mock" as const;
|
|
27
|
+
|
|
28
|
+
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
|
33
|
+
try {
|
|
34
|
+
const payload = JSON.parse(ctx.rawBody);
|
|
35
|
+
const events: NormalizedEvent[] = [];
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(payload.events)) {
|
|
38
|
+
for (const evt of payload.events) {
|
|
39
|
+
const normalized = this.normalizeEvent(evt);
|
|
40
|
+
if (normalized) events.push(normalized);
|
|
41
|
+
}
|
|
42
|
+
} else if (payload.event) {
|
|
43
|
+
const normalized = this.normalizeEvent(payload.event);
|
|
44
|
+
if (normalized) events.push(normalized);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { events, statusCode: 200 };
|
|
48
|
+
} catch {
|
|
49
|
+
return { events: [], statusCode: 400 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private normalizeEvent(
|
|
54
|
+
evt: Partial<NormalizedEvent>,
|
|
55
|
+
): NormalizedEvent | null {
|
|
56
|
+
if (!evt.type || !evt.callId) return null;
|
|
57
|
+
|
|
58
|
+
const base = {
|
|
59
|
+
id: evt.id || crypto.randomUUID(),
|
|
60
|
+
callId: evt.callId,
|
|
61
|
+
providerCallId: evt.providerCallId,
|
|
62
|
+
timestamp: evt.timestamp || Date.now(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
switch (evt.type) {
|
|
66
|
+
case "call.initiated":
|
|
67
|
+
case "call.ringing":
|
|
68
|
+
case "call.answered":
|
|
69
|
+
case "call.active":
|
|
70
|
+
return { ...base, type: evt.type };
|
|
71
|
+
|
|
72
|
+
case "call.speaking": {
|
|
73
|
+
const payload = evt as Partial<NormalizedEvent & { text?: string }>;
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
type: evt.type,
|
|
77
|
+
text: payload.text || "",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case "call.speech": {
|
|
82
|
+
const payload = evt as Partial<
|
|
83
|
+
NormalizedEvent & {
|
|
84
|
+
transcript?: string;
|
|
85
|
+
isFinal?: boolean;
|
|
86
|
+
confidence?: number;
|
|
87
|
+
}
|
|
88
|
+
>;
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
type: evt.type,
|
|
92
|
+
transcript: payload.transcript || "",
|
|
93
|
+
isFinal: payload.isFinal ?? true,
|
|
94
|
+
confidence: payload.confidence,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case "call.silence": {
|
|
99
|
+
const payload = evt as Partial<
|
|
100
|
+
NormalizedEvent & { durationMs?: number }
|
|
101
|
+
>;
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
type: evt.type,
|
|
105
|
+
durationMs: payload.durationMs || 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case "call.dtmf": {
|
|
110
|
+
const payload = evt as Partial<NormalizedEvent & { digits?: string }>;
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
type: evt.type,
|
|
114
|
+
digits: payload.digits || "",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "call.ended": {
|
|
119
|
+
const payload = evt as Partial<
|
|
120
|
+
NormalizedEvent & { reason?: EndReason }
|
|
121
|
+
>;
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
type: evt.type,
|
|
125
|
+
reason: payload.reason || "completed",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "call.error": {
|
|
130
|
+
const payload = evt as Partial<
|
|
131
|
+
NormalizedEvent & { error?: string; retryable?: boolean }
|
|
132
|
+
>;
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
type: evt.type,
|
|
136
|
+
error: payload.error || "unknown error",
|
|
137
|
+
retryable: payload.retryable,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
147
|
+
return {
|
|
148
|
+
providerCallId: `mock-${input.callId}`,
|
|
149
|
+
status: "initiated",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async hangupCall(_input: HangupCallInput): Promise<void> {
|
|
154
|
+
// No-op for mock
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async playTts(_input: PlayTtsInput): Promise<void> {
|
|
158
|
+
// No-op for mock
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async startListening(_input: StartListeningInput): Promise<void> {
|
|
162
|
+
// No-op for mock
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async stopListening(_input: StopListeningInput): Promise<void> {
|
|
166
|
+
// No-op for mock
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PlivoProvider } from "./plivo.js";
|
|
4
|
+
|
|
5
|
+
describe("PlivoProvider", () => {
|
|
6
|
+
it("parses answer callback into call.answered and returns keep-alive XML", () => {
|
|
7
|
+
const provider = new PlivoProvider({
|
|
8
|
+
authId: "MA000000000000000000",
|
|
9
|
+
authToken: "test-token",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const result = provider.parseWebhookEvent({
|
|
13
|
+
headers: { host: "example.com" },
|
|
14
|
+
rawBody:
|
|
15
|
+
"CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
|
|
16
|
+
url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
|
|
17
|
+
method: "POST",
|
|
18
|
+
query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result.events).toHaveLength(1);
|
|
22
|
+
expect(result.events[0]?.type).toBe("call.answered");
|
|
23
|
+
expect(result.events[0]?.callId).toBe("internal-call-id");
|
|
24
|
+
expect(result.events[0]?.providerCallId).toBe("call-uuid");
|
|
25
|
+
expect(result.providerResponseBody).toContain("<Wait");
|
|
26
|
+
expect(result.providerResponseBody).toContain('length="300"');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { PlivoConfig } from "../config.js";
|
|
4
|
+
import type {
|
|
5
|
+
HangupCallInput,
|
|
6
|
+
InitiateCallInput,
|
|
7
|
+
InitiateCallResult,
|
|
8
|
+
NormalizedEvent,
|
|
9
|
+
PlayTtsInput,
|
|
10
|
+
ProviderWebhookParseResult,
|
|
11
|
+
StartListeningInput,
|
|
12
|
+
StopListeningInput,
|
|
13
|
+
WebhookContext,
|
|
14
|
+
WebhookVerificationResult,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
import { escapeXml } from "../voice-mapping.js";
|
|
17
|
+
import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
|
|
18
|
+
import type { VoiceCallProvider } from "./base.js";
|
|
19
|
+
|
|
20
|
+
export interface PlivoProviderOptions {
|
|
21
|
+
/** Override public URL origin for signature verification */
|
|
22
|
+
publicUrl?: string;
|
|
23
|
+
/** Skip webhook signature verification (development only) */
|
|
24
|
+
skipVerification?: boolean;
|
|
25
|
+
/** Outbound ring timeout in seconds */
|
|
26
|
+
ringTimeoutSec?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PendingSpeak = { text: string; locale?: string };
|
|
30
|
+
type PendingListen = { language?: string };
|
|
31
|
+
|
|
32
|
+
export class PlivoProvider implements VoiceCallProvider {
|
|
33
|
+
readonly name = "plivo" as const;
|
|
34
|
+
|
|
35
|
+
private readonly authId: string;
|
|
36
|
+
private readonly authToken: string;
|
|
37
|
+
private readonly baseUrl: string;
|
|
38
|
+
private readonly options: PlivoProviderOptions;
|
|
39
|
+
|
|
40
|
+
// Best-effort mapping between create-call request UUID and call UUID.
|
|
41
|
+
private requestUuidToCallUuid = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
// Used for transfer URLs and GetInput action URLs.
|
|
44
|
+
private callIdToWebhookUrl = new Map<string, string>();
|
|
45
|
+
private callUuidToWebhookUrl = new Map<string, string>();
|
|
46
|
+
|
|
47
|
+
private pendingSpeakByCallId = new Map<string, PendingSpeak>();
|
|
48
|
+
private pendingListenByCallId = new Map<string, PendingListen>();
|
|
49
|
+
|
|
50
|
+
constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) {
|
|
51
|
+
if (!config.authId) {
|
|
52
|
+
throw new Error("Plivo Auth ID is required");
|
|
53
|
+
}
|
|
54
|
+
if (!config.authToken) {
|
|
55
|
+
throw new Error("Plivo Auth Token is required");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.authId = config.authId;
|
|
59
|
+
this.authToken = config.authToken;
|
|
60
|
+
this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
|
|
61
|
+
this.options = options;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async apiRequest<T = unknown>(params: {
|
|
65
|
+
method: "GET" | "POST" | "DELETE";
|
|
66
|
+
endpoint: string;
|
|
67
|
+
body?: Record<string, unknown>;
|
|
68
|
+
allowNotFound?: boolean;
|
|
69
|
+
}): Promise<T> {
|
|
70
|
+
const { method, endpoint, body, allowNotFound } = params;
|
|
71
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
72
|
+
method,
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
if (allowNotFound && response.status === 404) {
|
|
82
|
+
return undefined as T;
|
|
83
|
+
}
|
|
84
|
+
const errorText = await response.text();
|
|
85
|
+
throw new Error(`Plivo API error: ${response.status} ${errorText}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const text = await response.text();
|
|
89
|
+
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
93
|
+
const result = verifyPlivoWebhook(ctx, this.authToken, {
|
|
94
|
+
publicUrl: this.options.publicUrl,
|
|
95
|
+
skipVerification: this.options.skipVerification,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: result.ok, reason: result.reason };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
|
106
|
+
const flow =
|
|
107
|
+
typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
108
|
+
|
|
109
|
+
const parsed = this.parseBody(ctx.rawBody);
|
|
110
|
+
if (!parsed) {
|
|
111
|
+
return { events: [], statusCode: 400 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Keep providerCallId mapping for later call control.
|
|
115
|
+
const callUuid = parsed.get("CallUUID") || undefined;
|
|
116
|
+
if (callUuid) {
|
|
117
|
+
const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
|
|
118
|
+
if (webhookBase) {
|
|
119
|
+
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Special flows that exist only to return Plivo XML (no events).
|
|
124
|
+
if (flow === "xml-speak") {
|
|
125
|
+
const callId = this.getCallIdFromQuery(ctx);
|
|
126
|
+
const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
|
|
127
|
+
if (callId) this.pendingSpeakByCallId.delete(callId);
|
|
128
|
+
|
|
129
|
+
const xml = pending
|
|
130
|
+
? PlivoProvider.xmlSpeak(pending.text, pending.locale)
|
|
131
|
+
: PlivoProvider.xmlKeepAlive();
|
|
132
|
+
return {
|
|
133
|
+
events: [],
|
|
134
|
+
providerResponseBody: xml,
|
|
135
|
+
providerResponseHeaders: { "Content-Type": "text/xml" },
|
|
136
|
+
statusCode: 200,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (flow === "xml-listen") {
|
|
141
|
+
const callId = this.getCallIdFromQuery(ctx);
|
|
142
|
+
const pending = callId
|
|
143
|
+
? this.pendingListenByCallId.get(callId)
|
|
144
|
+
: undefined;
|
|
145
|
+
if (callId) this.pendingListenByCallId.delete(callId);
|
|
146
|
+
|
|
147
|
+
const actionUrl = this.buildActionUrl(ctx, {
|
|
148
|
+
flow: "getinput",
|
|
149
|
+
callId,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const xml =
|
|
153
|
+
actionUrl && callId
|
|
154
|
+
? PlivoProvider.xmlGetInputSpeech({
|
|
155
|
+
actionUrl,
|
|
156
|
+
language: pending?.language,
|
|
157
|
+
})
|
|
158
|
+
: PlivoProvider.xmlKeepAlive();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
events: [],
|
|
162
|
+
providerResponseBody: xml,
|
|
163
|
+
providerResponseHeaders: { "Content-Type": "text/xml" },
|
|
164
|
+
statusCode: 200,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Normal events.
|
|
169
|
+
const callIdFromQuery = this.getCallIdFromQuery(ctx);
|
|
170
|
+
const event = this.normalizeEvent(parsed, callIdFromQuery);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
events: event ? [event] : [],
|
|
174
|
+
providerResponseBody:
|
|
175
|
+
flow === "answer" || flow === "getinput"
|
|
176
|
+
? PlivoProvider.xmlKeepAlive()
|
|
177
|
+
: PlivoProvider.xmlEmpty(),
|
|
178
|
+
providerResponseHeaders: { "Content-Type": "text/xml" },
|
|
179
|
+
statusCode: 200,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private normalizeEvent(
|
|
184
|
+
params: URLSearchParams,
|
|
185
|
+
callIdOverride?: string,
|
|
186
|
+
): NormalizedEvent | null {
|
|
187
|
+
const callUuid = params.get("CallUUID") || "";
|
|
188
|
+
const requestUuid = params.get("RequestUUID") || "";
|
|
189
|
+
|
|
190
|
+
if (requestUuid && callUuid) {
|
|
191
|
+
this.requestUuidToCallUuid.set(requestUuid, callUuid);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const direction = params.get("Direction");
|
|
195
|
+
const from = params.get("From") || undefined;
|
|
196
|
+
const to = params.get("To") || undefined;
|
|
197
|
+
const callStatus = params.get("CallStatus");
|
|
198
|
+
|
|
199
|
+
const baseEvent = {
|
|
200
|
+
id: crypto.randomUUID(),
|
|
201
|
+
callId: callIdOverride || callUuid || requestUuid,
|
|
202
|
+
providerCallId: callUuid || requestUuid || undefined,
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
direction:
|
|
205
|
+
direction === "inbound"
|
|
206
|
+
? ("inbound" as const)
|
|
207
|
+
: direction === "outbound"
|
|
208
|
+
? ("outbound" as const)
|
|
209
|
+
: undefined,
|
|
210
|
+
from,
|
|
211
|
+
to,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const digits = params.get("Digits");
|
|
215
|
+
if (digits) {
|
|
216
|
+
return { ...baseEvent, type: "call.dtmf", digits };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const transcript = PlivoProvider.extractTranscript(params);
|
|
220
|
+
if (transcript) {
|
|
221
|
+
return {
|
|
222
|
+
...baseEvent,
|
|
223
|
+
type: "call.speech",
|
|
224
|
+
transcript,
|
|
225
|
+
isFinal: true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Call lifecycle.
|
|
230
|
+
if (callStatus === "ringing") {
|
|
231
|
+
return { ...baseEvent, type: "call.ringing" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (callStatus === "in-progress") {
|
|
235
|
+
return { ...baseEvent, type: "call.answered" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (
|
|
239
|
+
callStatus === "completed" ||
|
|
240
|
+
callStatus === "busy" ||
|
|
241
|
+
callStatus === "no-answer" ||
|
|
242
|
+
callStatus === "failed"
|
|
243
|
+
) {
|
|
244
|
+
return {
|
|
245
|
+
...baseEvent,
|
|
246
|
+
type: "call.ended",
|
|
247
|
+
reason:
|
|
248
|
+
callStatus === "completed"
|
|
249
|
+
? "completed"
|
|
250
|
+
: callStatus === "busy"
|
|
251
|
+
? "busy"
|
|
252
|
+
: callStatus === "no-answer"
|
|
253
|
+
? "no-answer"
|
|
254
|
+
: "failed",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Plivo will call our answer_url when the call is answered; if we don't have
|
|
259
|
+
// a CallStatus for some reason, treat it as answered so the call can proceed.
|
|
260
|
+
if (params.get("Event") === "StartApp" && callUuid) {
|
|
261
|
+
return { ...baseEvent, type: "call.answered" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
268
|
+
const webhookUrl = new URL(input.webhookUrl);
|
|
269
|
+
webhookUrl.searchParams.set("provider", "plivo");
|
|
270
|
+
webhookUrl.searchParams.set("callId", input.callId);
|
|
271
|
+
|
|
272
|
+
const answerUrl = new URL(webhookUrl);
|
|
273
|
+
answerUrl.searchParams.set("flow", "answer");
|
|
274
|
+
|
|
275
|
+
const hangupUrl = new URL(webhookUrl);
|
|
276
|
+
hangupUrl.searchParams.set("flow", "hangup");
|
|
277
|
+
|
|
278
|
+
this.callIdToWebhookUrl.set(input.callId, input.webhookUrl);
|
|
279
|
+
|
|
280
|
+
const ringTimeoutSec = this.options.ringTimeoutSec ?? 30;
|
|
281
|
+
|
|
282
|
+
const result = await this.apiRequest<PlivoCreateCallResponse>({
|
|
283
|
+
method: "POST",
|
|
284
|
+
endpoint: "/Call/",
|
|
285
|
+
body: {
|
|
286
|
+
from: PlivoProvider.normalizeNumber(input.from),
|
|
287
|
+
to: PlivoProvider.normalizeNumber(input.to),
|
|
288
|
+
answer_url: answerUrl.toString(),
|
|
289
|
+
answer_method: "POST",
|
|
290
|
+
hangup_url: hangupUrl.toString(),
|
|
291
|
+
hangup_method: "POST",
|
|
292
|
+
// Plivo's API uses `hangup_on_ring` for outbound ring timeout.
|
|
293
|
+
hangup_on_ring: ringTimeoutSec,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const requestUuid = Array.isArray(result.request_uuid)
|
|
298
|
+
? result.request_uuid[0]
|
|
299
|
+
: result.request_uuid;
|
|
300
|
+
if (!requestUuid) {
|
|
301
|
+
throw new Error("Plivo call create returned no request_uuid");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { providerCallId: requestUuid, status: "initiated" };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
308
|
+
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId);
|
|
309
|
+
if (callUuid) {
|
|
310
|
+
await this.apiRequest({
|
|
311
|
+
method: "DELETE",
|
|
312
|
+
endpoint: `/Call/${callUuid}/`,
|
|
313
|
+
allowNotFound: true,
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Best-effort: try hangup (call UUID), then cancel (request UUID).
|
|
319
|
+
await this.apiRequest({
|
|
320
|
+
method: "DELETE",
|
|
321
|
+
endpoint: `/Call/${input.providerCallId}/`,
|
|
322
|
+
allowNotFound: true,
|
|
323
|
+
});
|
|
324
|
+
await this.apiRequest({
|
|
325
|
+
method: "DELETE",
|
|
326
|
+
endpoint: `/Request/${input.providerCallId}/`,
|
|
327
|
+
allowNotFound: true,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async playTts(input: PlayTtsInput): Promise<void> {
|
|
332
|
+
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
|
|
333
|
+
input.providerCallId;
|
|
334
|
+
const webhookBase =
|
|
335
|
+
this.callUuidToWebhookUrl.get(callUuid) ||
|
|
336
|
+
this.callIdToWebhookUrl.get(input.callId);
|
|
337
|
+
if (!webhookBase) {
|
|
338
|
+
throw new Error("Missing webhook URL for this call (provider state missing)");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!callUuid) {
|
|
342
|
+
throw new Error("Missing Plivo CallUUID for playTts");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const transferUrl = new URL(webhookBase);
|
|
346
|
+
transferUrl.searchParams.set("provider", "plivo");
|
|
347
|
+
transferUrl.searchParams.set("flow", "xml-speak");
|
|
348
|
+
transferUrl.searchParams.set("callId", input.callId);
|
|
349
|
+
|
|
350
|
+
this.pendingSpeakByCallId.set(input.callId, {
|
|
351
|
+
text: input.text,
|
|
352
|
+
locale: input.locale,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await this.apiRequest({
|
|
356
|
+
method: "POST",
|
|
357
|
+
endpoint: `/Call/${callUuid}/`,
|
|
358
|
+
body: {
|
|
359
|
+
legs: "aleg",
|
|
360
|
+
aleg_url: transferUrl.toString(),
|
|
361
|
+
aleg_method: "POST",
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async startListening(input: StartListeningInput): Promise<void> {
|
|
367
|
+
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
|
|
368
|
+
input.providerCallId;
|
|
369
|
+
const webhookBase =
|
|
370
|
+
this.callUuidToWebhookUrl.get(callUuid) ||
|
|
371
|
+
this.callIdToWebhookUrl.get(input.callId);
|
|
372
|
+
if (!webhookBase) {
|
|
373
|
+
throw new Error("Missing webhook URL for this call (provider state missing)");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!callUuid) {
|
|
377
|
+
throw new Error("Missing Plivo CallUUID for startListening");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const transferUrl = new URL(webhookBase);
|
|
381
|
+
transferUrl.searchParams.set("provider", "plivo");
|
|
382
|
+
transferUrl.searchParams.set("flow", "xml-listen");
|
|
383
|
+
transferUrl.searchParams.set("callId", input.callId);
|
|
384
|
+
|
|
385
|
+
this.pendingListenByCallId.set(input.callId, {
|
|
386
|
+
language: input.language,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await this.apiRequest({
|
|
390
|
+
method: "POST",
|
|
391
|
+
endpoint: `/Call/${callUuid}/`,
|
|
392
|
+
body: {
|
|
393
|
+
legs: "aleg",
|
|
394
|
+
aleg_url: transferUrl.toString(),
|
|
395
|
+
aleg_method: "POST",
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async stopListening(_input: StopListeningInput): Promise<void> {
|
|
401
|
+
// GetInput ends automatically when speech ends.
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private static normalizeNumber(numberOrSip: string): string {
|
|
405
|
+
const trimmed = numberOrSip.trim();
|
|
406
|
+
if (trimmed.toLowerCase().startsWith("sip:")) return trimmed;
|
|
407
|
+
return trimmed.replace(/[^\d+]/g, "");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private static xmlEmpty(): string {
|
|
411
|
+
return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private static xmlKeepAlive(): string {
|
|
415
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
416
|
+
<Response>
|
|
417
|
+
<Wait length="300" />
|
|
418
|
+
</Response>`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private static xmlSpeak(text: string, locale?: string): string {
|
|
422
|
+
const language = locale || "en-US";
|
|
423
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
424
|
+
<Response>
|
|
425
|
+
<Speak language="${escapeXml(language)}">${escapeXml(text)}</Speak>
|
|
426
|
+
<Wait length="300" />
|
|
427
|
+
</Response>`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private static xmlGetInputSpeech(params: {
|
|
431
|
+
actionUrl: string;
|
|
432
|
+
language?: string;
|
|
433
|
+
}): string {
|
|
434
|
+
const language = params.language || "en-US";
|
|
435
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
436
|
+
<Response>
|
|
437
|
+
<GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false">
|
|
438
|
+
</GetInput>
|
|
439
|
+
<Wait length="300" />
|
|
440
|
+
</Response>`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private getCallIdFromQuery(ctx: WebhookContext): string | undefined {
|
|
444
|
+
const callId =
|
|
445
|
+
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
|
446
|
+
? ctx.query.callId.trim()
|
|
447
|
+
: undefined;
|
|
448
|
+
return callId || undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private buildActionUrl(
|
|
452
|
+
ctx: WebhookContext,
|
|
453
|
+
opts: { flow: string; callId?: string },
|
|
454
|
+
): string | null {
|
|
455
|
+
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
|
|
456
|
+
if (!base) return null;
|
|
457
|
+
|
|
458
|
+
const u = new URL(base);
|
|
459
|
+
u.searchParams.set("provider", "plivo");
|
|
460
|
+
u.searchParams.set("flow", opts.flow);
|
|
461
|
+
if (opts.callId) u.searchParams.set("callId", opts.callId);
|
|
462
|
+
return u.toString();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
|
|
466
|
+
try {
|
|
467
|
+
const u = new URL(reconstructWebhookUrl(ctx));
|
|
468
|
+
return `${u.origin}${u.pathname}`;
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private parseBody(rawBody: string): URLSearchParams | null {
|
|
475
|
+
try {
|
|
476
|
+
return new URLSearchParams(rawBody);
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private static extractTranscript(params: URLSearchParams): string | null {
|
|
483
|
+
const candidates = [
|
|
484
|
+
"Speech",
|
|
485
|
+
"Transcription",
|
|
486
|
+
"TranscriptionText",
|
|
487
|
+
"SpeechResult",
|
|
488
|
+
"RecognizedSpeech",
|
|
489
|
+
"Text",
|
|
490
|
+
] as const;
|
|
491
|
+
|
|
492
|
+
for (const key of candidates) {
|
|
493
|
+
const value = params.get(key);
|
|
494
|
+
if (value && value.trim()) return value.trim();
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
type PlivoCreateCallResponse = {
|
|
501
|
+
api_id?: string;
|
|
502
|
+
message?: string;
|
|
503
|
+
request_uuid?: string | string[];
|
|
504
|
+
};
|