voicecc 1.0.7
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/.claude-plugin/plugin.json +6 -0
- package/README.md +48 -0
- package/bin/voicecc.js +39 -0
- package/dashboard/dist/assets/index-BXemFrMp.css +1 -0
- package/dashboard/dist/assets/index-dAYfRls7.js +11 -0
- package/dashboard/dist/audio-processor.js +126 -0
- package/dashboard/dist/index.html +13 -0
- package/dashboard/routes/auth.ts +119 -0
- package/dashboard/routes/browser-call.ts +87 -0
- package/dashboard/routes/claude-md.ts +50 -0
- package/dashboard/routes/conversations.ts +203 -0
- package/dashboard/routes/integrations.ts +154 -0
- package/dashboard/routes/mcp-servers.ts +198 -0
- package/dashboard/routes/settings.ts +64 -0
- package/dashboard/routes/tunnel.ts +66 -0
- package/dashboard/routes/twilio.ts +120 -0
- package/dashboard/routes/voice.ts +48 -0
- package/dashboard/routes/webrtc.ts +85 -0
- package/dashboard/server.ts +130 -0
- package/dashboard/tsconfig.json +13 -0
- package/init/CLAUDE.md +18 -0
- package/package.json +59 -0
- package/run.ts +68 -0
- package/scripts/postinstall.js +228 -0
- package/services/browser-call-manager.ts +106 -0
- package/services/device-pairing.ts +176 -0
- package/services/env.ts +88 -0
- package/services/tunnel.ts +204 -0
- package/services/twilio-manager.ts +126 -0
- package/sidecar/assets/startup.pcm +0 -0
- package/sidecar/audio-adapter.ts +60 -0
- package/sidecar/audio-capture.ts +220 -0
- package/sidecar/browser-audio-playback.test.ts +149 -0
- package/sidecar/browser-audio.ts +147 -0
- package/sidecar/browser-server.ts +331 -0
- package/sidecar/chime.test.ts +69 -0
- package/sidecar/chime.ts +54 -0
- package/sidecar/claude-session.ts +295 -0
- package/sidecar/endpointing.ts +163 -0
- package/sidecar/index.ts +83 -0
- package/sidecar/local-audio.ts +126 -0
- package/sidecar/mic-vpio +0 -0
- package/sidecar/mic-vpio.swift +484 -0
- package/sidecar/mock-tts-server-tagged.mjs +132 -0
- package/sidecar/narration.ts +204 -0
- package/sidecar/scripts/generate-startup-audio.py +79 -0
- package/sidecar/session-lock.ts +123 -0
- package/sidecar/sherpa-onnx-node.d.ts +4 -0
- package/sidecar/stt.ts +199 -0
- package/sidecar/tts-server.py +193 -0
- package/sidecar/tts.ts +481 -0
- package/sidecar/twilio-audio.ts +338 -0
- package/sidecar/twilio-server.ts +436 -0
- package/sidecar/types.ts +210 -0
- package/sidecar/vad.ts +101 -0
- package/sidecar/voice-loop-bugs.test.ts +522 -0
- package/sidecar/voice-session.ts +523 -0
- package/skills/voice/SKILL.md +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser call server process management.
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of the browser-server child process.
|
|
5
|
+
* Analogous to twilio-manager.ts but simpler -- no TwiML app updates,
|
|
6
|
+
* no Twilio SDK dependency.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Spawn browser-server.ts as a child process with DASHBOARD_PORT env var
|
|
10
|
+
* - Stop the server via SIGTERM
|
|
11
|
+
* - Report running status
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn, ChildProcess } from "child_process";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// TYPES
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Browser call server status for the dashboard UI */
|
|
21
|
+
export interface BrowserCallStatus {
|
|
22
|
+
/** Whether the browser-server process is alive */
|
|
23
|
+
running: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// STATE
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Browser server child process handle */
|
|
31
|
+
let browserProcess: ChildProcess | null = null;
|
|
32
|
+
|
|
33
|
+
/** Whether the browser call server is running */
|
|
34
|
+
let browserRunning = false;
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// MAIN HANDLERS
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start the browser call server.
|
|
42
|
+
* Spawns browser-server.ts as a child process with DASHBOARD_PORT env var.
|
|
43
|
+
* Uses TWILIO_PORT from .env (default 8080).
|
|
44
|
+
*
|
|
45
|
+
* @param dashboardPort - The dashboard server port (for proxying)
|
|
46
|
+
* @throws Error if the server is already running
|
|
47
|
+
*/
|
|
48
|
+
export async function startBrowserCallServer(dashboardPort: number): Promise<void> {
|
|
49
|
+
if (browserRunning) {
|
|
50
|
+
throw new Error("Browser call server is already running");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
browserProcess = spawn("npx", ["tsx", "sidecar/browser-server.ts"], {
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
env: { ...process.env, DASHBOARD_PORT: String(dashboardPort) },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
browserProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
60
|
+
process.stdout.write(`[browser-server] ${chunk.toString()}`);
|
|
61
|
+
});
|
|
62
|
+
browserProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
63
|
+
process.stderr.write(`[browser-server] ${chunk.toString()}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
browserProcess.on("exit", (code) => {
|
|
67
|
+
if (browserRunning) {
|
|
68
|
+
console.error(`Browser call server exited unexpectedly (code ${code})`);
|
|
69
|
+
}
|
|
70
|
+
browserRunning = false;
|
|
71
|
+
browserProcess = null;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
browserRunning = true;
|
|
75
|
+
console.log("Browser call server started.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stop the browser call server.
|
|
80
|
+
* Sends SIGTERM to the child process and clears state.
|
|
81
|
+
*/
|
|
82
|
+
export function stopBrowserCallServer(): void {
|
|
83
|
+
if (browserProcess && !browserProcess.killed) {
|
|
84
|
+
browserProcess.kill("SIGTERM");
|
|
85
|
+
}
|
|
86
|
+
browserProcess = null;
|
|
87
|
+
browserRunning = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the status of the browser call server.
|
|
92
|
+
*
|
|
93
|
+
* @returns Status with running state
|
|
94
|
+
*/
|
|
95
|
+
export function getBrowserCallStatus(): BrowserCallStatus {
|
|
96
|
+
return { running: browserRunning };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check whether the browser call server process is currently alive.
|
|
101
|
+
*
|
|
102
|
+
* @returns True if the server is running
|
|
103
|
+
*/
|
|
104
|
+
export function isBrowserCallRunning(): boolean {
|
|
105
|
+
return browserRunning;
|
|
106
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device pairing and token management for WebRTC browser calling.
|
|
3
|
+
*
|
|
4
|
+
* Handles the pairing flow between the dashboard and remote devices:
|
|
5
|
+
* - Generate 6-digit pairing codes with 5-minute TTL
|
|
6
|
+
* - Validate codes and issue persistent device tokens
|
|
7
|
+
* - Persist device tokens to disk across restarts
|
|
8
|
+
* - Purge expired pairing codes automatically
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile } from "fs/promises";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/** Internal pairing code entry */
|
|
20
|
+
interface PairingCode {
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
attempts: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stored device token info */
|
|
26
|
+
interface DeviceTokenInfo {
|
|
27
|
+
pairedAt: number;
|
|
28
|
+
userAgent: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Result of generating a new pairing code */
|
|
32
|
+
export interface PairingResult {
|
|
33
|
+
code: string;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Result of validating a pairing code */
|
|
38
|
+
export interface PairingValidation {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
token?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// CONSTANTS
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
const PAIRING_CODE_TTL_MS = 5 * 60 * 1000;
|
|
49
|
+
const PAIRING_MAX_ATTEMPTS = 5;
|
|
50
|
+
const DEVICE_TOKENS_PATH = join(process.cwd(), ".device-tokens.json");
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// STATE
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/** Active pairing codes: code -> { expiresAt, attempts } */
|
|
57
|
+
const pairingCodes = new Map<string, PairingCode>();
|
|
58
|
+
|
|
59
|
+
/** Paired device tokens: token -> { pairedAt, userAgent } */
|
|
60
|
+
const deviceTokens = new Map<string, DeviceTokenInfo>();
|
|
61
|
+
|
|
62
|
+
// Purge expired pairing codes every 60s
|
|
63
|
+
setInterval(() => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [code, data] of pairingCodes) {
|
|
66
|
+
if (now > data.expiresAt) pairingCodes.delete(code);
|
|
67
|
+
}
|
|
68
|
+
}, 60_000);
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// MAIN HANDLERS
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate a 6-digit pairing code with a 5-minute TTL.
|
|
76
|
+
*
|
|
77
|
+
* @returns The generated code and its expiration timestamp
|
|
78
|
+
*/
|
|
79
|
+
export function generatePairingCode(): PairingResult {
|
|
80
|
+
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
81
|
+
const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
|
|
82
|
+
pairingCodes.set(code, { expiresAt, attempts: 0 });
|
|
83
|
+
return { code, expiresAt };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a pairing code and issue a device token on success.
|
|
88
|
+
* Enforces max attempts and single-use consumption.
|
|
89
|
+
*
|
|
90
|
+
* @param code - The 6-digit pairing code to validate
|
|
91
|
+
* @param userAgent - The device's user-agent string
|
|
92
|
+
* @returns Validation result with token on success or error on failure
|
|
93
|
+
*/
|
|
94
|
+
export function validateAndConsumeCode(code: string, userAgent: string): PairingValidation {
|
|
95
|
+
const entry = pairingCodes.get(code);
|
|
96
|
+
|
|
97
|
+
if (!entry) {
|
|
98
|
+
return { ok: false, error: "Invalid pairing code" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (Date.now() > entry.expiresAt) {
|
|
102
|
+
pairingCodes.delete(code);
|
|
103
|
+
return { ok: false, error: "Pairing code expired" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
entry.attempts++;
|
|
107
|
+
if (entry.attempts > PAIRING_MAX_ATTEMPTS) {
|
|
108
|
+
pairingCodes.delete(code);
|
|
109
|
+
return { ok: false, error: "Too many attempts, code invalidated" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Code is valid -- delete it (single-use) and issue a device token
|
|
113
|
+
pairingCodes.delete(code);
|
|
114
|
+
const token = randomUUID();
|
|
115
|
+
deviceTokens.set(token, { pairedAt: Date.now(), userAgent });
|
|
116
|
+
saveDeviceTokens().catch(() => {});
|
|
117
|
+
|
|
118
|
+
return { ok: true, token };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a device token exists in the store.
|
|
123
|
+
*
|
|
124
|
+
* @param token - The device token to validate
|
|
125
|
+
* @returns True if the token is valid
|
|
126
|
+
*/
|
|
127
|
+
/**
|
|
128
|
+
* Check if a pairing code is still active (not yet consumed or expired).
|
|
129
|
+
*
|
|
130
|
+
* @param code - The 6-digit pairing code
|
|
131
|
+
* @returns True if the code is still waiting to be used
|
|
132
|
+
*/
|
|
133
|
+
export function isPairingCodeActive(code: string): boolean {
|
|
134
|
+
const entry = pairingCodes.get(code);
|
|
135
|
+
if (!entry) return false;
|
|
136
|
+
if (Date.now() > entry.expiresAt) {
|
|
137
|
+
pairingCodes.delete(code);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isValidDeviceToken(token: string): boolean {
|
|
144
|
+
return deviceTokens.has(token);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Load persisted device tokens from disk on startup.
|
|
149
|
+
* Call this before mounting routes.
|
|
150
|
+
*/
|
|
151
|
+
export async function loadDeviceTokens(): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(await readFile(DEVICE_TOKENS_PATH, "utf-8"));
|
|
154
|
+
for (const [token, info] of Object.entries(data)) {
|
|
155
|
+
deviceTokens.set(token, info as DeviceTokenInfo);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// File doesn't exist or is invalid -- start fresh
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// HELPER FUNCTIONS
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Persist device tokens to disk.
|
|
168
|
+
* Called internally by validateAndConsumeCode on success.
|
|
169
|
+
*/
|
|
170
|
+
async function saveDeviceTokens(): Promise<void> {
|
|
171
|
+
const data: Record<string, DeviceTokenInfo> = {};
|
|
172
|
+
for (const [token, info] of deviceTokens) {
|
|
173
|
+
data[token] = info;
|
|
174
|
+
}
|
|
175
|
+
await writeFile(DEVICE_TOKENS_PATH, JSON.stringify(data), "utf-8");
|
|
176
|
+
}
|
package/services/env.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment file (.env) read/write service.
|
|
3
|
+
*
|
|
4
|
+
* Shared utility for all services that need to read or write .env configuration:
|
|
5
|
+
* - Parse raw .env content into key-value records
|
|
6
|
+
* - Read .env from disk with a configurable path
|
|
7
|
+
* - Write a single key or full record back to disk
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, writeFile } from "fs/promises";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// TYPES
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Key-value record representing parsed .env contents */
|
|
18
|
+
export type EnvRecord = Record<string, string>;
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// MAIN HANDLERS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read and parse a .env file from disk.
|
|
26
|
+
* Returns an empty record if the file does not exist.
|
|
27
|
+
*
|
|
28
|
+
* @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
|
|
29
|
+
* @returns Parsed key-value pairs from the .env file
|
|
30
|
+
*/
|
|
31
|
+
export async function readEnv(envPath?: string): Promise<EnvRecord> {
|
|
32
|
+
const filePath = envPath ?? join(process.cwd(), ".env");
|
|
33
|
+
const content = await readFile(filePath, "utf-8").catch(() => "");
|
|
34
|
+
return parseEnvFile(content);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Update a single key in the .env file, preserving all other values.
|
|
39
|
+
* Creates the file if it does not exist.
|
|
40
|
+
*
|
|
41
|
+
* @param key - The env variable name to set
|
|
42
|
+
* @param value - The value to write
|
|
43
|
+
* @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
|
|
44
|
+
*/
|
|
45
|
+
export async function writeEnvKey(key: string, value: string, envPath?: string): Promise<void> {
|
|
46
|
+
const settings = await readEnv(envPath);
|
|
47
|
+
settings[key] = value;
|
|
48
|
+
await writeEnvFile(settings, envPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write a full key-value record to a .env file.
|
|
53
|
+
* Overwrites the entire file contents. Each entry becomes a KEY=VALUE line.
|
|
54
|
+
*
|
|
55
|
+
* @param settings - Key-value pairs to write
|
|
56
|
+
* @param envPath - Absolute path to the .env file. Defaults to process.cwd()/.env
|
|
57
|
+
*/
|
|
58
|
+
export async function writeEnvFile(settings: EnvRecord, envPath?: string): Promise<void> {
|
|
59
|
+
const filePath = envPath ?? join(process.cwd(), ".env");
|
|
60
|
+
const lines = Object.entries(settings).map(([k, v]) => `${k}=${v}`);
|
|
61
|
+
await writeFile(filePath, lines.join("\n") + "\n", "utf-8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// HELPER FUNCTIONS
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a .env file string into a key-value record.
|
|
70
|
+
* Handles lines in the format KEY=VALUE, ignores empty lines and comments.
|
|
71
|
+
* Keeps empty values (KEY= produces { KEY: "" }). Does NOT strip quotes.
|
|
72
|
+
*
|
|
73
|
+
* @param content - Raw .env file content
|
|
74
|
+
* @returns Parsed key-value pairs
|
|
75
|
+
*/
|
|
76
|
+
export function parseEnvFile(content: string): EnvRecord {
|
|
77
|
+
const result: EnvRecord = {};
|
|
78
|
+
for (const line of content.split("\n")) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
81
|
+
const eqIndex = trimmed.indexOf("=");
|
|
82
|
+
if (eqIndex === -1) continue;
|
|
83
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
84
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
85
|
+
result[key] = value;
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare quick tunnel process lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Manages spawning and stopping the cloudflared tunnel process:
|
|
5
|
+
* - Start cloudflared on a given port and parse the public HTTPS URL from stderr
|
|
6
|
+
* - Stop cloudflared and clear state
|
|
7
|
+
* - Check if cloudflared is installed
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, execFile, ChildProcess } from "child_process";
|
|
11
|
+
import { writeEnvKey } from "./env.js";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// CONSTANTS
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/** Timeout for waiting for the tunnel URL to appear in stderr */
|
|
18
|
+
const TUNNEL_URL_TIMEOUT_MS = 30000;
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// STATE
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** cloudflared child process handle */
|
|
25
|
+
let tunnelProcess: ChildProcess | null = null;
|
|
26
|
+
|
|
27
|
+
/** Current public tunnel URL */
|
|
28
|
+
let tunnelUrl: string | null = null;
|
|
29
|
+
|
|
30
|
+
/** Timestamp when tunnel URL was obtained */
|
|
31
|
+
let tunnelStartedAt: number | null = null;
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// MAIN HANDLERS
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Start a Cloudflare quick tunnel on the given port.
|
|
39
|
+
* Parses the public HTTPS URL from cloudflared's stderr output,
|
|
40
|
+
* writes it to .env as TWILIO_WEBHOOK_URL, and returns it.
|
|
41
|
+
*
|
|
42
|
+
* @param port - Local port to tunnel
|
|
43
|
+
* @returns The public HTTPS URL
|
|
44
|
+
*/
|
|
45
|
+
export async function startTunnel(port: number): Promise<string> {
|
|
46
|
+
if (tunnelProcess) {
|
|
47
|
+
throw new Error("Tunnel is already running");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let tunnelStderr = "";
|
|
51
|
+
|
|
52
|
+
tunnelProcess = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`, "--protocol", "http2"], {
|
|
53
|
+
cwd: process.cwd(),
|
|
54
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
tunnelProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
58
|
+
const text = chunk.toString();
|
|
59
|
+
tunnelStderr += text;
|
|
60
|
+
for (const line of text.split("\n")) {
|
|
61
|
+
if (line.trim() && (line.includes("WRN") || line.includes("ERR"))) {
|
|
62
|
+
console.log(`[tunnel] ${line}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
tunnelProcess!.on("error", (err: NodeJS.ErrnoException) => {
|
|
69
|
+
tunnelProcess = null;
|
|
70
|
+
if (err.code === "ENOENT") {
|
|
71
|
+
reject(new Error(
|
|
72
|
+
"cloudflared is not installed. Install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/ or: brew install cloudflared"
|
|
73
|
+
));
|
|
74
|
+
} else {
|
|
75
|
+
reject(new Error(`Failed to start cloudflared: ${err.message}`));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
tunnelProcess!.on("spawn", () => resolve());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
tunnelProcess.on("exit", (code) => {
|
|
82
|
+
console.log(`cloudflared exited (code ${code})`);
|
|
83
|
+
tunnelProcess = null;
|
|
84
|
+
tunnelUrl = null;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Parse the tunnel URL from stderr
|
|
88
|
+
const url = await parseTunnelUrl(() => {
|
|
89
|
+
if (tunnelProcess === null || tunnelProcess.exitCode !== null) {
|
|
90
|
+
return tunnelStderr.trim() || "cloudflared exited immediately. Run 'cloudflared tunnel --url http://localhost:8080' manually to see the error.";
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
tunnelUrl = url;
|
|
96
|
+
tunnelStartedAt = Date.now();
|
|
97
|
+
await writeEnvKey("TWILIO_WEBHOOK_URL", url);
|
|
98
|
+
console.log(`Tunnel URL: ${url}`);
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Stop the tunnel and clear state.
|
|
104
|
+
*/
|
|
105
|
+
export function stopTunnel(): void {
|
|
106
|
+
if (tunnelProcess && !tunnelProcess.killed) {
|
|
107
|
+
tunnelProcess.kill("SIGTERM");
|
|
108
|
+
}
|
|
109
|
+
tunnelProcess = null;
|
|
110
|
+
tunnelUrl = null;
|
|
111
|
+
tunnelStartedAt = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Return the current public tunnel URL, or null if not running.
|
|
116
|
+
*
|
|
117
|
+
* @returns The public HTTPS URL or null
|
|
118
|
+
*/
|
|
119
|
+
export function getTunnelUrl(): string | null {
|
|
120
|
+
return tunnelUrl;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return the timestamp when the tunnel URL was obtained, or null.
|
|
125
|
+
*
|
|
126
|
+
* @returns Unix ms timestamp or null
|
|
127
|
+
*/
|
|
128
|
+
export function getTunnelStartedAt(): number | null {
|
|
129
|
+
return tunnelStartedAt;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check whether the tunnel process is currently alive.
|
|
134
|
+
*
|
|
135
|
+
* @returns True if tunnel is running
|
|
136
|
+
*/
|
|
137
|
+
export function isTunnelRunning(): boolean {
|
|
138
|
+
return tunnelProcess !== null && !tunnelProcess.killed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if cloudflared is installed by running `cloudflared version`.
|
|
143
|
+
*
|
|
144
|
+
* @returns True if cloudflared is installed
|
|
145
|
+
*/
|
|
146
|
+
export async function checkCloudflaredInstalled(): Promise<boolean> {
|
|
147
|
+
return new Promise<boolean>((resolve) => {
|
|
148
|
+
try {
|
|
149
|
+
const child = execFile("cloudflared", ["version"], (err) => resolve(!err));
|
|
150
|
+
child.on("error", () => {}); // Suppress duplicate error event
|
|
151
|
+
} catch {
|
|
152
|
+
resolve(false);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// HELPER FUNCTIONS
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Wait for and parse the tunnel URL from cloudflared's stderr output.
|
|
163
|
+
* cloudflared prints the URL as: https://<random>.trycloudflare.com
|
|
164
|
+
*
|
|
165
|
+
* @param checkEarlyExit - Returns an error message if cloudflared exited, null otherwise
|
|
166
|
+
* @returns The public HTTPS URL
|
|
167
|
+
*/
|
|
168
|
+
async function parseTunnelUrl(checkEarlyExit: () => string | null): Promise<string> {
|
|
169
|
+
const deadline = Date.now() + TUNNEL_URL_TIMEOUT_MS;
|
|
170
|
+
|
|
171
|
+
return new Promise<string>((resolve, reject) => {
|
|
172
|
+
// Listen for the URL in stderr output
|
|
173
|
+
const onData = (chunk: Buffer) => {
|
|
174
|
+
const text = chunk.toString();
|
|
175
|
+
const match = text.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
176
|
+
if (match) {
|
|
177
|
+
tunnelProcess?.stderr?.off("data", onData);
|
|
178
|
+
clearInterval(checkInterval);
|
|
179
|
+
clearTimeout(timeout);
|
|
180
|
+
resolve(match[0]);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
tunnelProcess?.stderr?.on("data", onData);
|
|
185
|
+
|
|
186
|
+
// Periodically check for early exit
|
|
187
|
+
const checkInterval = setInterval(() => {
|
|
188
|
+
const earlyExitError = checkEarlyExit();
|
|
189
|
+
if (earlyExitError) {
|
|
190
|
+
tunnelProcess?.stderr?.off("data", onData);
|
|
191
|
+
clearInterval(checkInterval);
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
reject(new Error(earlyExitError));
|
|
194
|
+
}
|
|
195
|
+
}, 500);
|
|
196
|
+
|
|
197
|
+
// Timeout
|
|
198
|
+
const timeout = setTimeout(() => {
|
|
199
|
+
tunnelProcess?.stderr?.off("data", onData);
|
|
200
|
+
clearInterval(checkInterval);
|
|
201
|
+
reject(new Error("Timed out waiting for tunnel URL"));
|
|
202
|
+
}, TUNNEL_URL_TIMEOUT_MS);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio voice server process management.
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of the twilio-server child process:
|
|
5
|
+
* - Start the server with dashboard port and optional tunnel URL
|
|
6
|
+
* - Stop the server
|
|
7
|
+
* - Report running status
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, ChildProcess } from "child_process";
|
|
11
|
+
import { readEnv } from "./env.js";
|
|
12
|
+
import twilioSdk from "twilio";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// TYPES
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/** Twilio server status for the dashboard UI */
|
|
19
|
+
export interface TwilioStatus {
|
|
20
|
+
running: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// STATE
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Twilio server child process handle */
|
|
28
|
+
let twilioProcess: ChildProcess | null = null;
|
|
29
|
+
|
|
30
|
+
/** Whether the Twilio voice server is running */
|
|
31
|
+
let twilioRunning = false;
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// MAIN HANDLERS
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Start the Twilio voice server.
|
|
39
|
+
* Reads .env for TWILIO_AUTH_TOKEN. If tunnelUrl and TwiML app SID exist,
|
|
40
|
+
* updates the TwiML app voice URL via Twilio SDK.
|
|
41
|
+
* Spawns twilio-server.ts as a child process with DASHBOARD_PORT env var.
|
|
42
|
+
*
|
|
43
|
+
* @param dashboardPort - The dashboard server port (for proxying)
|
|
44
|
+
* @param tunnelUrl - Optional tunnel public URL for webhook configuration
|
|
45
|
+
*/
|
|
46
|
+
export async function startTwilioServer(dashboardPort: number, tunnelUrl?: string): Promise<void> {
|
|
47
|
+
if (twilioRunning) {
|
|
48
|
+
throw new Error("Twilio server is already running");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const envVars = await readEnv();
|
|
52
|
+
|
|
53
|
+
if (!envVars.TWILIO_AUTH_TOKEN) {
|
|
54
|
+
throw new Error("TWILIO_AUTH_TOKEN is not set in .env");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Update TwiML App voice URL if configured
|
|
58
|
+
const twimlAppSid = envVars.TWILIO_TWIML_APP_SID;
|
|
59
|
+
const accountSid = envVars.TWILIO_ACCOUNT_SID;
|
|
60
|
+
if (tunnelUrl && twimlAppSid && accountSid && envVars.TWILIO_AUTH_TOKEN) {
|
|
61
|
+
try {
|
|
62
|
+
const client = twilioSdk(accountSid, envVars.TWILIO_AUTH_TOKEN);
|
|
63
|
+
await client.applications(twimlAppSid).update({
|
|
64
|
+
voiceUrl: `${tunnelUrl}/twilio/incoming-call`,
|
|
65
|
+
voiceMethod: "POST",
|
|
66
|
+
});
|
|
67
|
+
console.log(`Updated TwiML App voice URL to ${tunnelUrl}/twilio/incoming-call`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`Failed to update TwiML App voice URL: ${err}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Start the Twilio server (pass dashboard port so it can proxy non-Twilio requests)
|
|
74
|
+
twilioProcess = spawn("npx", ["tsx", "sidecar/twilio-server.ts"], {
|
|
75
|
+
cwd: process.cwd(),
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
77
|
+
env: { ...process.env, DASHBOARD_PORT: String(dashboardPort) },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
twilioProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
81
|
+
process.stdout.write(`[twilio-server] ${chunk.toString()}`);
|
|
82
|
+
});
|
|
83
|
+
twilioProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
84
|
+
process.stderr.write(`[twilio-server] ${chunk.toString()}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
twilioProcess.on("exit", (code) => {
|
|
88
|
+
if (twilioRunning) {
|
|
89
|
+
console.error(`Twilio server exited unexpectedly (code ${code})`);
|
|
90
|
+
}
|
|
91
|
+
twilioRunning = false;
|
|
92
|
+
twilioProcess = null;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
twilioRunning = true;
|
|
96
|
+
console.log("Twilio server started.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stop the Twilio voice server.
|
|
101
|
+
*/
|
|
102
|
+
export function stopTwilioServer(): void {
|
|
103
|
+
if (twilioProcess && !twilioProcess.killed) {
|
|
104
|
+
twilioProcess.kill("SIGTERM");
|
|
105
|
+
}
|
|
106
|
+
twilioProcess = null;
|
|
107
|
+
twilioRunning = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get the status of the Twilio server.
|
|
112
|
+
*
|
|
113
|
+
* @returns Status with running state
|
|
114
|
+
*/
|
|
115
|
+
export async function getStatus(): Promise<TwilioStatus> {
|
|
116
|
+
return { running: twilioRunning };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check whether the Twilio server process is currently alive.
|
|
121
|
+
*
|
|
122
|
+
* @returns True if the server is running
|
|
123
|
+
*/
|
|
124
|
+
export function isRunning(): boolean {
|
|
125
|
+
return twilioRunning;
|
|
126
|
+
}
|
|
Binary file
|