heyhank 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/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// ─── Call Manager ───────────────────���────────────────────────────���────────────
|
|
2
|
+
// Manages active phone calls: FreeSWITCH ESL control + Gemini Live audio bridge.
|
|
3
|
+
// Each call gets its own AudioBridge instance connected to Gemini.
|
|
4
|
+
|
|
5
|
+
import type { ServerWebSocket } from "bun";
|
|
6
|
+
import type { CallConfig, CallState, CallEvent, TranscriptEntry } from "./call-types.js";
|
|
7
|
+
import { AudioBridge, downsampleTo8k, base64ToBuffer } from "./audio-bridge.js";
|
|
8
|
+
import { getSettings, saveCall } from "./telephony-store.js";
|
|
9
|
+
import { getSettings as getMainSettings } from "../settings-manager.js";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
// Gemini tool declarations for telephony calls (subset — focused on conversation)
|
|
13
|
+
const TELEPHONY_TOOL_DECLARATIONS = [{
|
|
14
|
+
functionDeclarations: [
|
|
15
|
+
{
|
|
16
|
+
name: "end_call",
|
|
17
|
+
description: "End/hang up the current phone call. Use when the conversation is complete, the task is done, or the other person wants to end the call.",
|
|
18
|
+
parameters: { type: "OBJECT", properties: {} },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "transfer_call",
|
|
22
|
+
description: "Request to transfer the call to a human or another number. Use when the AI can't handle the request.",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "OBJECT",
|
|
25
|
+
properties: {
|
|
26
|
+
reason: { type: "STRING", description: "Why the transfer is needed" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
}];
|
|
32
|
+
|
|
33
|
+
type EventListener = (event: CallEvent) => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CallManager orchestrates phone calls.
|
|
37
|
+
*
|
|
38
|
+
* Flow:
|
|
39
|
+
* 1. startCall() → creates CallState, connects AudioBridge to Gemini
|
|
40
|
+
* 2. FreeSWITCH sends audio via WebSocket to handleFreeSwitchAudio()
|
|
41
|
+
* 3. AudioBridge sends audio to Gemini, gets response audio back
|
|
42
|
+
* 4. Response audio sent back to FreeSWITCH via the same WebSocket
|
|
43
|
+
* 5. endCall() → hangup FreeSWITCH, disconnect Gemini, save transcript
|
|
44
|
+
*/
|
|
45
|
+
export class CallManager {
|
|
46
|
+
private activeCalls = new Map<string, {
|
|
47
|
+
state: CallState;
|
|
48
|
+
bridge: AudioBridge;
|
|
49
|
+
fsSockets: Set<ServerWebSocket<unknown>>; // FreeSWITCH audio fork WebSockets
|
|
50
|
+
transcriptSockets: Set<ServerWebSocket<unknown>>; // Browser WebSockets for live transcript
|
|
51
|
+
maxDurationTimer?: ReturnType<typeof setTimeout>;
|
|
52
|
+
}>();
|
|
53
|
+
|
|
54
|
+
private listeners = new Set<EventListener>();
|
|
55
|
+
|
|
56
|
+
/** Subscribe to call events */
|
|
57
|
+
onEvent(listener: EventListener): () => void {
|
|
58
|
+
this.listeners.add(listener);
|
|
59
|
+
return () => this.listeners.delete(listener);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private emit(event: CallEvent): void {
|
|
63
|
+
for (const listener of this.listeners) {
|
|
64
|
+
try { listener(event); } catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Start a new outbound call */
|
|
69
|
+
async startCall(config: CallConfig): Promise<CallState> {
|
|
70
|
+
const telSettings = getSettings();
|
|
71
|
+
|
|
72
|
+
if (!telSettings.enabled) {
|
|
73
|
+
throw new Error("Telephony is not enabled. Configure it in Settings → Telephony.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve Gemini API key
|
|
77
|
+
const mainSettings = getMainSettings();
|
|
78
|
+
const geminiKey = telSettings.geminiApiKey || mainSettings.geminiApiKey;
|
|
79
|
+
if (!geminiKey) {
|
|
80
|
+
throw new Error("Gemini API key not configured.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve SIP trunk
|
|
84
|
+
const trunkId = config.trunkId || telSettings.defaultTrunkId;
|
|
85
|
+
const trunk = trunkId ? telSettings.trunks.find((t) => t.id === trunkId) : telSettings.trunks[0];
|
|
86
|
+
if (!trunk) {
|
|
87
|
+
throw new Error("No SIP trunk configured. Add one in Settings → Telephony.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const callId = randomUUID();
|
|
91
|
+
const voice = config.voice || telSettings.defaultVoice || "Kore";
|
|
92
|
+
const maxDuration = config.maxDurationSeconds || telSettings.maxCallDurationSeconds || 600;
|
|
93
|
+
|
|
94
|
+
// Build telephony-specific system prompt
|
|
95
|
+
const systemPrompt = buildTelephonyPrompt(config.prompt, config.phone);
|
|
96
|
+
|
|
97
|
+
// Create call state
|
|
98
|
+
const callState: CallState = {
|
|
99
|
+
id: callId,
|
|
100
|
+
phone: config.phone,
|
|
101
|
+
prompt: config.prompt,
|
|
102
|
+
voice,
|
|
103
|
+
status: "initiating",
|
|
104
|
+
trunkId: trunk.id,
|
|
105
|
+
callerId: config.callerId || trunk.callerId,
|
|
106
|
+
transcript: [],
|
|
107
|
+
summary: null,
|
|
108
|
+
durationSeconds: 0,
|
|
109
|
+
startedAt: Date.now(),
|
|
110
|
+
connectedAt: null,
|
|
111
|
+
endedAt: null,
|
|
112
|
+
error: null,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Create audio bridge to Gemini
|
|
116
|
+
const bridge = new AudioBridge(callId, {
|
|
117
|
+
geminiApiKey: geminiKey,
|
|
118
|
+
voice,
|
|
119
|
+
systemPrompt,
|
|
120
|
+
tools: [...TELEPHONY_TOOL_DECLARATIONS, { googleSearch: {} }],
|
|
121
|
+
onTranscript: (entry) => {
|
|
122
|
+
callState.transcript.push(entry);
|
|
123
|
+
this.emit({ type: "transcript", callId, entry });
|
|
124
|
+
this.broadcastTranscript(callId, entry);
|
|
125
|
+
},
|
|
126
|
+
onStatusChange: (status) => {
|
|
127
|
+
callState.status = status;
|
|
128
|
+
if (status === "active" && !callState.connectedAt) {
|
|
129
|
+
callState.connectedAt = Date.now();
|
|
130
|
+
}
|
|
131
|
+
this.emit({ type: "status", callId, status });
|
|
132
|
+
},
|
|
133
|
+
onToolCall: async (calls) => {
|
|
134
|
+
return this.handleToolCalls(callId, calls);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// When Gemini produces response audio, send it to FreeSWITCH
|
|
139
|
+
bridge.onGeminiAudio = (base64Pcm: string) => {
|
|
140
|
+
const pcm = base64ToBuffer(base64Pcm);
|
|
141
|
+
// Gemini outputs 24kHz PCM, FreeSWITCH expects 8kHz
|
|
142
|
+
const downsampled = downsampleTo8k(pcm, 24000);
|
|
143
|
+
const call = this.activeCalls.get(callId);
|
|
144
|
+
if (call) {
|
|
145
|
+
for (const ws of call.fsSockets) {
|
|
146
|
+
try {
|
|
147
|
+
ws.send(downsampled);
|
|
148
|
+
} catch { /* socket closed */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
this.activeCalls.set(callId, {
|
|
154
|
+
state: callState,
|
|
155
|
+
bridge,
|
|
156
|
+
fsSockets: new Set(),
|
|
157
|
+
transcriptSockets: new Set(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Connect to Gemini first
|
|
161
|
+
try {
|
|
162
|
+
await bridge.connect();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
callState.status = "failed";
|
|
165
|
+
callState.error = err instanceof Error ? err.message : "Gemini connection failed";
|
|
166
|
+
callState.endedAt = Date.now();
|
|
167
|
+
saveCall(callState);
|
|
168
|
+
this.activeCalls.delete(callId);
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Now initiate the FreeSWITCH call via ESL
|
|
173
|
+
callState.status = "dialing";
|
|
174
|
+
this.emit({ type: "status", callId, status: "dialing" });
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await this.eslOriginate(callId, config.phone, trunk, callState.callerId);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
callState.status = "failed";
|
|
180
|
+
callState.error = err instanceof Error ? err.message : "FreeSWITCH originate failed";
|
|
181
|
+
callState.endedAt = Date.now();
|
|
182
|
+
bridge.disconnect();
|
|
183
|
+
saveCall(callState);
|
|
184
|
+
this.activeCalls.delete(callId);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Safety: auto-hangup after max duration
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
console.log(`[telephony] Call ${callId} hit max duration (${maxDuration}s), hanging up`);
|
|
191
|
+
this.endCall(callId).catch(() => {});
|
|
192
|
+
}, maxDuration * 1000);
|
|
193
|
+
|
|
194
|
+
const call = this.activeCalls.get(callId);
|
|
195
|
+
if (call) call.maxDurationTimer = timer;
|
|
196
|
+
|
|
197
|
+
saveCall(callState);
|
|
198
|
+
return callState;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** End an active call */
|
|
202
|
+
async endCall(callId: string): Promise<CallState | null> {
|
|
203
|
+
const call = this.activeCalls.get(callId);
|
|
204
|
+
if (!call) return null;
|
|
205
|
+
|
|
206
|
+
const { state, bridge } = call;
|
|
207
|
+
|
|
208
|
+
// Clear safety timer
|
|
209
|
+
if (call.maxDurationTimer) clearTimeout(call.maxDurationTimer);
|
|
210
|
+
|
|
211
|
+
// Disconnect Gemini
|
|
212
|
+
bridge.disconnect();
|
|
213
|
+
|
|
214
|
+
// Hangup FreeSWITCH
|
|
215
|
+
try {
|
|
216
|
+
await this.eslHangup(callId);
|
|
217
|
+
} catch { /* might already be hung up */ }
|
|
218
|
+
|
|
219
|
+
// Calculate duration
|
|
220
|
+
state.status = "ended";
|
|
221
|
+
state.endedAt = Date.now();
|
|
222
|
+
if (state.connectedAt) {
|
|
223
|
+
state.durationSeconds = Math.round((state.endedAt - state.connectedAt) / 1000);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Generate summary from transcript
|
|
227
|
+
state.summary = this.generateSummary(state);
|
|
228
|
+
|
|
229
|
+
this.emit({ type: "ended", callId, summary: state.summary });
|
|
230
|
+
saveCall(state);
|
|
231
|
+
this.activeCalls.delete(callId);
|
|
232
|
+
|
|
233
|
+
return state;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Handle audio from FreeSWITCH mod_audio_fork WebSocket */
|
|
237
|
+
handleFreeSwitchAudio(callId: string, data: Buffer | Uint8Array): void {
|
|
238
|
+
const call = this.activeCalls.get(callId);
|
|
239
|
+
if (!call) return;
|
|
240
|
+
call.bridge.sendCallerAudio(data);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Register a FreeSWITCH audio WebSocket for a call */
|
|
244
|
+
addFreeSwitchSocket(callId: string, ws: ServerWebSocket<unknown>): void {
|
|
245
|
+
const call = this.activeCalls.get(callId);
|
|
246
|
+
if (call) call.fsSockets.add(ws);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
removeFreeSwitchSocket(callId: string, ws: ServerWebSocket<unknown>): void {
|
|
250
|
+
const call = this.activeCalls.get(callId);
|
|
251
|
+
if (call) call.fsSockets.delete(ws);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Register a browser WebSocket for live transcript */
|
|
255
|
+
addTranscriptSocket(callId: string, ws: ServerWebSocket<unknown>): void {
|
|
256
|
+
const call = this.activeCalls.get(callId);
|
|
257
|
+
if (call) {
|
|
258
|
+
call.transcriptSockets.add(ws);
|
|
259
|
+
// Send existing transcript
|
|
260
|
+
for (const entry of call.state.transcript) {
|
|
261
|
+
try {
|
|
262
|
+
ws.send(JSON.stringify({ type: "transcript", entry }));
|
|
263
|
+
} catch { /* ignore */ }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
removeTranscriptSocket(callId: string, ws: ServerWebSocket<unknown>): void {
|
|
269
|
+
const call = this.activeCalls.get(callId);
|
|
270
|
+
if (call) call.transcriptSockets.delete(ws);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private broadcastTranscript(callId: string, entry: TranscriptEntry): void {
|
|
274
|
+
const call = this.activeCalls.get(callId);
|
|
275
|
+
if (!call) return;
|
|
276
|
+
const msg = JSON.stringify({ type: "transcript", entry });
|
|
277
|
+
for (const ws of call.transcriptSockets) {
|
|
278
|
+
try { ws.send(msg); } catch { /* ignore */ }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Get state of a specific call */
|
|
283
|
+
getCallState(callId: string): CallState | null {
|
|
284
|
+
return this.activeCalls.get(callId)?.state || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** List all active calls */
|
|
288
|
+
getActiveCalls(): CallState[] {
|
|
289
|
+
return Array.from(this.activeCalls.values()).map((c) => c.state);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Handle tool calls from Gemini during a phone call */
|
|
293
|
+
private async handleToolCalls(
|
|
294
|
+
callId: string,
|
|
295
|
+
calls: Array<{ id: string; name: string; args: Record<string, unknown> }>,
|
|
296
|
+
): Promise<Array<{ id: string; name: string; response: unknown }>> {
|
|
297
|
+
const results: Array<{ id: string; name: string; response: unknown }> = [];
|
|
298
|
+
|
|
299
|
+
for (const call of calls) {
|
|
300
|
+
switch (call.name) {
|
|
301
|
+
case "end_call":
|
|
302
|
+
// Gemini decided to hang up
|
|
303
|
+
setTimeout(() => this.endCall(callId).catch(() => {}), 500); // short delay for final audio
|
|
304
|
+
results.push({ id: call.id, name: call.name, response: { success: true, message: "Hanging up" } });
|
|
305
|
+
break;
|
|
306
|
+
case "transfer_call":
|
|
307
|
+
results.push({ id: call.id, name: call.name, response: { error: "Transfer not yet implemented" } });
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
// Forward to main tool handler (for todos, notes, etc.)
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch(`http://127.0.0.1:${process.env.PORT || 3100}/api/gemini/tool-call`, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
headers: { "Content-Type": "application/json" },
|
|
315
|
+
body: JSON.stringify({ name: call.name, args: call.args }),
|
|
316
|
+
});
|
|
317
|
+
const data = await res.json();
|
|
318
|
+
results.push({ id: call.id, name: call.name, response: data.result || { error: "No result" } });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
results.push({ id: call.id, name: call.name, response: { error: String(err) } });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return results;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── FreeSWITCH ESL Commands ────���──────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Originate a call via FreeSWITCH ESL.
|
|
332
|
+
* Uses HTTP API (mod_xml_rpc or mod_httapi) for simplicity —
|
|
333
|
+
* no need for a persistent ESL TCP connection.
|
|
334
|
+
*/
|
|
335
|
+
private async eslOriginate(
|
|
336
|
+
callId: string,
|
|
337
|
+
phone: string,
|
|
338
|
+
trunk: { id: string; name: string },
|
|
339
|
+
callerId: string,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const settings = getSettings();
|
|
342
|
+
const { eslHost, eslPort, eslPassword } = settings.freeswitch;
|
|
343
|
+
|
|
344
|
+
// FreeSWITCH ESL over HTTP (mod_xml_rpc)
|
|
345
|
+
// Format: api originate {vars}sofia/gateway/trunk/number &park()
|
|
346
|
+
const vars = [
|
|
347
|
+
`origination_caller_id_number=${callerId}`,
|
|
348
|
+
`origination_caller_id_name=HeyHank`,
|
|
349
|
+
`origination_uuid=${callId}`,
|
|
350
|
+
`ignore_early_media=true`,
|
|
351
|
+
].join(",");
|
|
352
|
+
|
|
353
|
+
const cmd = `originate {${vars}}sofia/gateway/${trunk.name}/${phone} &park()`;
|
|
354
|
+
|
|
355
|
+
console.log(`[telephony] ESL originate: ${cmd}`);
|
|
356
|
+
|
|
357
|
+
// Try ESL HTTP API first (port 8080 default for mod_xml_rpc)
|
|
358
|
+
try {
|
|
359
|
+
const eslUrl = `http://${eslHost}:${eslPort}/api`;
|
|
360
|
+
const res = await fetch(eslUrl, {
|
|
361
|
+
method: "POST",
|
|
362
|
+
headers: {
|
|
363
|
+
"Content-Type": "text/plain",
|
|
364
|
+
"Authorization": `Basic ${btoa(`freeswitch:${eslPassword}`)}`,
|
|
365
|
+
},
|
|
366
|
+
body: cmd,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!res.ok) {
|
|
370
|
+
const text = await res.text();
|
|
371
|
+
throw new Error(`ESL error: ${res.status} ${text}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const result = await res.text();
|
|
375
|
+
console.log(`[telephony] ESL originate result: ${result.trim()}`);
|
|
376
|
+
|
|
377
|
+
if (result.includes("-ERR")) {
|
|
378
|
+
throw new Error(`FreeSWITCH error: ${result.trim()}`);
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`[telephony] ESL originate failed:`, err);
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async eslHangup(callId: string): Promise<void> {
|
|
387
|
+
const settings = getSettings();
|
|
388
|
+
const { eslHost, eslPort, eslPassword } = settings.freeswitch;
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const eslUrl = `http://${eslHost}:${eslPort}/api`;
|
|
392
|
+
await fetch(eslUrl, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: {
|
|
395
|
+
"Content-Type": "text/plain",
|
|
396
|
+
"Authorization": `Basic ${btoa(`freeswitch:${eslPassword}`)}`,
|
|
397
|
+
},
|
|
398
|
+
body: `uuid_kill ${callId}`,
|
|
399
|
+
});
|
|
400
|
+
} catch {
|
|
401
|
+
// Might already be disconnected
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Generate a simple summary from the transcript */
|
|
406
|
+
private generateSummary(state: CallState): string {
|
|
407
|
+
const meaningful = state.transcript.filter((t) => t.speaker !== "system");
|
|
408
|
+
if (meaningful.length === 0) return "No conversation recorded.";
|
|
409
|
+
|
|
410
|
+
const lines = meaningful.map((t) =>
|
|
411
|
+
`${t.speaker === "callee" ? "Callee" : "AI"}: ${t.text}`
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
return `Call to ${state.phone} (${state.durationSeconds}s). ` +
|
|
415
|
+
`${meaningful.filter((t) => t.speaker === "callee").length} callee messages, ` +
|
|
416
|
+
`${meaningful.filter((t) => t.speaker === "ai").length} AI responses.\n\n` +
|
|
417
|
+
lines.slice(-6).join("\n"); // Last 6 lines as summary
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Shutdown: end all calls */
|
|
421
|
+
async shutdown(): Promise<void> {
|
|
422
|
+
const callIds = Array.from(this.activeCalls.keys());
|
|
423
|
+
await Promise.all(callIds.map((id) => this.endCall(id)));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Prompt Builder ──────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function buildTelephonyPrompt(taskPrompt: string, phoneNumber: string): string {
|
|
430
|
+
return `You are conducting a PHONE CALL. You are speaking to a real person on the telephone.
|
|
431
|
+
|
|
432
|
+
YOUR TASK: ${taskPrompt}
|
|
433
|
+
|
|
434
|
+
CRITICAL PHONE CALL RULES:
|
|
435
|
+
- You are on a TELEPHONE. Speak naturally, briefly, and conversationally.
|
|
436
|
+
- Maximum 2-3 short sentences per response. Nobody likes long monologues on the phone.
|
|
437
|
+
- Use natural filler words ("well", "I see", "right", "exactly") — this sounds human.
|
|
438
|
+
- If you don't understand something, politely ask them to repeat.
|
|
439
|
+
- When your task is complete, summarize the result and say goodbye politely.
|
|
440
|
+
- If asked directly whether you're an AI, be honest.
|
|
441
|
+
- NEVER mention that you are calling on behalf of "HeyHank" or a "platform" unless asked.
|
|
442
|
+
- Speak in the same language as the person you're calling.
|
|
443
|
+
- The phone number you're calling is: ${phoneNumber}
|
|
444
|
+
|
|
445
|
+
CALL FLOW:
|
|
446
|
+
1. Greet the person naturally (e.g., "Hello, good day!")
|
|
447
|
+
2. State your request concisely
|
|
448
|
+
3. Listen and respond to their questions
|
|
449
|
+
4. When done, thank them and say goodbye
|
|
450
|
+
5. Use the end_call tool when the conversation is finished
|
|
451
|
+
|
|
452
|
+
You also have access to Google Search if you need to look something up during the call.
|
|
453
|
+
When the task is completed, call end_call to hang up.`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Export singleton
|
|
457
|
+
export const callManager = new CallManager();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ─── Telephony Types ──────────────────────────────────────────────────────────
|
|
2
|
+
// Types for the KI-Telephony system: FreeSWITCH ↔ Gemini Live bridge.
|
|
3
|
+
|
|
4
|
+
export interface SipTrunkConfig {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
provider: "sipgate" | "easybell" | "peoplefone" | "custom";
|
|
8
|
+
username: string;
|
|
9
|
+
password: string;
|
|
10
|
+
server: string;
|
|
11
|
+
callerId: string; // e.g. "+4312345678"
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FreeSwitchConfig {
|
|
16
|
+
eslHost: string;
|
|
17
|
+
eslPort: number;
|
|
18
|
+
eslPassword: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CallConfig {
|
|
22
|
+
phone: string; // E.164 format: "+4366412345"
|
|
23
|
+
prompt: string; // Task/persona for Gemini
|
|
24
|
+
voice?: string; // Gemini voice (default: "Kore")
|
|
25
|
+
trunkId?: string; // Which SIP trunk to use
|
|
26
|
+
callerId?: string; // Override caller ID
|
|
27
|
+
maxDurationSeconds?: number; // Auto-hangup after N seconds (safety)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CallStatus =
|
|
31
|
+
| "initiating" // Server preparing the call
|
|
32
|
+
| "dialing" // FreeSWITCH placing the call
|
|
33
|
+
| "ringing" // Remote phone ringing
|
|
34
|
+
| "active" // Call connected, AI speaking
|
|
35
|
+
| "ended" // Call ended normally
|
|
36
|
+
| "failed" // Call failed (busy, no answer, error)
|
|
37
|
+
| "cancelled"; // Cancelled by user before connecting
|
|
38
|
+
|
|
39
|
+
export interface TranscriptEntry {
|
|
40
|
+
speaker: "callee" | "ai" | "system";
|
|
41
|
+
text: string;
|
|
42
|
+
isFinal: boolean;
|
|
43
|
+
ts: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CallState {
|
|
47
|
+
id: string;
|
|
48
|
+
phone: string;
|
|
49
|
+
prompt: string;
|
|
50
|
+
voice: string;
|
|
51
|
+
status: CallStatus;
|
|
52
|
+
trunkId: string;
|
|
53
|
+
callerId: string;
|
|
54
|
+
transcript: TranscriptEntry[];
|
|
55
|
+
summary: string | null;
|
|
56
|
+
durationSeconds: number;
|
|
57
|
+
startedAt: number;
|
|
58
|
+
connectedAt: number | null;
|
|
59
|
+
endedAt: number | null;
|
|
60
|
+
error: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Message from FreeSWITCH audio fork WebSocket (PCM audio chunks) */
|
|
64
|
+
export interface AudioChunk {
|
|
65
|
+
data: Buffer | Uint8Array;
|
|
66
|
+
sampleRate: number; // 8000 from FreeSWITCH
|
|
67
|
+
channels: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Events emitted by CallManager */
|
|
71
|
+
export type CallEvent =
|
|
72
|
+
| { type: "status"; callId: string; status: CallStatus }
|
|
73
|
+
| { type: "transcript"; callId: string; entry: TranscriptEntry }
|
|
74
|
+
| { type: "ended"; callId: string; summary: string | null };
|
|
75
|
+
|
|
76
|
+
/** A phone contact that Gemini can look up and call by name */
|
|
77
|
+
export interface TelephonyContact {
|
|
78
|
+
id: string;
|
|
79
|
+
name: string; // Display name (e.g. "Mama", "Restaurant Steirereck")
|
|
80
|
+
phone: string; // E.164 format (e.g. "+4366412345")
|
|
81
|
+
notes?: string; // Optional context (e.g. "Mon-Sat 10-18 Uhr")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Telephony settings stored in ~/.heyhank/telephony.json */
|
|
85
|
+
export interface TelephonySettings {
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
freeswitch: FreeSwitchConfig;
|
|
88
|
+
trunks: SipTrunkConfig[];
|
|
89
|
+
contacts: TelephonyContact[];
|
|
90
|
+
defaultTrunkId: string | null;
|
|
91
|
+
defaultVoice: string;
|
|
92
|
+
maxCallDurationSeconds: number;
|
|
93
|
+
geminiApiKey?: string; // Override; falls back to main Gemini key
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_TELEPHONY_SETTINGS: TelephonySettings = {
|
|
97
|
+
enabled: false,
|
|
98
|
+
freeswitch: {
|
|
99
|
+
eslHost: "localhost",
|
|
100
|
+
eslPort: 8021,
|
|
101
|
+
eslPassword: "ClueCon",
|
|
102
|
+
},
|
|
103
|
+
trunks: [],
|
|
104
|
+
contacts: [],
|
|
105
|
+
defaultTrunkId: null,
|
|
106
|
+
defaultVoice: "Kore",
|
|
107
|
+
maxCallDurationSeconds: 600, // 10 min safety limit
|
|
108
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ─── Telephony Store ──────────────────────────────────────────────────────────
|
|
2
|
+
// File-based persistence for telephony settings and call history.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { TelephonySettings, CallState, TelephonyContact } from "./call-types.js";
|
|
8
|
+
import { DEFAULT_TELEPHONY_SETTINGS } from "./call-types.js";
|
|
9
|
+
|
|
10
|
+
const BASE_DIR = join(homedir(), ".heyhank", "telephony");
|
|
11
|
+
const SETTINGS_FILE = join(BASE_DIR, "settings.json");
|
|
12
|
+
const CALLS_DIR = join(BASE_DIR, "calls");
|
|
13
|
+
|
|
14
|
+
function ensureDirs(): void {
|
|
15
|
+
if (!existsSync(BASE_DIR)) mkdirSync(BASE_DIR, { recursive: true });
|
|
16
|
+
if (!existsSync(CALLS_DIR)) mkdirSync(CALLS_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Settings ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export function getSettings(): TelephonySettings {
|
|
22
|
+
ensureDirs();
|
|
23
|
+
if (!existsSync(SETTINGS_FILE)) return { ...DEFAULT_TELEPHONY_SETTINGS };
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(SETTINGS_FILE, "utf-8");
|
|
26
|
+
return { ...DEFAULT_TELEPHONY_SETTINGS, ...JSON.parse(raw) };
|
|
27
|
+
} catch {
|
|
28
|
+
return { ...DEFAULT_TELEPHONY_SETTINGS };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveSettings(settings: TelephonySettings): void {
|
|
33
|
+
ensureDirs();
|
|
34
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf-8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Contacts ───────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function getContacts(): TelephonyContact[] {
|
|
40
|
+
return getSettings().contacts || [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function addContact(contact: TelephonyContact): void {
|
|
44
|
+
const settings = getSettings();
|
|
45
|
+
if (!settings.contacts) settings.contacts = [];
|
|
46
|
+
settings.contacts.push(contact);
|
|
47
|
+
saveSettings(settings);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function updateContact(id: string, patch: Partial<Omit<TelephonyContact, "id">>): TelephonyContact | null {
|
|
51
|
+
const settings = getSettings();
|
|
52
|
+
if (!settings.contacts) return null;
|
|
53
|
+
const idx = settings.contacts.findIndex((c) => c.id === id);
|
|
54
|
+
if (idx < 0) return null;
|
|
55
|
+
settings.contacts[idx] = { ...settings.contacts[idx], ...patch };
|
|
56
|
+
saveSettings(settings);
|
|
57
|
+
return settings.contacts[idx];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function deleteContact(id: string): boolean {
|
|
61
|
+
const settings = getSettings();
|
|
62
|
+
if (!settings.contacts) return false;
|
|
63
|
+
const before = settings.contacts.length;
|
|
64
|
+
settings.contacts = settings.contacts.filter((c) => c.id !== id);
|
|
65
|
+
if (settings.contacts.length === before) return false;
|
|
66
|
+
saveSettings(settings);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve a contact name (fuzzy) to a phone number */
|
|
71
|
+
export function resolveContactByName(nameQuery: string): TelephonyContact | null {
|
|
72
|
+
const contacts = getContacts();
|
|
73
|
+
if (contacts.length === 0) return null;
|
|
74
|
+
const q = nameQuery.toLowerCase().trim();
|
|
75
|
+
// Exact match first
|
|
76
|
+
const exact = contacts.find((c) => c.name.toLowerCase() === q);
|
|
77
|
+
if (exact) return exact;
|
|
78
|
+
// Partial match
|
|
79
|
+
const partial = contacts.find((c) => c.name.toLowerCase().includes(q) || q.includes(c.name.toLowerCase()));
|
|
80
|
+
return partial || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Call History ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export function saveCall(call: CallState): void {
|
|
86
|
+
ensureDirs();
|
|
87
|
+
const file = join(CALLS_DIR, `${call.id}.json`);
|
|
88
|
+
writeFileSync(file, JSON.stringify(call, null, 2), "utf-8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getCall(callId: string): CallState | null {
|
|
92
|
+
const file = join(CALLS_DIR, `${callId}.json`);
|
|
93
|
+
if (!existsSync(file)) return null;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function listCalls(limit = 50): CallState[] {
|
|
102
|
+
ensureDirs();
|
|
103
|
+
try {
|
|
104
|
+
const files = readdirSync(CALLS_DIR)
|
|
105
|
+
.filter((f) => f.endsWith(".json"))
|
|
106
|
+
.sort()
|
|
107
|
+
.reverse()
|
|
108
|
+
.slice(0, limit);
|
|
109
|
+
return files.map((f) => {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(readFileSync(join(CALLS_DIR, f), "utf-8")) as CallState;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}).filter(Boolean) as CallState[];
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|