@clawdbot/voice-call 0.1.0
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 +20 -0
- package/README.md +107 -0
- package/index.ts +477 -0
- package/package.json +14 -0
- package/src/cli.ts +297 -0
- package/src/config.ts +355 -0
- package/src/core-bridge.ts +190 -0
- package/src/manager.ts +846 -0
- package/src/media-stream.ts +279 -0
- package/src/providers/base.ts +67 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/mock.ts +168 -0
- package/src/providers/stt-openai-realtime.ts +303 -0
- package/src/providers/telnyx.ts +364 -0
- package/src/providers/tts-openai.ts +264 -0
- package/src/providers/twilio.ts +537 -0
- package/src/response-generator.ts +171 -0
- package/src/runtime.ts +194 -0
- package/src/tunnel.ts +330 -0
- package/src/types.ts +272 -0
- package/src/utils.ts +12 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook-security.ts +197 -0
- package/src/webhook.ts +480 -0
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { VoiceCallConfig } from "./config.js";
|
|
6
|
+
import type { CoreConfig } from "./core-bridge.js";
|
|
7
|
+
import type { CallManager } from "./manager.js";
|
|
8
|
+
import type { MediaStreamConfig } from "./media-stream.js";
|
|
9
|
+
import { MediaStreamHandler } from "./media-stream.js";
|
|
10
|
+
import type { VoiceCallProvider } from "./providers/base.js";
|
|
11
|
+
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
|
12
|
+
import type { TwilioProvider } from "./providers/twilio.js";
|
|
13
|
+
import type { NormalizedEvent, WebhookContext } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* HTTP server for receiving voice call webhooks from providers.
|
|
17
|
+
* Supports WebSocket upgrades for media streams when streaming is enabled.
|
|
18
|
+
*/
|
|
19
|
+
export class VoiceCallWebhookServer {
|
|
20
|
+
private server: http.Server | null = null;
|
|
21
|
+
private config: VoiceCallConfig;
|
|
22
|
+
private manager: CallManager;
|
|
23
|
+
private provider: VoiceCallProvider;
|
|
24
|
+
private coreConfig: CoreConfig | null;
|
|
25
|
+
|
|
26
|
+
/** Media stream handler for bidirectional audio (when streaming enabled) */
|
|
27
|
+
private mediaStreamHandler: MediaStreamHandler | null = null;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
config: VoiceCallConfig,
|
|
31
|
+
manager: CallManager,
|
|
32
|
+
provider: VoiceCallProvider,
|
|
33
|
+
coreConfig?: CoreConfig,
|
|
34
|
+
) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.manager = manager;
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
this.coreConfig = coreConfig ?? null;
|
|
39
|
+
|
|
40
|
+
// Initialize media stream handler if streaming is enabled
|
|
41
|
+
if (config.streaming?.enabled) {
|
|
42
|
+
this.initializeMediaStreaming();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the media stream handler (for wiring to provider).
|
|
48
|
+
*/
|
|
49
|
+
getMediaStreamHandler(): MediaStreamHandler | null {
|
|
50
|
+
return this.mediaStreamHandler;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Initialize media streaming with OpenAI Realtime STT.
|
|
55
|
+
*/
|
|
56
|
+
private initializeMediaStreaming(): void {
|
|
57
|
+
const apiKey =
|
|
58
|
+
this.config.streaming?.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
59
|
+
|
|
60
|
+
if (!apiKey) {
|
|
61
|
+
console.warn(
|
|
62
|
+
"[voice-call] Streaming enabled but no OpenAI API key found",
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sttProvider = new OpenAIRealtimeSTTProvider({
|
|
68
|
+
apiKey,
|
|
69
|
+
model: this.config.streaming?.sttModel,
|
|
70
|
+
silenceDurationMs: this.config.streaming?.silenceDurationMs,
|
|
71
|
+
vadThreshold: this.config.streaming?.vadThreshold,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const streamConfig: MediaStreamConfig = {
|
|
75
|
+
sttProvider,
|
|
76
|
+
onTranscript: (providerCallId, transcript) => {
|
|
77
|
+
console.log(
|
|
78
|
+
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Look up our internal call ID from the provider call ID
|
|
82
|
+
const call = this.manager.getCallByProviderCallId(providerCallId);
|
|
83
|
+
if (!call) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`[voice-call] No active call found for provider ID: ${providerCallId}`,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create a speech event and process it through the manager
|
|
91
|
+
const event: NormalizedEvent = {
|
|
92
|
+
id: `stream-transcript-${Date.now()}`,
|
|
93
|
+
type: "call.speech",
|
|
94
|
+
callId: call.callId,
|
|
95
|
+
providerCallId,
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
transcript,
|
|
98
|
+
isFinal: true,
|
|
99
|
+
};
|
|
100
|
+
this.manager.processEvent(event);
|
|
101
|
+
|
|
102
|
+
// Auto-respond in conversation mode (inbound always, outbound if mode is conversation)
|
|
103
|
+
const callMode = call.metadata?.mode as string | undefined;
|
|
104
|
+
const shouldRespond =
|
|
105
|
+
call.direction === "inbound" || callMode === "conversation";
|
|
106
|
+
if (shouldRespond) {
|
|
107
|
+
this.handleInboundResponse(call.callId, transcript).catch((err) => {
|
|
108
|
+
console.warn(`[voice-call] Failed to auto-respond:`, err);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
onPartialTranscript: (callId, partial) => {
|
|
113
|
+
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
|
114
|
+
},
|
|
115
|
+
onConnect: (callId, streamSid) => {
|
|
116
|
+
console.log(
|
|
117
|
+
`[voice-call] Media stream connected: ${callId} -> ${streamSid}`,
|
|
118
|
+
);
|
|
119
|
+
// Register stream with provider for TTS routing
|
|
120
|
+
if (this.provider.name === "twilio") {
|
|
121
|
+
(this.provider as TwilioProvider).registerCallStream(
|
|
122
|
+
callId,
|
|
123
|
+
streamSid,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Speak initial message if one was provided when call was initiated
|
|
128
|
+
// Use setTimeout to allow stream setup to complete
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
this.manager.speakInitialMessage(callId).catch((err) => {
|
|
131
|
+
console.warn(`[voice-call] Failed to speak initial message:`, err);
|
|
132
|
+
});
|
|
133
|
+
}, 500);
|
|
134
|
+
},
|
|
135
|
+
onDisconnect: (callId) => {
|
|
136
|
+
console.log(`[voice-call] Media stream disconnected: ${callId}`);
|
|
137
|
+
if (this.provider.name === "twilio") {
|
|
138
|
+
(this.provider as TwilioProvider).unregisterCallStream(callId);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
|
|
144
|
+
console.log("[voice-call] Media streaming initialized");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Start the webhook server.
|
|
149
|
+
*/
|
|
150
|
+
async start(): Promise<string> {
|
|
151
|
+
const { port, bind, path: webhookPath } = this.config.serve;
|
|
152
|
+
const streamPath = this.config.streaming?.streamPath || "/voice/stream";
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
this.server = http.createServer((req, res) => {
|
|
156
|
+
this.handleRequest(req, res, webhookPath).catch((err) => {
|
|
157
|
+
console.error("[voice-call] Webhook error:", err);
|
|
158
|
+
res.statusCode = 500;
|
|
159
|
+
res.end("Internal Server Error");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Handle WebSocket upgrades for media streams
|
|
164
|
+
if (this.mediaStreamHandler) {
|
|
165
|
+
this.server.on("upgrade", (request, socket, head) => {
|
|
166
|
+
const url = new URL(
|
|
167
|
+
request.url || "/",
|
|
168
|
+
`http://${request.headers.host}`,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (url.pathname === streamPath) {
|
|
172
|
+
console.log("[voice-call] WebSocket upgrade for media stream");
|
|
173
|
+
this.mediaStreamHandler?.handleUpgrade(request, socket, head);
|
|
174
|
+
} else {
|
|
175
|
+
socket.destroy();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.server.on("error", reject);
|
|
181
|
+
|
|
182
|
+
this.server.listen(port, bind, () => {
|
|
183
|
+
const url = `http://${bind}:${port}${webhookPath}`;
|
|
184
|
+
console.log(`[voice-call] Webhook server listening on ${url}`);
|
|
185
|
+
if (this.mediaStreamHandler) {
|
|
186
|
+
console.log(
|
|
187
|
+
`[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
resolve(url);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Stop the webhook server.
|
|
197
|
+
*/
|
|
198
|
+
async stop(): Promise<void> {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
if (this.server) {
|
|
201
|
+
this.server.close(() => {
|
|
202
|
+
this.server = null;
|
|
203
|
+
resolve();
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
resolve();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Handle incoming HTTP request.
|
|
213
|
+
*/
|
|
214
|
+
private async handleRequest(
|
|
215
|
+
req: http.IncomingMessage,
|
|
216
|
+
res: http.ServerResponse,
|
|
217
|
+
webhookPath: string,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
220
|
+
|
|
221
|
+
// Check path
|
|
222
|
+
if (!url.pathname.startsWith(webhookPath)) {
|
|
223
|
+
res.statusCode = 404;
|
|
224
|
+
res.end("Not Found");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Only accept POST
|
|
229
|
+
if (req.method !== "POST") {
|
|
230
|
+
res.statusCode = 405;
|
|
231
|
+
res.end("Method Not Allowed");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Read body
|
|
236
|
+
const body = await this.readBody(req);
|
|
237
|
+
|
|
238
|
+
// Build webhook context
|
|
239
|
+
const ctx: WebhookContext = {
|
|
240
|
+
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
241
|
+
rawBody: body,
|
|
242
|
+
url: `http://${req.headers.host}${req.url}`,
|
|
243
|
+
method: "POST",
|
|
244
|
+
query: Object.fromEntries(url.searchParams),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Verify signature
|
|
248
|
+
const verification = this.provider.verifyWebhook(ctx);
|
|
249
|
+
if (!verification.ok) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`[voice-call] Webhook verification failed: ${verification.reason}`,
|
|
252
|
+
);
|
|
253
|
+
res.statusCode = 401;
|
|
254
|
+
res.end("Unauthorized");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Parse events
|
|
259
|
+
const result = this.provider.parseWebhookEvent(ctx);
|
|
260
|
+
|
|
261
|
+
// Process each event
|
|
262
|
+
for (const event of result.events) {
|
|
263
|
+
try {
|
|
264
|
+
this.manager.processEvent(event);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(
|
|
267
|
+
`[voice-call] Error processing event ${event.type}:`,
|
|
268
|
+
err,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Send response
|
|
274
|
+
res.statusCode = result.statusCode || 200;
|
|
275
|
+
|
|
276
|
+
if (result.providerResponseHeaders) {
|
|
277
|
+
for (const [key, value] of Object.entries(
|
|
278
|
+
result.providerResponseHeaders,
|
|
279
|
+
)) {
|
|
280
|
+
res.setHeader(key, value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
res.end(result.providerResponseBody || "OK");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read request body as string.
|
|
289
|
+
*/
|
|
290
|
+
private readBody(req: http.IncomingMessage): Promise<string> {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const chunks: Buffer[] = [];
|
|
293
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
294
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
295
|
+
req.on("error", reject);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Handle auto-response for inbound calls using the agent system.
|
|
301
|
+
* Supports tool calling for richer voice interactions.
|
|
302
|
+
*/
|
|
303
|
+
private async handleInboundResponse(
|
|
304
|
+
callId: string,
|
|
305
|
+
userMessage: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
console.log(
|
|
308
|
+
`[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Get call context for conversation history
|
|
312
|
+
const call = this.manager.getCall(callId);
|
|
313
|
+
if (!call) {
|
|
314
|
+
console.warn(`[voice-call] Call ${callId} not found for auto-response`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!this.coreConfig) {
|
|
319
|
+
console.warn("[voice-call] Core config missing; skipping auto-response");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const { generateVoiceResponse } = await import("./response-generator.js");
|
|
325
|
+
|
|
326
|
+
const result = await generateVoiceResponse({
|
|
327
|
+
voiceConfig: this.config,
|
|
328
|
+
coreConfig: this.coreConfig,
|
|
329
|
+
callId,
|
|
330
|
+
from: call.from,
|
|
331
|
+
transcript: call.transcript,
|
|
332
|
+
userMessage,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (result.error) {
|
|
336
|
+
console.error(
|
|
337
|
+
`[voice-call] Response generation error: ${result.error}`,
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (result.text) {
|
|
343
|
+
console.log(`[voice-call] AI response: "${result.text}"`);
|
|
344
|
+
await this.manager.speak(callId, result.text);
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.error(`[voice-call] Auto-response error:`, err);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve the current machine's Tailscale DNS name.
|
|
354
|
+
*/
|
|
355
|
+
export type TailscaleSelfInfo = {
|
|
356
|
+
dnsName: string | null;
|
|
357
|
+
nodeId: string | null;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run a tailscale command with timeout, collecting stdout.
|
|
362
|
+
*/
|
|
363
|
+
function runTailscaleCommand(
|
|
364
|
+
args: string[],
|
|
365
|
+
timeoutMs = 2500,
|
|
366
|
+
): Promise<{ code: number; stdout: string }> {
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
const proc = spawn("tailscale", args, {
|
|
369
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
let stdout = "";
|
|
373
|
+
proc.stdout.on("data", (data) => {
|
|
374
|
+
stdout += data;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
proc.kill("SIGKILL");
|
|
379
|
+
resolve({ code: -1, stdout: "" });
|
|
380
|
+
}, timeoutMs);
|
|
381
|
+
|
|
382
|
+
proc.on("close", (code) => {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
resolve({ code: code ?? -1, stdout });
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function getTailscaleSelfInfo(): Promise<TailscaleSelfInfo | null> {
|
|
390
|
+
const { code, stdout } = await runTailscaleCommand(["status", "--json"]);
|
|
391
|
+
if (code !== 0) return null;
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const status = JSON.parse(stdout);
|
|
395
|
+
return {
|
|
396
|
+
dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
|
|
397
|
+
nodeId: status.Self?.ID || null,
|
|
398
|
+
};
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function getTailscaleDnsName(): Promise<string | null> {
|
|
405
|
+
const info = await getTailscaleSelfInfo();
|
|
406
|
+
return info?.dnsName ?? null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function setupTailscaleExposureRoute(opts: {
|
|
410
|
+
mode: "serve" | "funnel";
|
|
411
|
+
path: string;
|
|
412
|
+
localUrl: string;
|
|
413
|
+
}): Promise<string | null> {
|
|
414
|
+
const dnsName = await getTailscaleDnsName();
|
|
415
|
+
if (!dnsName) {
|
|
416
|
+
console.warn("[voice-call] Could not get Tailscale DNS name");
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const { code } = await runTailscaleCommand([
|
|
421
|
+
opts.mode,
|
|
422
|
+
"--bg",
|
|
423
|
+
"--yes",
|
|
424
|
+
"--set-path",
|
|
425
|
+
opts.path,
|
|
426
|
+
opts.localUrl,
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
if (code === 0) {
|
|
430
|
+
const publicUrl = `https://${dnsName}${opts.path}`;
|
|
431
|
+
console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
|
|
432
|
+
return publicUrl;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function cleanupTailscaleExposureRoute(opts: {
|
|
440
|
+
mode: "serve" | "funnel";
|
|
441
|
+
path: string;
|
|
442
|
+
}): Promise<void> {
|
|
443
|
+
await runTailscaleCommand([opts.mode, "off", opts.path]);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Setup Tailscale serve/funnel for the webhook server.
|
|
448
|
+
* This is a helper that shells out to `tailscale serve` or `tailscale funnel`.
|
|
449
|
+
*/
|
|
450
|
+
export async function setupTailscaleExposure(
|
|
451
|
+
config: VoiceCallConfig,
|
|
452
|
+
): Promise<string | null> {
|
|
453
|
+
if (config.tailscale.mode === "off") {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
458
|
+
// Include the path suffix so tailscale forwards to the correct endpoint
|
|
459
|
+
// (tailscale strips the mount path prefix when proxying)
|
|
460
|
+
const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
|
|
461
|
+
return setupTailscaleExposureRoute({
|
|
462
|
+
mode,
|
|
463
|
+
path: config.tailscale.path,
|
|
464
|
+
localUrl,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Cleanup Tailscale serve/funnel.
|
|
470
|
+
*/
|
|
471
|
+
export async function cleanupTailscaleExposure(
|
|
472
|
+
config: VoiceCallConfig,
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
if (config.tailscale.mode === "off") {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
|
|
479
|
+
await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path });
|
|
480
|
+
}
|