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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -10
  3. package/bin/cli.ts +7 -7
  4. package/bin/ctl.ts +42 -42
  5. package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
  6. package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
  7. package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
  8. package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
  9. package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
  10. package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
  11. package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
  12. package/dist/assets/MediaPage-C48HTTrt.js +1 -0
  13. package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
  14. package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
  15. package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
  16. package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
  17. package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
  18. package/dist/assets/RunsPage-B9UOyO79.js +1 -0
  19. package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
  20. package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
  21. package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
  22. package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
  23. package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
  24. package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
  25. package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
  26. package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
  27. package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
  28. package/dist/assets/index-BkjSoVgn.css +32 -0
  29. package/dist/assets/sw-register-C7NOHtIu.js +1 -0
  30. package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
  31. package/dist/index.html +2 -2
  32. package/dist/sw.js +1 -1
  33. package/package.json +6 -1
  34. package/server/agent-executor.ts +37 -2
  35. package/server/agent-store.ts +3 -3
  36. package/server/agent-types.ts +11 -0
  37. package/server/assistant-store.ts +232 -6
  38. package/server/auth-manager.ts +9 -0
  39. package/server/cache-headers.ts +1 -1
  40. package/server/calendar-service.ts +10 -0
  41. package/server/ceo/document-store.ts +129 -0
  42. package/server/ceo/finance-store.ts +343 -0
  43. package/server/ceo/kpi-store.ts +208 -0
  44. package/server/ceo/memory-import.ts +277 -0
  45. package/server/ceo/news-store.ts +208 -0
  46. package/server/ceo/template-store.ts +134 -0
  47. package/server/ceo/time-tracking-store.ts +227 -0
  48. package/server/claude-auth-monitor.ts +128 -0
  49. package/server/claude-code-worker.ts +86 -0
  50. package/server/claude-session-discovery.ts +74 -1
  51. package/server/cli-launcher.ts +32 -10
  52. package/server/codex-adapter.ts +2 -2
  53. package/server/codex-ws-proxy.cjs +1 -1
  54. package/server/container-manager.ts +4 -4
  55. package/server/content-intelligence/content-engine.ts +1112 -0
  56. package/server/content-intelligence/platform-knowledge.ts +870 -0
  57. package/server/cron-store.ts +3 -3
  58. package/server/embedding-service.ts +49 -0
  59. package/server/event-bus-types.ts +13 -0
  60. package/server/federation/node-store.ts +5 -4
  61. package/server/fs-utils.ts +28 -1
  62. package/server/hank-notifications-store.ts +91 -0
  63. package/server/hank-tool-executor.ts +1835 -0
  64. package/server/hank-tools.ts +2107 -0
  65. package/server/image-pull-manager.ts +2 -2
  66. package/server/index.ts +25 -2
  67. package/server/llm-providers-streaming.ts +541 -0
  68. package/server/llm-providers.ts +12 -0
  69. package/server/marketplace.ts +249 -0
  70. package/server/mcp-registry.ts +158 -0
  71. package/server/memory-service.ts +296 -0
  72. package/server/obsidian-sync.ts +184 -0
  73. package/server/provider-manager.ts +5 -2
  74. package/server/provider-registry.ts +12 -0
  75. package/server/reminder-scheduler.ts +37 -1
  76. package/server/routes/agent-routes.ts +2 -1
  77. package/server/routes/assistant-routes.ts +198 -5
  78. package/server/routes/ceo-finance-kpi-routes.ts +167 -0
  79. package/server/routes/ceo-news-time-routes.ts +137 -0
  80. package/server/routes/ceo-routes.ts +99 -0
  81. package/server/routes/content-routes.ts +116 -0
  82. package/server/routes/email-routes.ts +147 -0
  83. package/server/routes/env-routes.ts +3 -3
  84. package/server/routes/fs-routes.ts +12 -9
  85. package/server/routes/hank-chat-routes.ts +592 -0
  86. package/server/routes/llm-routes.ts +12 -0
  87. package/server/routes/marketplace-routes.ts +63 -0
  88. package/server/routes/media-routes.ts +1 -1
  89. package/server/routes/memory-routes.ts +127 -0
  90. package/server/routes/platform-routes.ts +14 -675
  91. package/server/routes/sandbox-routes.ts +1 -1
  92. package/server/routes/settings-routes.ts +51 -1
  93. package/server/routes/socialmedia-routes.ts +152 -2
  94. package/server/routes/system-routes.ts +2 -2
  95. package/server/routes/team-routes.ts +71 -0
  96. package/server/routes/telephony-routes.ts +98 -18
  97. package/server/routes.ts +36 -9
  98. package/server/session-creation-service.ts +2 -2
  99. package/server/session-orchestrator.ts +54 -2
  100. package/server/session-types.ts +2 -0
  101. package/server/settings-manager.ts +50 -2
  102. package/server/skill-discovery.ts +68 -0
  103. package/server/socialmedia/adapters/browser-adapter.ts +179 -0
  104. package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
  105. package/server/socialmedia/manager.ts +234 -15
  106. package/server/socialmedia/store.ts +51 -1
  107. package/server/socialmedia/types.ts +35 -2
  108. package/server/socialview/browser-manager.ts +150 -0
  109. package/server/socialview/extractors.ts +1298 -0
  110. package/server/socialview/image-describe.ts +188 -0
  111. package/server/socialview/library.ts +119 -0
  112. package/server/socialview/poster.ts +276 -0
  113. package/server/socialview/routes.ts +371 -0
  114. package/server/socialview/style-analyzer.ts +187 -0
  115. package/server/socialview/style-profiles.ts +67 -0
  116. package/server/socialview/types.ts +166 -0
  117. package/server/socialview/vision.ts +127 -0
  118. package/server/socialview/vnc-manager.ts +110 -0
  119. package/server/style-injector.ts +135 -0
  120. package/server/team-service.ts +239 -0
  121. package/server/team-store.ts +75 -0
  122. package/server/team-types.ts +52 -0
  123. package/server/telephony/audio-bridge.ts +281 -35
  124. package/server/telephony/audio-recorder.ts +132 -0
  125. package/server/telephony/call-manager.ts +803 -104
  126. package/server/telephony/call-types.ts +67 -1
  127. package/server/telephony/esl-client.ts +319 -0
  128. package/server/telephony/freeswitch-sync.ts +155 -0
  129. package/server/telephony/phone-utils.ts +63 -0
  130. package/server/telephony/telephony-store.ts +9 -8
  131. package/server/url-validator.ts +82 -0
  132. package/server/vault-markdown.ts +317 -0
  133. package/server/vault-migration.ts +121 -0
  134. package/server/vault-store.ts +466 -0
  135. package/server/vault-watcher.ts +59 -0
  136. package/server/vector-store.ts +210 -0
  137. package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
  138. package/server/voice-pipeline/greeting-cache.ts +200 -0
  139. package/server/voice-pipeline/manager.ts +249 -0
  140. package/server/voice-pipeline/pipeline.ts +335 -0
  141. package/server/voice-pipeline/providers/index.ts +47 -0
  142. package/server/voice-pipeline/providers/llm-internal.ts +527 -0
  143. package/server/voice-pipeline/providers/stt-google.ts +157 -0
  144. package/server/voice-pipeline/providers/tts-google.ts +126 -0
  145. package/server/voice-pipeline/types.ts +247 -0
  146. package/server/ws-bridge-types.ts +6 -1
  147. package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
  148. package/dist/assets/HelpPage-DMfkzERp.js +0 -1
  149. package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
  150. package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
  151. package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
  152. package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
  153. package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
  154. package/dist/assets/index-C8M_PUmX.css +0 -32
  155. package/dist/assets/sw-register-LSSpj6RU.js +0 -1
  156. 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: "ClueCon",
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, "&amp;")
58
+ .replace(/"/g, "&quot;")
59
+ .replace(/'/g, "&apos;")
60
+ .replace(/</g, "&lt;")
61
+ .replace(/>/g, "&gt;");
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
+ }