@openclaw/voice-call 2026.2.13 → 2026.2.15

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.15
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.14
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.13
4
16
 
5
17
  ### Changes
package/README.md CHANGED
@@ -45,6 +45,14 @@ Put under `plugins.entries.voice-call.config`:
45
45
  authToken: "your_token",
46
46
  },
47
47
 
48
+ telnyx: {
49
+ apiKey: "KEYxxxx",
50
+ connectionId: "CONNxxxx",
51
+ // Telnyx webhook public key from the Telnyx Mission Control Portal
52
+ // (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
53
+ publicKey: "...",
54
+ },
55
+
48
56
  plivo: {
49
57
  authId: "MAxxxxxxxxxxxxxxxxxxxx",
50
58
  authToken: "your_token",
@@ -76,6 +84,7 @@ Notes:
76
84
 
77
85
  - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
78
86
  - `mock` is a local dev provider (no network calls).
87
+ - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
79
88
  - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
80
89
 
81
90
  ## TTS for calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.13",
3
+ "version": "2026.2.15",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -47,6 +47,7 @@ describe("validateProviderConfig", () => {
47
47
  delete process.env.TWILIO_AUTH_TOKEN;
48
48
  delete process.env.TELNYX_API_KEY;
49
49
  delete process.env.TELNYX_CONNECTION_ID;
50
+ delete process.env.TELNYX_PUBLIC_KEY;
50
51
  delete process.env.PLIVO_AUTH_ID;
51
52
  delete process.env.PLIVO_AUTH_TOKEN;
52
53
  });
@@ -121,7 +122,7 @@ describe("validateProviderConfig", () => {
121
122
  describe("telnyx provider", () => {
122
123
  it("passes validation when credentials are in config", () => {
123
124
  const config = createBaseConfig("telnyx");
124
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
125
+ config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
125
126
 
126
127
  const result = validateProviderConfig(config);
127
128
 
@@ -132,6 +133,7 @@ describe("validateProviderConfig", () => {
132
133
  it("passes validation when credentials are in environment variables", () => {
133
134
  process.env.TELNYX_API_KEY = "KEY123";
134
135
  process.env.TELNYX_CONNECTION_ID = "CONN456";
136
+ process.env.TELNYX_PUBLIC_KEY = "public-key";
135
137
  let config = createBaseConfig("telnyx");
136
138
  config = resolveVoiceCallConfig(config);
137
139
 
@@ -163,7 +165,7 @@ describe("validateProviderConfig", () => {
163
165
 
164
166
  expect(result.valid).toBe(false);
165
167
  expect(result.errors).toContain(
166
- "plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing",
168
+ "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
167
169
  );
168
170
  });
169
171
 
@@ -181,6 +183,17 @@ describe("validateProviderConfig", () => {
181
183
  expect(result.valid).toBe(true);
182
184
  expect(result.errors).toEqual([]);
183
185
  });
186
+
187
+ it("passes validation when skipSignatureVerification is true (even without public key)", () => {
188
+ const config = createBaseConfig("telnyx");
189
+ config.skipSignatureVerification = true;
190
+ config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
191
+
192
+ const result = validateProviderConfig(config);
193
+
194
+ expect(result.valid).toBe(true);
195
+ expect(result.errors).toEqual([]);
196
+ });
184
197
  });
185
198
 
186
199
  describe("plivo provider", () => {
package/src/config.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ TtsAutoSchema,
3
+ TtsConfigSchema,
4
+ TtsModeSchema,
5
+ TtsProviderSchema,
6
+ } from "openclaw/plugin-sdk";
1
7
  import { z } from "zod";
2
8
 
3
9
  // -----------------------------------------------------------------------------
@@ -77,81 +83,7 @@ export const SttConfigSchema = z
77
83
  .default({ provider: "openai", model: "whisper-1" });
78
84
  export type SttConfig = z.infer<typeof SttConfigSchema>;
79
85
 
80
- export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
81
- export const TtsModeSchema = z.enum(["final", "all"]);
82
- export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
83
-
84
- export const TtsConfigSchema = z
85
- .object({
86
- auto: TtsAutoSchema.optional(),
87
- enabled: z.boolean().optional(),
88
- mode: TtsModeSchema.optional(),
89
- provider: TtsProviderSchema.optional(),
90
- summaryModel: z.string().optional(),
91
- modelOverrides: z
92
- .object({
93
- enabled: z.boolean().optional(),
94
- allowText: z.boolean().optional(),
95
- allowProvider: z.boolean().optional(),
96
- allowVoice: z.boolean().optional(),
97
- allowModelId: z.boolean().optional(),
98
- allowVoiceSettings: z.boolean().optional(),
99
- allowNormalization: z.boolean().optional(),
100
- allowSeed: z.boolean().optional(),
101
- })
102
- .strict()
103
- .optional(),
104
- elevenlabs: z
105
- .object({
106
- apiKey: z.string().optional(),
107
- baseUrl: z.string().optional(),
108
- voiceId: z.string().optional(),
109
- modelId: z.string().optional(),
110
- seed: z.number().int().min(0).max(4294967295).optional(),
111
- applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
112
- languageCode: z.string().optional(),
113
- voiceSettings: z
114
- .object({
115
- stability: z.number().min(0).max(1).optional(),
116
- similarityBoost: z.number().min(0).max(1).optional(),
117
- style: z.number().min(0).max(1).optional(),
118
- useSpeakerBoost: z.boolean().optional(),
119
- speed: z.number().min(0.5).max(2).optional(),
120
- })
121
- .strict()
122
- .optional(),
123
- })
124
- .strict()
125
- .optional(),
126
- openai: z
127
- .object({
128
- apiKey: z.string().optional(),
129
- model: z.string().optional(),
130
- voice: z.string().optional(),
131
- })
132
- .strict()
133
- .optional(),
134
- edge: z
135
- .object({
136
- enabled: z.boolean().optional(),
137
- voice: z.string().optional(),
138
- lang: z.string().optional(),
139
- outputFormat: z.string().optional(),
140
- pitch: z.string().optional(),
141
- rate: z.string().optional(),
142
- volume: z.string().optional(),
143
- saveSubtitles: z.boolean().optional(),
144
- proxy: z.string().optional(),
145
- timeoutMs: z.number().int().min(1000).max(120000).optional(),
146
- })
147
- .strict()
148
- .optional(),
149
- prefsPath: z.string().optional(),
150
- maxTextLength: z.number().int().min(1).optional(),
151
- timeoutMs: z.number().int().min(1000).max(120000).optional(),
152
- })
153
- .strict()
154
- .optional();
86
+ export { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema };
155
87
  export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
156
88
 
157
89
  // -----------------------------------------------------------------------------
@@ -207,8 +139,10 @@ export const VoiceCallTunnelConfigSchema = z
207
139
  ngrokDomain: z.string().min(1).optional(),
208
140
  /**
209
141
  * Allow ngrok free tier compatibility mode.
210
- * When true, signature verification failures on ngrok-free.app URLs
211
- * will be allowed only for loopback requests (ngrok local agent).
142
+ * When true, forwarded headers may be trusted for loopback requests
143
+ * to reconstruct the public ngrok URL used for signing.
144
+ *
145
+ * IMPORTANT: This does NOT bypass signature verification.
212
146
  */
213
147
  allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
214
148
  })
@@ -483,12 +417,9 @@ export function validateProviderConfig(config: VoiceCallConfig): {
483
417
  "plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
484
418
  );
485
419
  }
486
- if (
487
- (config.inboundPolicy === "allowlist" || config.inboundPolicy === "pairing") &&
488
- !config.telnyx?.publicKey
489
- ) {
420
+ if (!config.skipSignatureVerification && !config.telnyx?.publicKey) {
490
421
  errors.push(
491
- "plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing",
422
+ "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
492
423
  );
493
424
  }
494
425
  }
@@ -8,18 +8,32 @@ export type TranscriptWaiter = {
8
8
  timeout: NodeJS.Timeout;
9
9
  };
10
10
 
11
- export type CallManagerContext = {
11
+ export type CallManagerRuntimeState = {
12
12
  activeCalls: Map<CallId, CallRecord>;
13
13
  providerCallIdMap: Map<string, CallId>;
14
14
  processedEventIds: Set<string>;
15
15
  /** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
16
16
  rejectedProviderCallIds: Set<string>;
17
- /** Optional runtime hook invoked after an event transitions a call into answered state. */
18
- onCallAnswered?: (call: CallRecord) => void;
17
+ };
18
+
19
+ export type CallManagerRuntimeDeps = {
19
20
  provider: VoiceCallProvider | null;
20
21
  config: VoiceCallConfig;
21
22
  storePath: string;
22
23
  webhookUrl: string | null;
24
+ };
25
+
26
+ export type CallManagerTransientState = {
23
27
  transcriptWaiters: Map<CallId, TranscriptWaiter>;
24
28
  maxDurationTimers: Map<CallId, NodeJS.Timeout>;
25
29
  };
30
+
31
+ export type CallManagerHooks = {
32
+ /** Optional runtime hook invoked after an event transitions a call into answered state. */
33
+ onCallAnswered?: (call: CallRecord) => void;
34
+ };
35
+
36
+ export type CallManagerContext = CallManagerRuntimeState &
37
+ CallManagerRuntimeDeps &
38
+ CallManagerTransientState &
39
+ CallManagerHooks;
@@ -13,10 +13,21 @@ import {
13
13
  startMaxDurationTimer,
14
14
  } from "./timers.js";
15
15
 
16
- function shouldAcceptInbound(
17
- config: CallManagerContext["config"],
18
- from: string | undefined,
19
- ): boolean {
16
+ type EventContext = Pick<
17
+ CallManagerContext,
18
+ | "activeCalls"
19
+ | "providerCallIdMap"
20
+ | "processedEventIds"
21
+ | "rejectedProviderCallIds"
22
+ | "provider"
23
+ | "config"
24
+ | "storePath"
25
+ | "transcriptWaiters"
26
+ | "maxDurationTimers"
27
+ | "onCallAnswered"
28
+ >;
29
+
30
+ function shouldAcceptInbound(config: EventContext["config"], from: string | undefined): boolean {
20
31
  const { inboundPolicy: policy, allowFrom } = config;
21
32
 
22
33
  switch (policy) {
@@ -49,7 +60,7 @@ function shouldAcceptInbound(
49
60
  }
50
61
 
51
62
  function createInboundCall(params: {
52
- ctx: CallManagerContext;
63
+ ctx: EventContext;
53
64
  providerCallId: string;
54
65
  from: string;
55
66
  to: string;
@@ -80,7 +91,7 @@ function createInboundCall(params: {
80
91
  return callRecord;
81
92
  }
82
93
 
83
- export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): void {
94
+ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
84
95
  if (ctx.processedEventIds.has(event.id)) {
85
96
  return;
86
97
  }
@@ -19,8 +19,39 @@ import {
19
19
  } from "./timers.js";
20
20
  import { generateNotifyTwiml } from "./twiml.js";
21
21
 
22
+ type InitiateContext = Pick<
23
+ CallManagerContext,
24
+ "activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath" | "webhookUrl"
25
+ >;
26
+
27
+ type SpeakContext = Pick<
28
+ CallManagerContext,
29
+ "activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath"
30
+ >;
31
+
32
+ type ConversationContext = Pick<
33
+ CallManagerContext,
34
+ | "activeCalls"
35
+ | "providerCallIdMap"
36
+ | "provider"
37
+ | "config"
38
+ | "storePath"
39
+ | "transcriptWaiters"
40
+ | "maxDurationTimers"
41
+ >;
42
+
43
+ type EndCallContext = Pick<
44
+ CallManagerContext,
45
+ | "activeCalls"
46
+ | "providerCallIdMap"
47
+ | "provider"
48
+ | "storePath"
49
+ | "transcriptWaiters"
50
+ | "maxDurationTimers"
51
+ >;
52
+
22
53
  export async function initiateCall(
23
- ctx: CallManagerContext,
54
+ ctx: InitiateContext,
24
55
  to: string,
25
56
  sessionKey?: string,
26
57
  options?: OutboundCallOptions | string,
@@ -113,7 +144,7 @@ export async function initiateCall(
113
144
  }
114
145
 
115
146
  export async function speak(
116
- ctx: CallManagerContext,
147
+ ctx: SpeakContext,
117
148
  callId: CallId,
118
149
  text: string,
119
150
  ): Promise<{ success: boolean; error?: string }> {
@@ -149,7 +180,7 @@ export async function speak(
149
180
  }
150
181
 
151
182
  export async function speakInitialMessage(
152
- ctx: CallManagerContext,
183
+ ctx: ConversationContext,
153
184
  providerCallId: string,
154
185
  ): Promise<void> {
155
186
  const call = getCallByProviderCallId({
@@ -197,7 +228,7 @@ export async function speakInitialMessage(
197
228
  }
198
229
 
199
230
  export async function continueCall(
200
- ctx: CallManagerContext,
231
+ ctx: ConversationContext,
201
232
  callId: CallId,
202
233
  prompt: string,
203
234
  ): Promise<{ success: boolean; transcript?: string; error?: string }> {
@@ -234,7 +265,7 @@ export async function continueCall(
234
265
  }
235
266
 
236
267
  export async function endCall(
237
- ctx: CallManagerContext,
268
+ ctx: EndCallContext,
238
269
  callId: CallId,
239
270
  ): Promise<{ success: boolean; error?: string }> {
240
271
  const call = ctx.activeCalls.get(callId);
@@ -2,7 +2,20 @@ import type { CallManagerContext } from "./context.js";
2
2
  import { TerminalStates, type CallId } from "../types.js";
3
3
  import { persistCallRecord } from "./store.js";
4
4
 
5
- export function clearMaxDurationTimer(ctx: CallManagerContext, callId: CallId): void {
5
+ type TimerContext = Pick<
6
+ CallManagerContext,
7
+ "activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
8
+ >;
9
+ type MaxDurationTimerContext = Pick<
10
+ TimerContext,
11
+ "activeCalls" | "maxDurationTimers" | "config" | "storePath"
12
+ >;
13
+ type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
14
+
15
+ export function clearMaxDurationTimer(
16
+ ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
17
+ callId: CallId,
18
+ ): void {
6
19
  const timer = ctx.maxDurationTimers.get(callId);
7
20
  if (timer) {
8
21
  clearTimeout(timer);
@@ -11,7 +24,7 @@ export function clearMaxDurationTimer(ctx: CallManagerContext, callId: CallId):
11
24
  }
12
25
 
13
26
  export function startMaxDurationTimer(params: {
14
- ctx: CallManagerContext;
27
+ ctx: MaxDurationTimerContext;
15
28
  callId: CallId;
16
29
  onTimeout: (callId: CallId) => Promise<void>;
17
30
  }): void {
@@ -38,7 +51,7 @@ export function startMaxDurationTimer(params: {
38
51
  params.ctx.maxDurationTimers.set(params.callId, timer);
39
52
  }
40
53
 
41
- export function clearTranscriptWaiter(ctx: CallManagerContext, callId: CallId): void {
54
+ export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
42
55
  const waiter = ctx.transcriptWaiters.get(callId);
43
56
  if (!waiter) {
44
57
  return;
@@ -48,7 +61,7 @@ export function clearTranscriptWaiter(ctx: CallManagerContext, callId: CallId):
48
61
  }
49
62
 
50
63
  export function rejectTranscriptWaiter(
51
- ctx: CallManagerContext,
64
+ ctx: TranscriptWaiterContext,
52
65
  callId: CallId,
53
66
  reason: string,
54
67
  ): void {
@@ -61,7 +74,7 @@ export function rejectTranscriptWaiter(
61
74
  }
62
75
 
63
76
  export function resolveTranscriptWaiter(
64
- ctx: CallManagerContext,
77
+ ctx: TranscriptWaiterContext,
65
78
  callId: CallId,
66
79
  transcript: string,
67
80
  ): void {
@@ -73,7 +86,7 @@ export function resolveTranscriptWaiter(
73
86
  waiter.resolve(transcript);
74
87
  }
75
88
 
76
- export function waitForFinalTranscript(ctx: CallManagerContext, callId: CallId): Promise<string> {
89
+ export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise<string> {
77
90
  // Only allow one in-flight waiter per call.
78
91
  rejectTranscriptWaiter(ctx, callId, "Transcript waiter replaced");
79
92