heyhank 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -25,6 +25,8 @@ export interface CallConfig {
|
|
|
25
25
|
trunkId?: string; // Which SIP trunk to use
|
|
26
26
|
callerId?: string; // Override caller ID
|
|
27
27
|
maxDurationSeconds?: number; // Auto-hangup after N seconds (safety)
|
|
28
|
+
listen?: boolean; // Stream live audio to browser (listen mode)
|
|
29
|
+
useSavedScript?: boolean; // If true, inject contact.script/callFlow as PRIMARY OBJECTIVE. Default: false.
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export type CallStatus =
|
|
@@ -49,6 +51,7 @@ export interface CallState {
|
|
|
49
51
|
prompt: string;
|
|
50
52
|
voice: string;
|
|
51
53
|
status: CallStatus;
|
|
54
|
+
direction: "outbound" | "inbound";
|
|
52
55
|
trunkId: string;
|
|
53
56
|
callerId: string;
|
|
54
57
|
transcript: TranscriptEntry[];
|
|
@@ -58,6 +61,8 @@ export interface CallState {
|
|
|
58
61
|
connectedAt: number | null;
|
|
59
62
|
endedAt: number | null;
|
|
60
63
|
error: string | null;
|
|
64
|
+
listenMode: boolean;
|
|
65
|
+
audioFile?: string | null; // Path to WAV recording (stereo: caller L, AI R)
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/** Message from FreeSWITCH audio fork WebSocket (PCM audio chunks) */
|
|
@@ -79,6 +84,49 @@ export interface TelephonyContact {
|
|
|
79
84
|
name: string; // Display name (e.g. "Mama", "Restaurant Steirereck")
|
|
80
85
|
phone: string; // E.164 format (e.g. "+4366412345")
|
|
81
86
|
notes?: string; // Optional context (e.g. "Mon-Sat 10-18 Uhr")
|
|
87
|
+
language?: string; // Call language (e.g. "de", "en") — default: "en"
|
|
88
|
+
script?: string; // Simple call script (markdown prompt)
|
|
89
|
+
callFlow?: CallFlow; // Enterprise: state machine call flow
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Call Flow (State Machine) ────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** A node in the call flow graph */
|
|
95
|
+
export interface CallFlowNode {
|
|
96
|
+
id: string;
|
|
97
|
+
type: "start" | "say" | "ask" | "condition" | "action" | "end";
|
|
98
|
+
label: string; // Short label for UI display
|
|
99
|
+
prompt?: string; // What the AI should say/do at this node
|
|
100
|
+
/** For "ask" nodes: what to listen for */
|
|
101
|
+
expectedResponses?: string[];
|
|
102
|
+
/** For "condition" nodes: variable to evaluate */
|
|
103
|
+
conditionVariable?: string;
|
|
104
|
+
/** For "action" nodes: tool to call (e.g. "save_note", "end_call") */
|
|
105
|
+
actionTool?: string;
|
|
106
|
+
actionArgs?: Record<string, unknown>;
|
|
107
|
+
/** Position in visual editor (pixels) */
|
|
108
|
+
position?: { x: number; y: number };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An edge connecting two nodes in the call flow */
|
|
112
|
+
export interface CallFlowEdge {
|
|
113
|
+
id: string;
|
|
114
|
+
from: string; // source node ID
|
|
115
|
+
to: string; // target node ID
|
|
116
|
+
label?: string; // condition label (e.g. "yes", "no", "timeout", "default")
|
|
117
|
+
condition?: string; // optional match condition (regex or keyword)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A complete call flow definition (state machine) */
|
|
121
|
+
export interface CallFlow {
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
description?: string;
|
|
125
|
+
nodes: CallFlowNode[];
|
|
126
|
+
edges: CallFlowEdge[];
|
|
127
|
+
variables?: Record<string, string>; // template variables (e.g. {{companyName}})
|
|
128
|
+
createdAt?: string;
|
|
129
|
+
updatedAt?: string;
|
|
82
130
|
}
|
|
83
131
|
|
|
84
132
|
/** Telephony settings stored in ~/.heyhank/telephony.json */
|
|
@@ -89,8 +137,23 @@ export interface TelephonySettings {
|
|
|
89
137
|
contacts: TelephonyContact[];
|
|
90
138
|
defaultTrunkId: string | null;
|
|
91
139
|
defaultVoice: string;
|
|
140
|
+
defaultLanguage: string; // e.g. "de", "en" — used when contact has no language set
|
|
92
141
|
maxCallDurationSeconds: number;
|
|
93
142
|
geminiApiKey?: string; // Override; falls back to main Gemini key
|
|
143
|
+
geminiBackend?: "aistudio" | "vertexai"; // Default: aistudio
|
|
144
|
+
gcpProjectId?: string; // Required for Vertex AI
|
|
145
|
+
gcpLocation?: string; // e.g. "europe-west4" (default for Vertex AI)
|
|
146
|
+
gcpServiceAccountKey?: string; // Path to service account JSON key file
|
|
147
|
+
// Inbound call handling
|
|
148
|
+
inboundEnabled?: boolean;
|
|
149
|
+
defaultInboundPrompt?: string; // System prompt for answering calls
|
|
150
|
+
defaultInboundVoice?: string; // Voice for inbound (default: same as defaultVoice)
|
|
151
|
+
inboundKnowledgeBase?: string; // Business info, FAQs, etc. injected into every inbound call
|
|
152
|
+
/**
|
|
153
|
+
* Country-code digits (no `+`) used to convert national-format inbound caller-IDs
|
|
154
|
+
* to E.164 for contact lookup. If empty, the first enabled trunk's callerId is parsed.
|
|
155
|
+
*/
|
|
156
|
+
defaultCountryCode?: string;
|
|
94
157
|
}
|
|
95
158
|
|
|
96
159
|
export const DEFAULT_TELEPHONY_SETTINGS: TelephonySettings = {
|
|
@@ -98,11 +161,14 @@ export const DEFAULT_TELEPHONY_SETTINGS: TelephonySettings = {
|
|
|
98
161
|
freeswitch: {
|
|
99
162
|
eslHost: "localhost",
|
|
100
163
|
eslPort: 8021,
|
|
101
|
-
eslPassword: "
|
|
164
|
+
eslPassword: "heyhank_esl_secret",
|
|
102
165
|
},
|
|
103
166
|
trunks: [],
|
|
104
167
|
contacts: [],
|
|
105
168
|
defaultTrunkId: null,
|
|
106
169
|
defaultVoice: "Kore",
|
|
170
|
+
defaultLanguage: "de",
|
|
107
171
|
maxCallDurationSeconds: 600, // 10 min safety limit
|
|
172
|
+
inboundEnabled: false,
|
|
173
|
+
defaultInboundPrompt: "You are Hank, a helpful AI assistant answering the phone. Be friendly, concise, and helpful. Ask the caller how you can help them.",
|
|
108
174
|
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// ─── FreeSWITCH ESL TCP Client ───────────────────────────────────────────────
|
|
2
|
+
// Lightweight ESL (Event Socket Layer) client for sending commands to FreeSWITCH.
|
|
3
|
+
// Uses TCP socket protocol (mod_event_socket) instead of HTTP (mod_xml_rpc).
|
|
4
|
+
//
|
|
5
|
+
// Protocol: Connect → receive "Content-Type: auth/request" → send "auth <pw>" →
|
|
6
|
+
// receive "Reply-Text: +OK" → send "api <cmd>" → read response.
|
|
7
|
+
|
|
8
|
+
import { connect, type Socket } from "node:net";
|
|
9
|
+
import type { FreeSwitchConfig } from "./call-types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Send a single FreeSWITCH API command via ESL TCP and return the response.
|
|
13
|
+
* Opens a connection, authenticates, sends the command, then closes.
|
|
14
|
+
* Use background=true for long-running commands like originate (uses bgapi).
|
|
15
|
+
*/
|
|
16
|
+
export async function eslCommand(
|
|
17
|
+
command: string,
|
|
18
|
+
config: FreeSwitchConfig,
|
|
19
|
+
timeoutMs = 5000,
|
|
20
|
+
background = false,
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let buffer = "";
|
|
24
|
+
let authenticated = false;
|
|
25
|
+
let commandSent = false;
|
|
26
|
+
let responseBody = "";
|
|
27
|
+
let expectedContentLength = 0;
|
|
28
|
+
let resolved = false;
|
|
29
|
+
|
|
30
|
+
const done = (result: string) => {
|
|
31
|
+
if (resolved) return;
|
|
32
|
+
resolved = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
socket.destroy();
|
|
35
|
+
resolve(result);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const fail = (err: Error) => {
|
|
39
|
+
if (resolved) return;
|
|
40
|
+
resolved = true;
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
socket.destroy();
|
|
43
|
+
reject(err);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
fail(new Error(`ESL timeout after ${timeoutMs}ms`));
|
|
48
|
+
}, timeoutMs);
|
|
49
|
+
|
|
50
|
+
const socket: Socket = connect(config.eslPort, config.eslHost, () => {
|
|
51
|
+
// Connection established — wait for auth/request
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
socket.setEncoding("utf-8");
|
|
55
|
+
|
|
56
|
+
socket.on("data", (chunk: string) => {
|
|
57
|
+
buffer += chunk;
|
|
58
|
+
|
|
59
|
+
// Parse ESL protocol messages (header + body separated by \n\n)
|
|
60
|
+
while (buffer.includes("\n\n")) {
|
|
61
|
+
const delimIdx = buffer.indexOf("\n\n");
|
|
62
|
+
const block = buffer.slice(0, delimIdx);
|
|
63
|
+
buffer = buffer.slice(delimIdx + 2);
|
|
64
|
+
|
|
65
|
+
const headers = parseHeaders(block);
|
|
66
|
+
|
|
67
|
+
if (!authenticated) {
|
|
68
|
+
if (headers["Content-Type"] === "auth/request") {
|
|
69
|
+
socket.write(`auth ${config.eslPassword}\n\n`);
|
|
70
|
+
} else if (headers["Reply-Text"]?.startsWith("+OK")) {
|
|
71
|
+
authenticated = true;
|
|
72
|
+
// Use bgapi for background commands (non-blocking), api for regular
|
|
73
|
+
const prefix = background ? "bgapi" : "api";
|
|
74
|
+
socket.write(`${prefix} ${command}\n\n`);
|
|
75
|
+
commandSent = true;
|
|
76
|
+
} else if (headers["Reply-Text"]?.startsWith("-ERR")) {
|
|
77
|
+
fail(new Error(`ESL auth failed: ${headers["Reply-Text"]}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
} else if (commandSent) {
|
|
81
|
+
// bgapi returns Reply-Text with Job-UUID immediately
|
|
82
|
+
if (background && headers["Reply-Text"]) {
|
|
83
|
+
const reply = headers["Reply-Text"];
|
|
84
|
+
if (reply.startsWith("+OK")) {
|
|
85
|
+
// Extract Job-UUID: "+OK Job-UUID: xxxx"
|
|
86
|
+
const jobId = reply.replace("+OK Job-UUID: ", "").trim();
|
|
87
|
+
done(jobId || "+OK");
|
|
88
|
+
return;
|
|
89
|
+
} else if (reply.startsWith("-ERR")) {
|
|
90
|
+
fail(new Error(`FreeSWITCH error: ${reply}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Regular api response
|
|
96
|
+
if (headers["Content-Type"] === "api/response") {
|
|
97
|
+
expectedContentLength = parseInt(headers["Content-Length"] || "0", 10);
|
|
98
|
+
if (expectedContentLength > 0) {
|
|
99
|
+
if (buffer.length >= expectedContentLength) {
|
|
100
|
+
done(buffer.slice(0, expectedContentLength));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Wait for more data
|
|
104
|
+
} else {
|
|
105
|
+
done("");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// If we're waiting for body content after headers
|
|
113
|
+
if (expectedContentLength > 0 && buffer.length >= expectedContentLength) {
|
|
114
|
+
done(buffer.slice(0, expectedContentLength));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
socket.on("error", (err) => {
|
|
119
|
+
fail(new Error(`ESL connection error: ${err.message}`));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
socket.on("close", () => {
|
|
123
|
+
if (!resolved && !commandSent) {
|
|
124
|
+
fail(new Error("ESL connection closed before command sent"));
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseHeaders(block: string): Record<string, string> {
|
|
131
|
+
const headers: Record<string, string> = {};
|
|
132
|
+
for (const line of block.split("\n")) {
|
|
133
|
+
const idx = line.indexOf(": ");
|
|
134
|
+
if (idx > 0) {
|
|
135
|
+
headers[line.slice(0, idx)] = line.slice(idx + 2);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return headers;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to FreeSWITCH events for inbound call detection + hangup cleanup.
|
|
143
|
+
* Maintains a persistent ESL connection that listens for CHANNEL_CREATE and
|
|
144
|
+
* CHANNEL_HANGUP_COMPLETE events. Auto-reconnects on disconnect.
|
|
145
|
+
*/
|
|
146
|
+
export function eslSubscribe(
|
|
147
|
+
config: FreeSwitchConfig,
|
|
148
|
+
onInboundCall: (info: { uuid: string; callerNumber: string }) => void,
|
|
149
|
+
onHangup?: (info: { uuid: string }) => void,
|
|
150
|
+
): { stop: () => void } {
|
|
151
|
+
let stopped = false;
|
|
152
|
+
let socket: Socket | null = null;
|
|
153
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
154
|
+
let reconnectDelay = 5000; // Start at 5s, exponential backoff up to 60s
|
|
155
|
+
let authFailed = false;
|
|
156
|
+
|
|
157
|
+
function connectAndSubscribe() {
|
|
158
|
+
if (stopped) return;
|
|
159
|
+
|
|
160
|
+
let buffer = "";
|
|
161
|
+
let authenticated = false;
|
|
162
|
+
let subscribed = false;
|
|
163
|
+
authFailed = false;
|
|
164
|
+
|
|
165
|
+
socket = connect(config.eslPort, config.eslHost, () => {
|
|
166
|
+
console.log("[esl-subscribe] Connected to FreeSWITCH for inbound events");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
socket.setEncoding("utf-8");
|
|
170
|
+
|
|
171
|
+
socket.on("data", (chunk: string) => {
|
|
172
|
+
buffer += chunk;
|
|
173
|
+
|
|
174
|
+
while (buffer.includes("\n\n")) {
|
|
175
|
+
const delimIdx = buffer.indexOf("\n\n");
|
|
176
|
+
const block = buffer.slice(0, delimIdx);
|
|
177
|
+
buffer = buffer.slice(delimIdx + 2);
|
|
178
|
+
|
|
179
|
+
const headers = parseHeaders(block);
|
|
180
|
+
|
|
181
|
+
if (!authenticated) {
|
|
182
|
+
if (headers["Content-Type"] === "auth/request") {
|
|
183
|
+
socket!.write(`auth ${config.eslPassword}\n\n`);
|
|
184
|
+
} else if (headers["Reply-Text"]?.startsWith("+OK")) {
|
|
185
|
+
authenticated = true;
|
|
186
|
+
// Reset backoff on successful auth
|
|
187
|
+
reconnectDelay = 5000;
|
|
188
|
+
// Subscribe to channel create (inbound detection) + hangup (cleanup).
|
|
189
|
+
socket!.write("event plain CHANNEL_CREATE CHANNEL_HANGUP_COMPLETE\n\n");
|
|
190
|
+
} else if (headers["Reply-Text"]?.startsWith("-ERR")) {
|
|
191
|
+
authFailed = true;
|
|
192
|
+
console.error("[esl-subscribe] Authentication failed:", headers["Reply-Text"]);
|
|
193
|
+
socket!.destroy();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
} else if (!subscribed) {
|
|
197
|
+
if (headers["Reply-Text"]?.startsWith("+OK")) {
|
|
198
|
+
subscribed = true;
|
|
199
|
+
console.log("[esl-subscribe] Subscribed to CHANNEL_CREATE + CHANNEL_HANGUP_COMPLETE events");
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// Parse events — look for inbound calls + hangups
|
|
203
|
+
if (headers["Content-Type"] === "text/event-plain") {
|
|
204
|
+
const contentLength = parseInt(headers["Content-Length"] || "0", 10);
|
|
205
|
+
// Read the event body
|
|
206
|
+
let eventBody = "";
|
|
207
|
+
if (contentLength > 0 && buffer.length >= contentLength) {
|
|
208
|
+
eventBody = buffer.slice(0, contentLength);
|
|
209
|
+
buffer = buffer.slice(contentLength);
|
|
210
|
+
} else if (contentLength > 0) {
|
|
211
|
+
// Body incomplete — restore the full original buffer (headers + delimiter + remaining)
|
|
212
|
+
// so the next data event can parse the complete message
|
|
213
|
+
buffer = block + "\n\n" + buffer;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Parse event headers from the body
|
|
218
|
+
const eventHeaders = parseHeaders(eventBody || block);
|
|
219
|
+
const allHeaders = { ...headers, ...eventHeaders };
|
|
220
|
+
const eventName = allHeaders["Event-Name"] || "";
|
|
221
|
+
const uuid = allHeaders["Unique-ID"] || allHeaders["Channel-Call-UUID"] || "";
|
|
222
|
+
|
|
223
|
+
if (eventName === "CHANNEL_CREATE") {
|
|
224
|
+
const direction = allHeaders["Call-Direction"] || allHeaders["variable_call_direction"] || "";
|
|
225
|
+
const callerNumber = allHeaders["Caller-Caller-ID-Number"] || allHeaders["variable_sip_from_user"] || "";
|
|
226
|
+
const destNumber = allHeaders["Caller-Destination-Number"] || allHeaders["variable_destination_number"] || "";
|
|
227
|
+
// Filter out SIP scanner probes / keepalive INVITEs (destination "0",
|
|
228
|
+
// empty, or anything that doesn't match our DID pattern). Only real
|
|
229
|
+
// inbound calls to 43… reach the heyhank dialplan, but CHANNEL_CREATE
|
|
230
|
+
// fires for every INVITE even when NO_ROUTE_DESTINATION rejects it.
|
|
231
|
+
const isRealDid = /^\+?43\d+$/.test(destNumber);
|
|
232
|
+
if (direction === "inbound" && uuid && isRealDid) {
|
|
233
|
+
console.log(`[esl-subscribe] Inbound call detected: UUID=${uuid}, caller=${callerNumber}, dest=${destNumber}`);
|
|
234
|
+
try {
|
|
235
|
+
onInboundCall({ uuid, callerNumber: callerNumber || "unknown" });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error("[esl-subscribe] onInboundCall error:", err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} else if (eventName === "CHANNEL_HANGUP_COMPLETE" && uuid && onHangup) {
|
|
241
|
+
try {
|
|
242
|
+
onHangup({ uuid });
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error("[esl-subscribe] onHangup error:", err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
socket.on("error", (err) => {
|
|
253
|
+
console.error("[esl-subscribe] Connection error:", err.message);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
socket.on("close", () => {
|
|
257
|
+
console.log(`[esl-subscribe] Connection closed, reconnecting in ${reconnectDelay / 1000}s`);
|
|
258
|
+
if (!stopped) {
|
|
259
|
+
reconnectTimer = setTimeout(connectAndSubscribe, reconnectDelay);
|
|
260
|
+
// Exponential backoff: 5s → 10s → 20s → 40s → 60s (max)
|
|
261
|
+
if (authFailed) {
|
|
262
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 60000);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
connectAndSubscribe();
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
stop: () => {
|
|
272
|
+
stopped = true;
|
|
273
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
274
|
+
if (socket) socket.destroy();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Check if FreeSWITCH is reachable and authenticated.
|
|
281
|
+
*/
|
|
282
|
+
export async function eslStatus(config: FreeSwitchConfig): Promise<{ connected: boolean; status: string }> {
|
|
283
|
+
try {
|
|
284
|
+
const result = await eslCommand("status", config);
|
|
285
|
+
return { connected: true, status: result.trim().slice(0, 300) };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return { connected: false, status: err instanceof Error ? err.message : String(err) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Rescan external SIP profiles (picks up new/changed gateway XML files).
|
|
293
|
+
*/
|
|
294
|
+
export async function eslRescanGateways(config: FreeSwitchConfig): Promise<boolean> {
|
|
295
|
+
try {
|
|
296
|
+
const result = await eslCommand("sofia profile external rescan", config);
|
|
297
|
+
console.log(`[telephony] ESL rescan result: ${result.trim()}`);
|
|
298
|
+
return true;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error(`[telephony] ESL rescan error:`, err);
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check registration status of a specific gateway.
|
|
307
|
+
*/
|
|
308
|
+
export async function eslGatewayStatus(
|
|
309
|
+
gatewayName: string,
|
|
310
|
+
config: FreeSwitchConfig,
|
|
311
|
+
): Promise<{ registered: boolean; status: string }> {
|
|
312
|
+
try {
|
|
313
|
+
const result = await eslCommand(`sofia status gateway ${gatewayName}`, config);
|
|
314
|
+
const registered = result.includes("REGED") || result.includes("REGISTER");
|
|
315
|
+
return { registered, status: result.trim().slice(0, 300) };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { registered: false, status: err instanceof Error ? err.message : String(err) };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ─── FreeSWITCH Config Sync ──────────────────────────────────────────────────
|
|
2
|
+
// Generates FreeSWITCH gateway XML from HeyHank trunk settings and triggers reload.
|
|
3
|
+
// This is the bridge between "user enters SIP credentials in UI" and "FreeSWITCH registers with provider".
|
|
4
|
+
|
|
5
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { SipTrunkConfig } from "./call-types.js";
|
|
8
|
+
import { getSettings } from "./telephony-store.js";
|
|
9
|
+
import { eslRescanGateways, eslGatewayStatus } from "./esl-client.js";
|
|
10
|
+
|
|
11
|
+
// The Docker volume mount maps this directory into the FreeSWITCH container
|
|
12
|
+
const GATEWAY_DIR = join(process.cwd(), "..", "freeswitch", "conf", "sip_profiles", "external");
|
|
13
|
+
|
|
14
|
+
// Known SIP provider defaults
|
|
15
|
+
const PROVIDER_DEFAULTS: Record<string, { realm: string; proxy: string; tls?: boolean }> = {
|
|
16
|
+
peoplefone: { realm: "sips.peoplefone.at", proxy: "sips.peoplefone.at", tls: true },
|
|
17
|
+
sipgate: { realm: "sipgate.de", proxy: "sipgate.de" },
|
|
18
|
+
easybell: { realm: "sip.easybell.de", proxy: "sip.easybell.de" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate FreeSWITCH gateway XML for a single SIP trunk.
|
|
23
|
+
*/
|
|
24
|
+
function generateGatewayXml(trunk: SipTrunkConfig): string {
|
|
25
|
+
const defaults = PROVIDER_DEFAULTS[trunk.provider];
|
|
26
|
+
const realm = trunk.server || defaults?.realm || trunk.server;
|
|
27
|
+
const proxy = trunk.server || defaults?.proxy || trunk.server;
|
|
28
|
+
const useTls = defaults?.tls || trunk.server?.startsWith("sips.");
|
|
29
|
+
|
|
30
|
+
// Sanitize the gateway name for FreeSWITCH (alphanumeric + underscore + hyphen)
|
|
31
|
+
// Prefix with "heyhank_" to match what call-manager.ts expects
|
|
32
|
+
const gatewayName = `heyhank_${trunk.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase()}`;
|
|
33
|
+
|
|
34
|
+
const tlsParams = useTls ? `
|
|
35
|
+
<param name="register-transport" value="tls"/>
|
|
36
|
+
<param name="contact-params" value="transport=tls"/>` : "";
|
|
37
|
+
|
|
38
|
+
return `<!-- Auto-generated by HeyHank — trunk: ${trunk.name} (${trunk.provider}) -->
|
|
39
|
+
<!-- DO NOT EDIT: This file is regenerated when trunk settings change in HeyHank UI -->
|
|
40
|
+
<include>
|
|
41
|
+
<gateway name="${gatewayName}">
|
|
42
|
+
<param name="username" value="${escapeXml(trunk.username)}"/>
|
|
43
|
+
<param name="password" value="${escapeXml(trunk.password)}"/>
|
|
44
|
+
<param name="realm" value="${escapeXml(realm)}"/>
|
|
45
|
+
<param name="proxy" value="${escapeXml(proxy)}"/>
|
|
46
|
+
<param name="register" value="${trunk.enabled ? "true" : "false"}"/>
|
|
47
|
+
<param name="expire-seconds" value="600"/>
|
|
48
|
+
<param name="caller-id-in-from" value="true"/>
|
|
49
|
+
<param name="codec-prefs" value="PCMA,PCMU"/>${tlsParams}
|
|
50
|
+
</gateway>
|
|
51
|
+
</include>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeXml(str: string): string {
|
|
56
|
+
return str
|
|
57
|
+
.replace(/&/g, "&")
|
|
58
|
+
.replace(/"/g, """)
|
|
59
|
+
.replace(/'/g, "'")
|
|
60
|
+
.replace(/</g, "<")
|
|
61
|
+
.replace(/>/g, ">");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write all trunk configs as FreeSWITCH gateway XML files.
|
|
66
|
+
* Old auto-generated files are cleaned up first.
|
|
67
|
+
*/
|
|
68
|
+
export function syncGatewayConfigs(trunks: SipTrunkConfig[]): { written: string[]; errors: string[] } {
|
|
69
|
+
const written: string[] = [];
|
|
70
|
+
const errors: string[] = [];
|
|
71
|
+
|
|
72
|
+
if (!existsSync(GATEWAY_DIR)) {
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(GATEWAY_DIR, { recursive: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
errors.push(`Cannot create gateway dir: ${err}`);
|
|
77
|
+
return { written, errors };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Remove old auto-generated gateway files (identified by "heyhank_" prefix)
|
|
82
|
+
try {
|
|
83
|
+
const existing = readdirSync(GATEWAY_DIR).filter((f) => f.startsWith("heyhank_") && f.endsWith(".xml"));
|
|
84
|
+
for (const file of existing) {
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(join(GATEWAY_DIR, file));
|
|
87
|
+
} catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
} catch { /* dir might not exist yet */ }
|
|
90
|
+
|
|
91
|
+
// Write new gateway files
|
|
92
|
+
for (const trunk of trunks) {
|
|
93
|
+
if (!trunk.username || !trunk.password || !trunk.server) {
|
|
94
|
+
errors.push(`Trunk "${trunk.name}" skipped: missing credentials`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const gatewayName = trunk.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
|
|
99
|
+
const filename = `heyhank_${gatewayName}.xml`;
|
|
100
|
+
const filepath = join(GATEWAY_DIR, filename);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const xml = generateGatewayXml(trunk);
|
|
104
|
+
writeFileSync(filepath, xml, "utf-8");
|
|
105
|
+
written.push(filename);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
errors.push(`Failed to write ${filename}: ${err}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { written, errors };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sync gateway configs AND tell FreeSWITCH to reload them.
|
|
116
|
+
* This is the main function called when trunk settings change in the UI.
|
|
117
|
+
*/
|
|
118
|
+
export async function syncAndReload(): Promise<{
|
|
119
|
+
synced: boolean;
|
|
120
|
+
reloaded: boolean;
|
|
121
|
+
written: string[];
|
|
122
|
+
errors: string[];
|
|
123
|
+
}> {
|
|
124
|
+
const settings = getSettings();
|
|
125
|
+
const { written, errors } = syncGatewayConfigs(settings.trunks);
|
|
126
|
+
|
|
127
|
+
let reloaded = false;
|
|
128
|
+
|
|
129
|
+
// Try to reload FreeSWITCH gateway profiles via ESL TCP
|
|
130
|
+
if (written.length > 0) {
|
|
131
|
+
try {
|
|
132
|
+
reloaded = await eslRescanGateways(settings.freeswitch);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
errors.push(`FreeSWITCH reload failed: ${err}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { synced: written.length > 0, reloaded, written, errors };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if a specific gateway is registered with FreeSWITCH.
|
|
143
|
+
* Accepts either a trunk ID (UUID) or a gateway name.
|
|
144
|
+
*/
|
|
145
|
+
export async function checkGatewayStatus(
|
|
146
|
+
trunkIdOrName: string,
|
|
147
|
+
): Promise<{ registered: boolean; status: string }> {
|
|
148
|
+
const settings = getSettings();
|
|
149
|
+
// Resolve trunk ID to gateway name
|
|
150
|
+
const trunk = settings.trunks.find((t) => t.id === trunkIdOrName);
|
|
151
|
+
const gwName = trunk
|
|
152
|
+
? `heyhank_${trunk.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase()}`
|
|
153
|
+
: trunkIdOrName;
|
|
154
|
+
return eslGatewayStatus(gwName, settings.freeswitch);
|
|
155
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── Phone number normalization ──────────────────────────────────────────────
|
|
2
|
+
// Inbound SIP providers vary in how they present the caller number:
|
|
3
|
+
// peoplefone (AT): "06508920611" → national format with leading 0
|
|
4
|
+
// sipgate (DE): "+4915112345678" → already E.164
|
|
5
|
+
// some legacy: "004915112345678" → "00" international prefix
|
|
6
|
+
// Contacts are stored in E.164 (e.g. "+436508920611"). Without normalization,
|
|
7
|
+
// the inbound caller-ID lookup fails to match a known contact, and Hank treats
|
|
8
|
+
// the caller as anonymous.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a phone number to E.164 (`+<country><subscriber>`).
|
|
12
|
+
* `defaultCountryCode` is the digit-only country code to assume when the input
|
|
13
|
+
* uses national format (leading "0"). If empty, leading "0" is preserved and
|
|
14
|
+
* the function returns the raw digits — callers should treat that as best-effort.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizePhoneE164(num: string, defaultCountryCode: string = ""): string {
|
|
17
|
+
if (!num) return "";
|
|
18
|
+
// Keep digits and a leading + only
|
|
19
|
+
const stripped = num.trim().replace(/[^\d+]/g, "");
|
|
20
|
+
if (!stripped) return "";
|
|
21
|
+
// Already E.164
|
|
22
|
+
if (stripped.startsWith("+")) return stripped;
|
|
23
|
+
// International prefix "00…" → "+…"
|
|
24
|
+
if (stripped.startsWith("00")) return "+" + stripped.slice(2);
|
|
25
|
+
// National format "0…" → "+<cc>…" if we know the country code
|
|
26
|
+
if (stripped.startsWith("0") && defaultCountryCode) {
|
|
27
|
+
return "+" + defaultCountryCode + stripped.slice(1);
|
|
28
|
+
}
|
|
29
|
+
// Bare digits — assume the caller already included the country code (e.g. peoplefone
|
|
30
|
+
// sometimes sends "43720271025" without the +).
|
|
31
|
+
return "+" + stripped;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pull the country-code digits ("43") out of a configured caller-ID like "+43720271025".
|
|
36
|
+
* E.164 country codes are 1-3 digits; we match against a known prefix list (longest-first)
|
|
37
|
+
* because a naive `\d{1,3}` regex is greedy and would return "437" for "+43720271025".
|
|
38
|
+
*/
|
|
39
|
+
const KNOWN_COUNTRY_CODES = [
|
|
40
|
+
// 3-digit
|
|
41
|
+
"350", "351", "352", "353", "354", "355", "356", "357", "358", "359",
|
|
42
|
+
"370", "371", "372", "373", "374", "375", "376", "377", "378", "380",
|
|
43
|
+
"381", "382", "383", "385", "386", "387", "389",
|
|
44
|
+
"420", "421", "423",
|
|
45
|
+
// 2-digit
|
|
46
|
+
"20", "27", "30", "31", "32", "33", "34", "36", "39", "40", "41", "43", "44",
|
|
47
|
+
"45", "46", "47", "48", "49", "51", "52", "53", "54", "55", "56", "57", "58",
|
|
48
|
+
"60", "61", "62", "63", "64", "65", "66", "81", "82", "84", "86", "90", "91",
|
|
49
|
+
"92", "93", "94", "95", "98",
|
|
50
|
+
// 1-digit
|
|
51
|
+
"1", "7",
|
|
52
|
+
];
|
|
53
|
+
export function extractCountryCode(callerId: string | undefined | null): string {
|
|
54
|
+
if (!callerId) return "";
|
|
55
|
+
const m = callerId.trim().match(/^\+(\d+)/);
|
|
56
|
+
if (!m) return "";
|
|
57
|
+
const digits = m[1];
|
|
58
|
+
// Match longest known prefix first (3 → 2 → 1)
|
|
59
|
+
for (const cc of KNOWN_COUNTRY_CODES) {
|
|
60
|
+
if (digits.startsWith(cc)) return cc;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|