@openclaw/voice-call 2026.2.15 → 2026.2.19

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/src/webhook.ts CHANGED
@@ -10,11 +10,11 @@ import type { VoiceCallConfig } from "./config.js";
10
10
  import type { CoreConfig } from "./core-bridge.js";
11
11
  import type { CallManager } from "./manager.js";
12
12
  import type { MediaStreamConfig } from "./media-stream.js";
13
+ import { MediaStreamHandler } from "./media-stream.js";
13
14
  import type { VoiceCallProvider } from "./providers/base.js";
15
+ import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
14
16
  import type { TwilioProvider } from "./providers/twilio.js";
15
17
  import type { NormalizedEvent, WebhookContext } from "./types.js";
16
- import { MediaStreamHandler } from "./media-stream.js";
17
- import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
18
18
 
19
19
  const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
20
20
 
@@ -28,6 +28,7 @@ export class VoiceCallWebhookServer {
28
28
  private manager: CallManager;
29
29
  private provider: VoiceCallProvider;
30
30
  private coreConfig: CoreConfig | null;
31
+ private staleCallReaperInterval: ReturnType<typeof setInterval> | null = null;
31
32
 
32
33
  /** Media stream handler for bidirectional audio (when streaming enabled) */
33
34
  private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -151,6 +152,17 @@ export class VoiceCallWebhookServer {
151
152
  },
152
153
  onDisconnect: (callId) => {
153
154
  console.log(`[voice-call] Media stream disconnected: ${callId}`);
155
+ // Auto-end call when media stream disconnects to prevent stuck calls.
156
+ // Without this, calls can remain active indefinitely after the stream closes.
157
+ const disconnectedCall = this.manager.getCallByProviderCallId(callId);
158
+ if (disconnectedCall) {
159
+ console.log(
160
+ `[voice-call] Auto-ending call ${disconnectedCall.callId} on stream disconnect`,
161
+ );
162
+ void this.manager.endCall(disconnectedCall.callId).catch((err) => {
163
+ console.warn(`[voice-call] Failed to auto-end call ${disconnectedCall.callId}:`, err);
164
+ });
165
+ }
154
166
  if (this.provider.name === "twilio") {
155
167
  (this.provider as TwilioProvider).unregisterCallStream(callId);
156
168
  }
@@ -200,14 +212,51 @@ export class VoiceCallWebhookServer {
200
212
  console.log(`[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`);
201
213
  }
202
214
  resolve(url);
215
+
216
+ // Start the stale call reaper if configured
217
+ this.startStaleCallReaper();
203
218
  });
204
219
  });
205
220
  }
206
221
 
222
+ /**
223
+ * Start a periodic reaper that ends calls older than the configured threshold.
224
+ * Catches calls stuck in unexpected states (e.g., notify-mode calls that never
225
+ * receive a terminal webhook from the provider).
226
+ */
227
+ private startStaleCallReaper(): void {
228
+ const maxAgeSeconds = this.config.staleCallReaperSeconds;
229
+ if (!maxAgeSeconds || maxAgeSeconds <= 0) {
230
+ return;
231
+ }
232
+
233
+ const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds
234
+ const maxAgeMs = maxAgeSeconds * 1000;
235
+
236
+ this.staleCallReaperInterval = setInterval(() => {
237
+ const now = Date.now();
238
+ for (const call of this.manager.getActiveCalls()) {
239
+ const age = now - call.startedAt;
240
+ if (age > maxAgeMs) {
241
+ console.log(
242
+ `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
243
+ );
244
+ void this.manager.endCall(call.callId).catch((err) => {
245
+ console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
246
+ });
247
+ }
248
+ }
249
+ }, CHECK_INTERVAL_MS);
250
+ }
251
+
207
252
  /**
208
253
  * Stop the webhook server.
209
254
  */
210
255
  async stop(): Promise<void> {
256
+ if (this.staleCallReaperInterval) {
257
+ clearInterval(this.staleCallReaperInterval);
258
+ this.staleCallReaperInterval = null;
259
+ }
211
260
  return new Promise((resolve) => {
212
261
  if (this.server) {
213
262
  this.server.close(() => {