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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration enable/disable API routes.
|
|
3
|
+
*
|
|
4
|
+
* Manages the enabled state of integrations (Twilio, Browser Call).
|
|
5
|
+
* Enabling an integration persists the flag to .env and immediately starts
|
|
6
|
+
* the service (plus tunnel as a dependency). Disabling stops it.
|
|
7
|
+
*
|
|
8
|
+
* - GET / -- returns enabled state for each integration
|
|
9
|
+
* - POST /:name -- sets enabled state and starts/stops the service
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import { readEnv, writeEnvKey } from "../../services/env.js";
|
|
14
|
+
import { startTwilioServer, stopTwilioServer, isRunning as isTwilioRunning } from "../../services/twilio-manager.js";
|
|
15
|
+
import { startBrowserCallServer, stopBrowserCallServer, isBrowserCallRunning } from "../../services/browser-call-manager.js";
|
|
16
|
+
import { startTunnel, stopTunnel, isTunnelRunning, getTunnelUrl } from "../../services/tunnel.js";
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// CONSTANTS
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/** Map of integration names to their .env key */
|
|
23
|
+
const INTEGRATION_ENV_KEYS: Record<string, string> = {
|
|
24
|
+
twilio: "TWILIO_ENABLED",
|
|
25
|
+
"browser-call": "BROWSER_CALL_ENABLED",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// STATE
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/** Dashboard port -- set by server.ts after the Hono server starts */
|
|
33
|
+
let dashboardPort = 0;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set the dashboard port for starting integration servers.
|
|
37
|
+
*
|
|
38
|
+
* @param port - The dashboard server port
|
|
39
|
+
*/
|
|
40
|
+
export function setDashboardPort(port: number): void {
|
|
41
|
+
dashboardPort = port;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// ROUTES
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create Hono route group for integration enable/disable operations.
|
|
50
|
+
*
|
|
51
|
+
* @returns Hono instance with GET / and POST /:name routes
|
|
52
|
+
*/
|
|
53
|
+
export function integrationsRoutes(): Hono {
|
|
54
|
+
const app = new Hono();
|
|
55
|
+
|
|
56
|
+
/** Get enabled state for all integrations */
|
|
57
|
+
app.get("/", async (c) => {
|
|
58
|
+
const envVars = await readEnv();
|
|
59
|
+
return c.json({
|
|
60
|
+
twilio: { enabled: envVars.TWILIO_ENABLED === "true" },
|
|
61
|
+
browserCall: { enabled: envVars.BROWSER_CALL_ENABLED === "true" },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Set enabled state for a specific integration and start/stop it */
|
|
66
|
+
app.post("/:name", async (c) => {
|
|
67
|
+
const name = c.req.param("name");
|
|
68
|
+
const envKey = INTEGRATION_ENV_KEYS[name];
|
|
69
|
+
|
|
70
|
+
if (!envKey) {
|
|
71
|
+
return c.json({ error: `Unknown integration: ${name}` }, 400);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const body = await c.req.json<{ enabled: boolean }>();
|
|
75
|
+
if (typeof body.enabled !== "boolean") {
|
|
76
|
+
return c.json({ error: "Missing 'enabled' boolean in request body" }, 400);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await writeEnvKey(envKey, String(body.enabled));
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (body.enabled) {
|
|
83
|
+
await startIntegration(name);
|
|
84
|
+
} else {
|
|
85
|
+
stopIntegration(name);
|
|
86
|
+
}
|
|
87
|
+
return c.json({ success: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : "Failed to update integration";
|
|
90
|
+
return c.json({ error: message }, 500);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return app;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// HELPER FUNCTIONS
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Start an integration and its tunnel dependency.
|
|
103
|
+
*
|
|
104
|
+
* @param name - Integration name ("twilio" or "browser-call")
|
|
105
|
+
*/
|
|
106
|
+
async function startIntegration(name: string): Promise<void> {
|
|
107
|
+
const envVars = await readEnv();
|
|
108
|
+
const tunnelPort = parseInt(envVars.TWILIO_PORT || "8080", 10);
|
|
109
|
+
|
|
110
|
+
// Start tunnel if not already running
|
|
111
|
+
if (!isTunnelRunning()) {
|
|
112
|
+
await startTunnel(tunnelPort);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (name === "twilio") {
|
|
116
|
+
if (!envVars.TWILIO_AUTH_TOKEN) {
|
|
117
|
+
throw new Error("TWILIO_AUTH_TOKEN is not configured. Set your Twilio credentials first.");
|
|
118
|
+
}
|
|
119
|
+
if (isBrowserCallRunning()) {
|
|
120
|
+
throw new Error("Browser call server is already running on this port");
|
|
121
|
+
}
|
|
122
|
+
if (!isTwilioRunning()) {
|
|
123
|
+
await startTwilioServer(dashboardPort, getTunnelUrl() ?? undefined);
|
|
124
|
+
}
|
|
125
|
+
} else if (name === "browser-call") {
|
|
126
|
+
if (isTwilioRunning()) {
|
|
127
|
+
throw new Error("Twilio server is already running on this port");
|
|
128
|
+
}
|
|
129
|
+
if (!isBrowserCallRunning()) {
|
|
130
|
+
await startBrowserCallServer(dashboardPort);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stop an integration and tunnel if no other consumer needs it.
|
|
137
|
+
*
|
|
138
|
+
* @param name - Integration name ("twilio" or "browser-call")
|
|
139
|
+
*/
|
|
140
|
+
function stopIntegration(name: string): void {
|
|
141
|
+
if (name === "twilio") {
|
|
142
|
+
stopTwilioServer();
|
|
143
|
+
// Only stop tunnel if browser call is also stopped
|
|
144
|
+
if (!isBrowserCallRunning()) {
|
|
145
|
+
stopTunnel();
|
|
146
|
+
}
|
|
147
|
+
} else if (name === "browser-call") {
|
|
148
|
+
stopBrowserCallServer();
|
|
149
|
+
// Only stop tunnel if Twilio is also stopped
|
|
150
|
+
if (!isTwilioRunning()) {
|
|
151
|
+
stopTunnel();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP servers list API route.
|
|
3
|
+
*
|
|
4
|
+
* Runs `claude mcp list` and parses the output into structured entries:
|
|
5
|
+
* - GET / -- list all configured MCP servers with connection status
|
|
6
|
+
* - POST /add -- add a new MCP server by running `claude mcp add` directly
|
|
7
|
+
* - POST /:name/auth -- open Terminal to guide user through MCP server auth
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Hono } from "hono";
|
|
11
|
+
import { execFile } from "child_process";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/** Parsed MCP server entry */
|
|
20
|
+
interface McpServerEntry {
|
|
21
|
+
name: string;
|
|
22
|
+
url: string;
|
|
23
|
+
type: "http" | "stdio";
|
|
24
|
+
status: "connected" | "failed" | "needs_auth";
|
|
25
|
+
scope: "project" | "user" | "local";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// ROUTES
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create Hono route group for MCP server operations.
|
|
34
|
+
*
|
|
35
|
+
* @returns Hono instance with GET / route
|
|
36
|
+
*/
|
|
37
|
+
export function mcpServersRoutes(): Hono {
|
|
38
|
+
const app = new Hono();
|
|
39
|
+
|
|
40
|
+
/** List MCP servers from Claude CLI, including scope from `mcp get` */
|
|
41
|
+
app.get("/", async (c) => {
|
|
42
|
+
const claudePath = join(homedir(), ".local", "bin", "claude");
|
|
43
|
+
const output = await new Promise<string>((resolve) => {
|
|
44
|
+
execFile(claudePath, ["mcp", "list"], { timeout: 15000 }, (err, stdout, stderr) => {
|
|
45
|
+
if (err) {
|
|
46
|
+
console.error("[mcp-servers] execFile error:", err.message);
|
|
47
|
+
console.error("[mcp-servers] stderr:", stderr);
|
|
48
|
+
resolve(stderr || err.message);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
resolve(stdout);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const servers = parseMcpListOutput(output);
|
|
56
|
+
|
|
57
|
+
// Fetch scope for each server in parallel via `claude mcp get <name>`
|
|
58
|
+
await Promise.all(
|
|
59
|
+
servers.map((server) =>
|
|
60
|
+
new Promise<void>((resolve) => {
|
|
61
|
+
execFile(claudePath, ["mcp", "get", server.name], { timeout: 10000 }, (err, stdout) => {
|
|
62
|
+
if (!err && stdout) {
|
|
63
|
+
server.scope = parseScopeFromGet(stdout);
|
|
64
|
+
}
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return c.json({ servers });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Add a new MCP server by running `claude mcp add` directly */
|
|
75
|
+
app.post("/add", async (c) => {
|
|
76
|
+
const { name, url, transport, scope } = await c.req.json<{
|
|
77
|
+
name: string;
|
|
78
|
+
url: string;
|
|
79
|
+
transport: string;
|
|
80
|
+
scope: string;
|
|
81
|
+
}>();
|
|
82
|
+
|
|
83
|
+
const claudePath = join(homedir(), ".local", "bin", "claude");
|
|
84
|
+
const args = ["mcp", "add", "--transport", transport, "--scope", scope, name, url];
|
|
85
|
+
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
execFile(claudePath, args, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
88
|
+
if (err) {
|
|
89
|
+
console.error("[mcp-servers] add error:", err.message);
|
|
90
|
+
console.error("[mcp-servers] stderr:", stderr);
|
|
91
|
+
resolve(c.json({ error: stderr || err.message }, 500));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolve(c.json({ success: true, output: stdout }));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** Open Claude Code in Terminal to guide user through MCP server auth */
|
|
100
|
+
app.post("/:name/auth", async (c) => {
|
|
101
|
+
const { name } = c.req.param();
|
|
102
|
+
const claudePath = join(homedir(), ".local", "bin", "claude");
|
|
103
|
+
|
|
104
|
+
const prompt = `Please guide the user through authenticating the ${name} MCP server. Tell them to type /mcp. Be to the point, concise, use bullet points and bold.`;
|
|
105
|
+
// Escape double quotes for AppleScript string
|
|
106
|
+
const escapedPrompt = prompt.replace(/"/g, '\\"');
|
|
107
|
+
const script = `tell application "Terminal"
|
|
108
|
+
activate
|
|
109
|
+
do script "${claudePath} \\"${escapedPrompt}\\""
|
|
110
|
+
end tell`;
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
execFile("osascript", ["-e", script], (err) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
resolve(c.json({ error: err.message }, 500));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
resolve(c.json({ success: true }));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Remove an MCP server by running `claude mcp remove` */
|
|
124
|
+
app.delete("/:name", async (c) => {
|
|
125
|
+
const { name } = c.req.param();
|
|
126
|
+
const claudePath = join(homedir(), ".local", "bin", "claude");
|
|
127
|
+
const args = ["mcp", "remove", name];
|
|
128
|
+
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
execFile(claudePath, args, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
console.error("[mcp-servers] remove error:", err.message);
|
|
133
|
+
resolve(c.json({ error: stderr || err.message }, 500));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
resolve(c.json({ success: true, output: stdout }));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return app;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// HELPER FUNCTIONS
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse the text output of `claude mcp list` into structured entries.
|
|
150
|
+
* Each line has the format: `<name>: <url-or-command> - <icon> <status>`
|
|
151
|
+
*
|
|
152
|
+
* @param output - Raw CLI output
|
|
153
|
+
* @returns Array of parsed MCP server entries
|
|
154
|
+
*/
|
|
155
|
+
function parseMcpListOutput(output: string): McpServerEntry[] {
|
|
156
|
+
const servers: McpServerEntry[] = [];
|
|
157
|
+
const lines = output.split("\n");
|
|
158
|
+
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
const match = line.match(/^(\S+): (.+?) - (?:\u2713|\u2717|\u26A0)\s*(.+)$/);
|
|
161
|
+
if (!match) continue;
|
|
162
|
+
|
|
163
|
+
const name = match[1];
|
|
164
|
+
let urlOrCommand = match[2].trim();
|
|
165
|
+
const statusText = match[3].trim().toLowerCase();
|
|
166
|
+
|
|
167
|
+
const isHttp = urlOrCommand.includes("(HTTP)");
|
|
168
|
+
const type: "http" | "stdio" = isHttp ? "http" : "stdio";
|
|
169
|
+
urlOrCommand = urlOrCommand.replace(/\s*\(HTTP\)\s*$/, "").trim();
|
|
170
|
+
|
|
171
|
+
let status: McpServerEntry["status"] = "failed";
|
|
172
|
+
if (statusText.includes("connected")) {
|
|
173
|
+
status = "connected";
|
|
174
|
+
} else if (statusText.includes("auth")) {
|
|
175
|
+
status = "needs_auth";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
servers.push({ name, url: urlOrCommand, type, status, scope: "local" });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return servers;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse the scope from `claude mcp get <name>` output.
|
|
186
|
+
* Looks for "Scope: ..." line and maps to our scope type.
|
|
187
|
+
*
|
|
188
|
+
* @param output - Raw CLI output from `claude mcp get`
|
|
189
|
+
* @returns Parsed scope
|
|
190
|
+
*/
|
|
191
|
+
function parseScopeFromGet(output: string): McpServerEntry["scope"] {
|
|
192
|
+
const match = output.match(/Scope:\s*(.+)/i);
|
|
193
|
+
if (!match) return "local";
|
|
194
|
+
const scopeText = match[1].toLowerCase();
|
|
195
|
+
if (scopeText.includes("user")) return "user";
|
|
196
|
+
if (scopeText.includes("project")) return "project";
|
|
197
|
+
return "local";
|
|
198
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings (.env) API routes.
|
|
3
|
+
*
|
|
4
|
+
* Read and write .env configuration with secret masking:
|
|
5
|
+
* - GET / -- read .env with masked secrets
|
|
6
|
+
* - POST / -- merge incoming key-value pairs into .env
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { readEnv, writeEnvFile } from "../../services/env.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// CONSTANTS
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/** Keys that should be masked when reading settings */
|
|
17
|
+
const MASKED_KEYS = ["TWILIO_AUTH_TOKEN", "TWILIO_API_KEY_SECRET"];
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// ROUTES
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create Hono route group for settings operations.
|
|
25
|
+
*
|
|
26
|
+
* @returns Hono instance with GET / and POST / routes
|
|
27
|
+
*/
|
|
28
|
+
export function settingsRoutes(): Hono {
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
|
|
31
|
+
/** Read .env settings with masked secrets */
|
|
32
|
+
app.get("/", async (c) => {
|
|
33
|
+
const settings = await readEnv();
|
|
34
|
+
|
|
35
|
+
// Mask auth tokens -- show only last 4 chars
|
|
36
|
+
for (const key of MASKED_KEYS) {
|
|
37
|
+
if (settings[key]) {
|
|
38
|
+
const val = settings[key];
|
|
39
|
+
settings[key] = val.length > 4 ? "****" + val.slice(-4) : "****" + val;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return c.json(settings);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Merge incoming settings into .env, preserving masked values */
|
|
47
|
+
app.post("/", async (c) => {
|
|
48
|
+
const incoming = await c.req.json<Record<string, string>>();
|
|
49
|
+
const settings = await readEnv();
|
|
50
|
+
|
|
51
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
52
|
+
// If auth tokens are masked, preserve the existing value
|
|
53
|
+
if (MASKED_KEYS.includes(key) && value.startsWith("****")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
settings[key] = value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await writeEnvFile(settings);
|
|
60
|
+
return c.json({ success: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return app;
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel management API routes.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to the tunnel service for cloudflared lifecycle operations:
|
|
5
|
+
* - GET /check -- is cloudflared installed
|
|
6
|
+
* - GET /status -- running state + public URL
|
|
7
|
+
* - POST /start -- start tunnel
|
|
8
|
+
* - POST /stop -- stop tunnel
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import {
|
|
13
|
+
checkCloudflaredInstalled,
|
|
14
|
+
isTunnelRunning,
|
|
15
|
+
getTunnelUrl,
|
|
16
|
+
getTunnelStartedAt,
|
|
17
|
+
startTunnel,
|
|
18
|
+
stopTunnel,
|
|
19
|
+
} from "../../services/tunnel.js";
|
|
20
|
+
import { readEnv } from "../../services/env.js";
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// ROUTES
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create Hono route group for tunnel operations.
|
|
28
|
+
*
|
|
29
|
+
* @returns Hono instance with check, status, start, stop routes
|
|
30
|
+
*/
|
|
31
|
+
export function tunnelRoutes(): Hono {
|
|
32
|
+
const app = new Hono();
|
|
33
|
+
|
|
34
|
+
/** Check if cloudflared is installed */
|
|
35
|
+
app.get("/check", async (c) => {
|
|
36
|
+
const installed = await checkCloudflaredInstalled();
|
|
37
|
+
return c.json({ installed });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Get tunnel running status and URL */
|
|
41
|
+
app.get("/status", (c) => {
|
|
42
|
+
return c.json({ running: isTunnelRunning(), url: getTunnelUrl(), startedAt: getTunnelStartedAt() });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Start tunnel */
|
|
46
|
+
app.post("/start", async (c) => {
|
|
47
|
+
const envVars = await readEnv();
|
|
48
|
+
const port = parseInt(envVars.TWILIO_PORT || "8080", 10);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const url = await startTunnel(port);
|
|
52
|
+
return c.json({ success: true, url });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : "Failed to start tunnel";
|
|
55
|
+
return c.json({ error: message }, 500);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Stop tunnel */
|
|
60
|
+
app.post("/stop", (c) => {
|
|
61
|
+
stopTunnel();
|
|
62
|
+
return c.json({ success: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return app;
|
|
66
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio PSTN server management API routes.
|
|
3
|
+
*
|
|
4
|
+
* Manages the Twilio voice server lifecycle for PSTN phone calls:
|
|
5
|
+
* - GET /status -- server running state and tunnel URL
|
|
6
|
+
* - POST /start -- start tunnel + twilio server
|
|
7
|
+
* - POST /stop -- stop twilio server + tunnel
|
|
8
|
+
* - GET /phone-numbers -- fetch phone numbers from Twilio API
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { readEnv } from "../../services/env.js";
|
|
13
|
+
import { startTwilioServer, stopTwilioServer, getStatus } from "../../services/twilio-manager.js";
|
|
14
|
+
import { startTunnel, stopTunnel, getTunnelUrl, isTunnelRunning } from "../../services/tunnel.js";
|
|
15
|
+
import { isBrowserCallRunning } from "../../services/browser-call-manager.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// STATE
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/** Dashboard port -- set by server.ts when calling setDashboardPort */
|
|
22
|
+
let dashboardPort = 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set the dashboard port for twilio-server proxying.
|
|
26
|
+
* Called by server.ts after the Hono server starts listening.
|
|
27
|
+
*
|
|
28
|
+
* @param port - The dashboard server port
|
|
29
|
+
*/
|
|
30
|
+
export function setDashboardPort(port: number): void {
|
|
31
|
+
dashboardPort = port;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// ROUTES
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create Hono route group for Twilio operations.
|
|
40
|
+
*
|
|
41
|
+
* @returns Hono instance with status, start, stop, phone-numbers routes
|
|
42
|
+
*/
|
|
43
|
+
export function twilioRoutes(): Hono {
|
|
44
|
+
const app = new Hono();
|
|
45
|
+
|
|
46
|
+
/** Get Twilio server status */
|
|
47
|
+
app.get("/status", async (c) => {
|
|
48
|
+
const status = await getStatus();
|
|
49
|
+
return c.json({ running: status.running, tunnelUrl: getTunnelUrl() });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Start tunnel + Twilio server */
|
|
53
|
+
app.post("/start", async (c) => {
|
|
54
|
+
try {
|
|
55
|
+
// Port conflict check: browser-server uses the same port
|
|
56
|
+
if (isBrowserCallRunning()) {
|
|
57
|
+
return c.json({ error: "Browser call server is already running on this port" }, 409);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const envVars = await readEnv();
|
|
61
|
+
const port = parseInt(envVars.TWILIO_PORT || "8080", 10);
|
|
62
|
+
|
|
63
|
+
if (!isTunnelRunning()) {
|
|
64
|
+
await startTunnel(port);
|
|
65
|
+
}
|
|
66
|
+
const status = await getStatus();
|
|
67
|
+
if (!status.running) {
|
|
68
|
+
await startTwilioServer(dashboardPort, getTunnelUrl() ?? undefined);
|
|
69
|
+
}
|
|
70
|
+
return c.json({ success: true });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : "Failed to start";
|
|
73
|
+
return c.json({ error: message }, 500);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Stop Twilio server. Only stops tunnel if browser call is also stopped. */
|
|
78
|
+
app.post("/stop", (c) => {
|
|
79
|
+
stopTwilioServer();
|
|
80
|
+
if (!isBrowserCallRunning()) {
|
|
81
|
+
stopTunnel();
|
|
82
|
+
}
|
|
83
|
+
return c.json({ success: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Fetch phone numbers from Twilio API */
|
|
87
|
+
app.get("/phone-numbers", async (c) => {
|
|
88
|
+
const envVars = await readEnv();
|
|
89
|
+
const accountSid = envVars.TWILIO_ACCOUNT_SID;
|
|
90
|
+
const authToken = envVars.TWILIO_AUTH_TOKEN;
|
|
91
|
+
|
|
92
|
+
if (!accountSid || !authToken) {
|
|
93
|
+
return c.json({ error: "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set" }, 400);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const apiUrl = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/IncomingPhoneNumbers.json`;
|
|
97
|
+
const auth = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
|
|
98
|
+
|
|
99
|
+
const apiRes = await fetch(apiUrl, {
|
|
100
|
+
headers: { Authorization: `Basic ${auth}` },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!apiRes.ok) {
|
|
104
|
+
const body = await apiRes.text();
|
|
105
|
+
return c.json({ error: `Twilio API error: ${body}` }, apiRes.status as 400);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await apiRes.json();
|
|
109
|
+
const numbers = (data.incoming_phone_numbers ?? []).map(
|
|
110
|
+
(n: { phone_number: string; friendly_name: string }) => ({
|
|
111
|
+
phoneNumber: n.phone_number,
|
|
112
|
+
friendlyName: n.friendly_name,
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return c.json({ numbers });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return app;
|
|
120
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice sidecar launch route.
|
|
3
|
+
*
|
|
4
|
+
* Opens Terminal.app and starts the voice sidecar process:
|
|
5
|
+
* - POST /start -- opens Terminal via osascript
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { execFile } from "child_process";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// CONSTANTS
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
const VOICE_CMD = `cd ${process.cwd()} && npx tsx sidecar/index.ts`;
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// ROUTES
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create Hono route group for voice operations.
|
|
23
|
+
*
|
|
24
|
+
* @returns Hono instance with POST /start route
|
|
25
|
+
*/
|
|
26
|
+
export function voiceRoutes(): Hono {
|
|
27
|
+
const app = new Hono();
|
|
28
|
+
|
|
29
|
+
/** Open Terminal.app and start the voice sidecar */
|
|
30
|
+
app.post("/start", async (c) => {
|
|
31
|
+
const script = `tell application "Terminal"
|
|
32
|
+
activate
|
|
33
|
+
do script "${VOICE_CMD}"
|
|
34
|
+
end tell`;
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
execFile("osascript", ["-e", script], (err) => {
|
|
38
|
+
if (err) {
|
|
39
|
+
resolve(c.json({ error: err.message }, 500));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
resolve(c.json({ success: true }));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return app;
|
|
48
|
+
}
|