@diegoaltoworks/talker 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/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/adapters/twilio.d.ts +12 -0
- package/dist/adapters/twilio.d.ts.map +1 -0
- package/dist/core/chat.d.ts +14 -0
- package/dist/core/chat.d.ts.map +1 -0
- package/dist/core/chatbot/client.d.ts +12 -0
- package/dist/core/chatbot/client.d.ts.map +1 -0
- package/dist/core/chatbot/conversations.d.ts +14 -0
- package/dist/core/chatbot/conversations.d.ts.map +1 -0
- package/dist/core/chatbot/index.d.ts +9 -0
- package/dist/core/chatbot/index.d.ts.map +1 -0
- package/dist/core/chatbot/types.d.ts +25 -0
- package/dist/core/chatbot/types.d.ts.map +1 -0
- package/dist/core/context.d.ts +61 -0
- package/dist/core/context.d.ts.map +1 -0
- package/dist/core/context.test.d.ts +2 -0
- package/dist/core/context.test.d.ts.map +1 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.test.d.ts +2 -0
- package/dist/core/errors.test.d.ts.map +1 -0
- package/dist/core/logger.d.ts +11 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/phrases.d.ts +30 -0
- package/dist/core/phrases.d.ts.map +1 -0
- package/dist/core/phrases.test.d.ts +2 -0
- package/dist/core/phrases.test.d.ts.map +1 -0
- package/dist/core/processing/incoming.d.ts +12 -0
- package/dist/core/processing/incoming.d.ts.map +1 -0
- package/dist/core/processing/index.d.ts +10 -0
- package/dist/core/processing/index.d.ts.map +1 -0
- package/dist/core/processing/openai.d.ts +15 -0
- package/dist/core/processing/openai.d.ts.map +1 -0
- package/dist/core/processing/outgoing.d.ts +12 -0
- package/dist/core/processing/outgoing.d.ts.map +1 -0
- package/dist/core/processing/prompts.d.ts +14 -0
- package/dist/core/processing/prompts.d.ts.map +1 -0
- package/dist/core/twiml.d.ts +31 -0
- package/dist/core/twiml.d.ts.map +1 -0
- package/dist/core/twiml.test.d.ts +2 -0
- package/dist/core/twiml.test.d.ts.map +1 -0
- package/dist/core/voice.d.ts +16 -0
- package/dist/core/voice.d.ts.map +1 -0
- package/dist/core/voice.test.d.ts +2 -0
- package/dist/core/voice.test.d.ts.map +1 -0
- package/dist/core/xml.d.ts +8 -0
- package/dist/core/xml.d.ts.map +1 -0
- package/dist/core/xml.test.d.ts +2 -0
- package/dist/core/xml.test.d.ts.map +1 -0
- package/dist/db/client.d.ts +25 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/index.d.ts +12 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/migrate.d.ts +8 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/persist.d.ts +18 -0
- package/dist/db/persist.d.ts.map +1 -0
- package/dist/db/sessions.d.ts +57 -0
- package/dist/db/sessions.d.ts.map +1 -0
- package/dist/flows/index.d.ts +12 -0
- package/dist/flows/index.d.ts.map +1 -0
- package/dist/flows/intent.d.ts +11 -0
- package/dist/flows/intent.d.ts.map +1 -0
- package/dist/flows/loader.d.ts +12 -0
- package/dist/flows/loader.d.ts.map +1 -0
- package/dist/flows/manager.d.ts +14 -0
- package/dist/flows/manager.d.ts.map +1 -0
- package/dist/flows/params.d.ts +12 -0
- package/dist/flows/params.d.ts.map +1 -0
- package/dist/flows/registry.d.ts +35 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/utils.d.ts +12 -0
- package/dist/flows/utils.d.ts.map +1 -0
- package/dist/flows/utils.test.d.ts +2 -0
- package/dist/flows/utils.test.d.ts.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1840 -0
- package/dist/index.mjs +1771 -0
- package/dist/plugin.d.ts +31 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/routes/call/handle-answer.d.ts +10 -0
- package/dist/routes/call/handle-answer.d.ts.map +1 -0
- package/dist/routes/call/handle-initial.d.ts +10 -0
- package/dist/routes/call/handle-initial.d.ts.map +1 -0
- package/dist/routes/call/handle-nospeech.d.ts +10 -0
- package/dist/routes/call/handle-nospeech.d.ts.map +1 -0
- package/dist/routes/call/handle-respond.d.ts +11 -0
- package/dist/routes/call/handle-respond.d.ts.map +1 -0
- package/dist/routes/call/handle-status.d.ts +9 -0
- package/dist/routes/call/handle-status.d.ts.map +1 -0
- package/dist/routes/call/index.d.ts +14 -0
- package/dist/routes/call/index.d.ts.map +1 -0
- package/dist/routes/call/pending.d.ts +20 -0
- package/dist/routes/call/pending.d.ts.map +1 -0
- package/dist/routes/call/processor.d.ts +13 -0
- package/dist/routes/call/processor.d.ts.map +1 -0
- package/dist/routes/sms/handle-incoming.d.ts +10 -0
- package/dist/routes/sms/handle-incoming.d.ts.map +1 -0
- package/dist/routes/sms/index.d.ts +13 -0
- package/dist/routes/sms/index.d.ts.map +1 -0
- package/dist/routes/sms/processor.d.ts +12 -0
- package/dist/routes/sms/processor.d.ts.map +1 -0
- package/dist/standalone.d.ts +44 -0
- package/dist/standalone.d.ts.map +1 -0
- package/dist/types.d.ts +240 -0
- package/dist/types.d.ts.map +1 -0
- package/language/de.json +27 -0
- package/language/en.json +27 -0
- package/language/es.json +27 -0
- package/language/fr.json +27 -0
- package/language/nl.json +27 -0
- package/language/pt.json +27 -0
- package/package.json +103 -0
- package/prompts/incoming.md +88 -0
- package/prompts/outgoing.md +58 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
|
|
22
|
+
// src/routes/call/pending.ts
|
|
23
|
+
var pending_exports = {};
|
|
24
|
+
__export(pending_exports, {
|
|
25
|
+
deletePending: () => deletePending,
|
|
26
|
+
getPending: () => getPending,
|
|
27
|
+
setPending: () => setPending
|
|
28
|
+
});
|
|
29
|
+
function setPending(phoneNumber, query) {
|
|
30
|
+
pendingQueries.set(phoneNumber, query);
|
|
31
|
+
}
|
|
32
|
+
function getPending(phoneNumber) {
|
|
33
|
+
return pendingQueries.get(phoneNumber);
|
|
34
|
+
}
|
|
35
|
+
function deletePending(phoneNumber) {
|
|
36
|
+
pendingQueries.delete(phoneNumber);
|
|
37
|
+
}
|
|
38
|
+
var pendingQueries;
|
|
39
|
+
var init_pending = __esm({
|
|
40
|
+
"src/routes/call/pending.ts"() {
|
|
41
|
+
"use strict";
|
|
42
|
+
pendingQueries = /* @__PURE__ */ new Map();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// src/core/logger.ts
|
|
47
|
+
var isTestEnv = process.env.NODE_ENV === "test" || typeof Bun !== "undefined" && process.argv.some((arg) => arg.includes("test"));
|
|
48
|
+
var isDebug = process.env.DEBUG === "true";
|
|
49
|
+
var isSilent = isTestEnv && !isDebug;
|
|
50
|
+
var timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
51
|
+
var log = (level, message, data) => {
|
|
52
|
+
if (isSilent) return;
|
|
53
|
+
const entry = {
|
|
54
|
+
timestamp: timestamp(),
|
|
55
|
+
level,
|
|
56
|
+
message,
|
|
57
|
+
...data
|
|
58
|
+
};
|
|
59
|
+
console.log(JSON.stringify(entry));
|
|
60
|
+
};
|
|
61
|
+
var logger = {
|
|
62
|
+
info: (message, data) => log("info", message, data),
|
|
63
|
+
warn: (message, data) => log("warn", message, data),
|
|
64
|
+
error: (message, data) => log("error", message, data)
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/core/context.ts
|
|
68
|
+
var contexts = /* @__PURE__ */ new Map();
|
|
69
|
+
var cleanupTimer = null;
|
|
70
|
+
function startCleanup(ttlMs, intervalMs) {
|
|
71
|
+
if (cleanupTimer) return;
|
|
72
|
+
cleanupTimer = setInterval(() => {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [phoneNumber, context] of contexts) {
|
|
75
|
+
if (now - context.lastActivity > ttlMs) {
|
|
76
|
+
contexts.delete(phoneNumber);
|
|
77
|
+
logger.info(`context expired for ${phoneNumber}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, intervalMs);
|
|
81
|
+
}
|
|
82
|
+
function stopCleanup() {
|
|
83
|
+
if (cleanupTimer) {
|
|
84
|
+
clearInterval(cleanupTimer);
|
|
85
|
+
cleanupTimer = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function getOrCreateContext(phoneNumber, channel = "call") {
|
|
89
|
+
let context = contexts.get(phoneNumber);
|
|
90
|
+
if (!context) {
|
|
91
|
+
context = {
|
|
92
|
+
phoneNumber,
|
|
93
|
+
channel,
|
|
94
|
+
detectedLanguage: null,
|
|
95
|
+
messageHistory: [],
|
|
96
|
+
activeFlow: null,
|
|
97
|
+
noSpeechRetries: 0,
|
|
98
|
+
lastPrompt: null,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
lastActivity: Date.now()
|
|
101
|
+
};
|
|
102
|
+
contexts.set(phoneNumber, context);
|
|
103
|
+
logger.info(`context created for ${phoneNumber} (${channel})`);
|
|
104
|
+
}
|
|
105
|
+
context.lastActivity = Date.now();
|
|
106
|
+
return context;
|
|
107
|
+
}
|
|
108
|
+
function getContext(phoneNumber) {
|
|
109
|
+
return contexts.get(phoneNumber);
|
|
110
|
+
}
|
|
111
|
+
function setDetectedLanguage(phoneNumber, language) {
|
|
112
|
+
const context = getOrCreateContext(phoneNumber);
|
|
113
|
+
if (!context.detectedLanguage) {
|
|
114
|
+
context.detectedLanguage = language;
|
|
115
|
+
logger.info(`detected language for ${phoneNumber}: ${language}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function getDetectedLanguage(phoneNumber) {
|
|
119
|
+
return contexts.get(phoneNumber)?.detectedLanguage || null;
|
|
120
|
+
}
|
|
121
|
+
function addMessage(phoneNumber, role, content, channel = "call") {
|
|
122
|
+
const context = getOrCreateContext(phoneNumber, channel);
|
|
123
|
+
context.messageHistory.push({ role, content, timestamp: Date.now() });
|
|
124
|
+
if (context.messageHistory.length > 10) {
|
|
125
|
+
context.messageHistory = context.messageHistory.slice(-10);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function getMessageHistory(phoneNumber) {
|
|
129
|
+
return contexts.get(phoneNumber)?.messageHistory || [];
|
|
130
|
+
}
|
|
131
|
+
function clearContext(phoneNumber) {
|
|
132
|
+
contexts.delete(phoneNumber);
|
|
133
|
+
logger.info("context cleared", { phoneNumber });
|
|
134
|
+
}
|
|
135
|
+
function setActiveFlow(phoneNumber, flowName, params = {}) {
|
|
136
|
+
const context = contexts.get(phoneNumber);
|
|
137
|
+
if (!context) return;
|
|
138
|
+
context.activeFlow = {
|
|
139
|
+
flowName,
|
|
140
|
+
params,
|
|
141
|
+
attempts: 0,
|
|
142
|
+
startedAt: Date.now()
|
|
143
|
+
};
|
|
144
|
+
logger.info(`flow activated for ${phoneNumber}: ${flowName}`);
|
|
145
|
+
}
|
|
146
|
+
function getActiveFlow(phoneNumber) {
|
|
147
|
+
return contexts.get(phoneNumber)?.activeFlow || null;
|
|
148
|
+
}
|
|
149
|
+
function updateFlowParams(phoneNumber, params) {
|
|
150
|
+
const context = contexts.get(phoneNumber);
|
|
151
|
+
if (!context || !context.activeFlow) return;
|
|
152
|
+
context.activeFlow.params = { ...context.activeFlow.params, ...params };
|
|
153
|
+
context.activeFlow.attempts += 1;
|
|
154
|
+
}
|
|
155
|
+
function clearActiveFlow(phoneNumber) {
|
|
156
|
+
const context = contexts.get(phoneNumber);
|
|
157
|
+
if (!context) return;
|
|
158
|
+
logger.info(`flow cleared for ${phoneNumber}: ${context.activeFlow?.flowName}`);
|
|
159
|
+
context.activeFlow = null;
|
|
160
|
+
}
|
|
161
|
+
function incrementNoSpeechRetries(phoneNumber) {
|
|
162
|
+
const context = contexts.get(phoneNumber);
|
|
163
|
+
if (!context) return 0;
|
|
164
|
+
context.noSpeechRetries += 1;
|
|
165
|
+
return context.noSpeechRetries;
|
|
166
|
+
}
|
|
167
|
+
function resetNoSpeechRetries(phoneNumber) {
|
|
168
|
+
const context = contexts.get(phoneNumber);
|
|
169
|
+
if (context && context.noSpeechRetries > 0) {
|
|
170
|
+
context.noSpeechRetries = 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function setLastPrompt(phoneNumber, prompt) {
|
|
174
|
+
const context = getOrCreateContext(phoneNumber);
|
|
175
|
+
context.lastPrompt = prompt;
|
|
176
|
+
}
|
|
177
|
+
function getLastPrompt(phoneNumber) {
|
|
178
|
+
return contexts.get(phoneNumber)?.lastPrompt || null;
|
|
179
|
+
}
|
|
180
|
+
function clearAllContexts() {
|
|
181
|
+
contexts.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/db/client.ts
|
|
185
|
+
import { createClient } from "@libsql/client";
|
|
186
|
+
var client = null;
|
|
187
|
+
function initDbClient(url, authToken) {
|
|
188
|
+
if (client) return;
|
|
189
|
+
try {
|
|
190
|
+
client = createClient({ url, authToken });
|
|
191
|
+
logger.info("database client initialized", {
|
|
192
|
+
url: url.replace(/:[^:]*@/, ":***@")
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logger.error("failed to initialize database client", {
|
|
196
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function getDbClient() {
|
|
201
|
+
return client;
|
|
202
|
+
}
|
|
203
|
+
async function closeDbClient() {
|
|
204
|
+
if (client) {
|
|
205
|
+
try {
|
|
206
|
+
client.close();
|
|
207
|
+
client = null;
|
|
208
|
+
logger.info("database client closed");
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logger.error("error closing database client", {
|
|
211
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/db/migrate.ts
|
|
218
|
+
import { readFileSync } from "node:fs";
|
|
219
|
+
import { join } from "node:path";
|
|
220
|
+
async function runMigrations() {
|
|
221
|
+
const client2 = getDbClient();
|
|
222
|
+
if (!client2) {
|
|
223
|
+
logger.warn("cannot run migrations - database not configured");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const schemaPath = join(__dirname, "schema.sql");
|
|
228
|
+
const schema = readFileSync(schemaPath, "utf-8");
|
|
229
|
+
const statements = schema.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
230
|
+
logger.info("running database migrations", { statementCount: statements.length });
|
|
231
|
+
for (const statement of statements) {
|
|
232
|
+
await client2.execute(statement);
|
|
233
|
+
}
|
|
234
|
+
logger.info("database migrations completed");
|
|
235
|
+
} catch (error) {
|
|
236
|
+
logger.error("database migration failed", {
|
|
237
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
238
|
+
});
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/flows/registry.ts
|
|
244
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
245
|
+
|
|
246
|
+
// src/flows/intent.ts
|
|
247
|
+
var OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
248
|
+
async function detectIntent(deps, phoneNumber, userMessage, flows, conversationContext) {
|
|
249
|
+
const contextStr = conversationContext && conversationContext.length > 0 ? `
|
|
250
|
+
|
|
251
|
+
Recent conversation:
|
|
252
|
+
${conversationContext.join("\n")}` : "";
|
|
253
|
+
const flowDescriptions = Array.from(flows.entries()).map(([id, flow], index) => {
|
|
254
|
+
const keywords = flow.definition.triggerKeywords.join(", ");
|
|
255
|
+
return `${index + 1}. ${id} -- ${flow.definition.description}
|
|
256
|
+
- Keywords: ${keywords}`;
|
|
257
|
+
}).join("\n\n");
|
|
258
|
+
const systemPrompt = `You are an intent detector for a voice assistant.
|
|
259
|
+
|
|
260
|
+
Detect ONE category. Use these EXACT ids (they must match flow ids):
|
|
261
|
+
|
|
262
|
+
${flowDescriptions}
|
|
263
|
+
|
|
264
|
+
${flows.size + 1}. chatbot -- All other questions (general fallback)
|
|
265
|
+
${contextStr}
|
|
266
|
+
|
|
267
|
+
Return JSON with:
|
|
268
|
+
- "intent": one of the ids above (exact string)
|
|
269
|
+
- "confidence": 0.0-1.0 (how certain you are)
|
|
270
|
+
- "reasoning": brief explanation
|
|
271
|
+
|
|
272
|
+
Rules:
|
|
273
|
+
- If uncertain, always return "chatbot" with low confidence`;
|
|
274
|
+
const requestBody = {
|
|
275
|
+
model: deps.openaiModel,
|
|
276
|
+
messages: [
|
|
277
|
+
{ role: "system", content: systemPrompt },
|
|
278
|
+
{ role: "user", content: userMessage }
|
|
279
|
+
],
|
|
280
|
+
temperature: 0.1,
|
|
281
|
+
response_format: { type: "json_object" }
|
|
282
|
+
};
|
|
283
|
+
logger.info("detecting intent", {
|
|
284
|
+
phoneNumber,
|
|
285
|
+
msg: userMessage.substring(0, 160),
|
|
286
|
+
hasContext: !!conversationContext && conversationContext.length > 0
|
|
287
|
+
});
|
|
288
|
+
try {
|
|
289
|
+
const response = await fetch(OPENAI_API_URL, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: {
|
|
292
|
+
Authorization: `Bearer ${deps.openaiApiKey}`,
|
|
293
|
+
"Content-Type": "application/json"
|
|
294
|
+
},
|
|
295
|
+
body: JSON.stringify(requestBody)
|
|
296
|
+
});
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
const error = await response.text();
|
|
299
|
+
logger.error("intent detection error", { phoneNumber, status: response.status, error });
|
|
300
|
+
return { intent: "chatbot", confidence: 0, reasoning: "API error, defaulting to chatbot" };
|
|
301
|
+
}
|
|
302
|
+
const data = await response.json();
|
|
303
|
+
const content = data.choices[0]?.message?.content || "{}";
|
|
304
|
+
const raw = JSON.parse(content);
|
|
305
|
+
const validFlowIds = new Set(flows.keys());
|
|
306
|
+
const normalizedIntent = validFlowIds.has(raw.intent) ? raw.intent : "chatbot";
|
|
307
|
+
const result = {
|
|
308
|
+
intent: normalizedIntent,
|
|
309
|
+
confidence: raw.confidence,
|
|
310
|
+
reasoning: raw.reasoning
|
|
311
|
+
};
|
|
312
|
+
logger.info("intent detected", {
|
|
313
|
+
phoneNumber,
|
|
314
|
+
intent: result.intent,
|
|
315
|
+
confidence: result.confidence,
|
|
316
|
+
reasoning: result.reasoning
|
|
317
|
+
});
|
|
318
|
+
return result;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
logger.error("intent detection exception", {
|
|
321
|
+
phoneNumber,
|
|
322
|
+
error: error instanceof Error ? error.message : String(error)
|
|
323
|
+
});
|
|
324
|
+
return {
|
|
325
|
+
intent: "chatbot",
|
|
326
|
+
confidence: 0,
|
|
327
|
+
reasoning: "Exception occurred, defaulting to chatbot"
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/flows/loader.ts
|
|
333
|
+
import { existsSync, readFileSync as readFileSync2, readdirSync } from "node:fs";
|
|
334
|
+
import { join as join2 } from "node:path";
|
|
335
|
+
async function loadFlowsFromDirectory(flowsDir) {
|
|
336
|
+
const flows = /* @__PURE__ */ new Map();
|
|
337
|
+
if (!existsSync(flowsDir)) {
|
|
338
|
+
logger.warn("flows directory does not exist", { flowsDir });
|
|
339
|
+
return flows;
|
|
340
|
+
}
|
|
341
|
+
const flowDirs = readdirSync(flowsDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).filter((dirent) => ["lib", "tests", "registry"].indexOf(dirent.name) === -1).map((dirent) => dirent.name);
|
|
342
|
+
logger.info("loading flows", { count: flowDirs.length, flowDirs });
|
|
343
|
+
for (const flowName of flowDirs) {
|
|
344
|
+
try {
|
|
345
|
+
const flow = await loadFlow(flowsDir, flowName);
|
|
346
|
+
flows.set(flowName, flow);
|
|
347
|
+
logger.info("flow loaded", { flowName });
|
|
348
|
+
} catch (error) {
|
|
349
|
+
logger.error("failed to load flow", {
|
|
350
|
+
flowName,
|
|
351
|
+
error: error instanceof Error ? error.message : String(error)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
logger.info("flows loaded", { count: flows.size, flows: Array.from(flows.keys()) });
|
|
356
|
+
return flows;
|
|
357
|
+
}
|
|
358
|
+
async function loadFlow(flowsDir, flowName) {
|
|
359
|
+
const flowPath = join2(flowsDir, flowName);
|
|
360
|
+
const definitionPath = join2(flowPath, "flow.json");
|
|
361
|
+
const instructionsPath = join2(flowPath, "instructions.md");
|
|
362
|
+
const handlerPath = join2(flowPath, "handler.ts");
|
|
363
|
+
if (!existsSync(definitionPath)) {
|
|
364
|
+
throw new Error(`flow.json not found for ${flowName}`);
|
|
365
|
+
}
|
|
366
|
+
if (!existsSync(instructionsPath)) {
|
|
367
|
+
throw new Error(`instructions.md not found for ${flowName}`);
|
|
368
|
+
}
|
|
369
|
+
if (!existsSync(handlerPath)) {
|
|
370
|
+
throw new Error(`handler.ts not found for ${flowName}`);
|
|
371
|
+
}
|
|
372
|
+
const definitionContent = readFileSync2(definitionPath, "utf-8");
|
|
373
|
+
const definition = JSON.parse(definitionContent);
|
|
374
|
+
if (definition.id !== flowName) {
|
|
375
|
+
throw new Error(`Flow id mismatch: ${definition.id} !== ${flowName}`);
|
|
376
|
+
}
|
|
377
|
+
if (!definition.triggerKeywords || definition.triggerKeywords.length === 0) {
|
|
378
|
+
throw new Error(`Flow ${flowName} has no trigger keywords`);
|
|
379
|
+
}
|
|
380
|
+
if (!definition.schema || Object.keys(definition.schema).length === 0) {
|
|
381
|
+
throw new Error(`Flow ${flowName} has no schema`);
|
|
382
|
+
}
|
|
383
|
+
const handlerModule = await import(handlerPath);
|
|
384
|
+
const handler = handlerModule.execute;
|
|
385
|
+
if (typeof handler !== "function") {
|
|
386
|
+
throw new Error(`Flow ${flowName} handler.ts must export an 'execute' function`);
|
|
387
|
+
}
|
|
388
|
+
const prefillPath = join2(flowPath, "prefill.ts");
|
|
389
|
+
let prefill;
|
|
390
|
+
if (existsSync(prefillPath)) {
|
|
391
|
+
const prefillModule = await import(prefillPath);
|
|
392
|
+
prefill = prefillModule.prefillFromContext;
|
|
393
|
+
if (typeof prefill !== "function") {
|
|
394
|
+
logger.warn("flow prefill.ts does not export prefillFromContext function", { flowName });
|
|
395
|
+
prefill = void 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
definition,
|
|
400
|
+
handler,
|
|
401
|
+
instructionsPath,
|
|
402
|
+
prefill
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/flows/registry.ts
|
|
407
|
+
var CRITICAL_KEYWORDS = ["human", "person", "agent", "representative", "operator"];
|
|
408
|
+
var FlowRegistry = class {
|
|
409
|
+
flows = /* @__PURE__ */ new Map();
|
|
410
|
+
flowsDir;
|
|
411
|
+
constructor(flowsDir) {
|
|
412
|
+
this.flowsDir = flowsDir;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Load all flows from the flows directory
|
|
416
|
+
*/
|
|
417
|
+
async loadFlows() {
|
|
418
|
+
this.flows = await loadFlowsFromDirectory(this.flowsDir);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Get a flow by name
|
|
422
|
+
*/
|
|
423
|
+
getFlow(name) {
|
|
424
|
+
return this.flows.get(name);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Match user message to a flow using hybrid approach:
|
|
428
|
+
* 1. Critical keyword detection (immediate)
|
|
429
|
+
* 2. LLM intent classification
|
|
430
|
+
*/
|
|
431
|
+
async matchFlow(deps, phoneNumber, message, conversationContext) {
|
|
432
|
+
const lowerMessage = message.toLowerCase();
|
|
433
|
+
for (const keyword of CRITICAL_KEYWORDS) {
|
|
434
|
+
if (lowerMessage.includes(keyword)) {
|
|
435
|
+
const transferFlow = this.flows.get("transfer");
|
|
436
|
+
if (transferFlow) {
|
|
437
|
+
logger.info("flow triggered (critical keyword)", {
|
|
438
|
+
flowId: transferFlow.definition.id,
|
|
439
|
+
keyword
|
|
440
|
+
});
|
|
441
|
+
return transferFlow;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (this.flows.size === 0) return void 0;
|
|
446
|
+
const detection = process.env.NODE_ENV === "test" ? testModeDetectIntent(message) : await detectIntent(deps, phoneNumber, message, this.flows, conversationContext);
|
|
447
|
+
if (detection.confidence >= 0.7) {
|
|
448
|
+
const flow = this.flows.get(detection.intent);
|
|
449
|
+
if (flow) {
|
|
450
|
+
logger.info("flow triggered (LLM detection)", {
|
|
451
|
+
flowId: flow.definition.id,
|
|
452
|
+
intent: detection.intent,
|
|
453
|
+
confidence: detection.confidence
|
|
454
|
+
});
|
|
455
|
+
return flow;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return void 0;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Get all loaded flows
|
|
462
|
+
*/
|
|
463
|
+
getAllFlows() {
|
|
464
|
+
return Array.from(this.flows.values());
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Get flow instructions content
|
|
468
|
+
*/
|
|
469
|
+
getInstructions(flowName) {
|
|
470
|
+
const flow = this.flows.get(flowName);
|
|
471
|
+
if (!flow) {
|
|
472
|
+
throw new Error(`Flow ${flowName} not found`);
|
|
473
|
+
}
|
|
474
|
+
return readFileSync3(flow.instructionsPath, "utf-8");
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
function testModeDetectIntent(message) {
|
|
478
|
+
const m = message.toLowerCase();
|
|
479
|
+
if (m.match(/\b(add|sum|plus)\b/)) {
|
|
480
|
+
return { intent: "addNumbers", confidence: 0.99, reasoning: "test-mode mapping" };
|
|
481
|
+
}
|
|
482
|
+
return { intent: "chatbot", confidence: 0.4, reasoning: "test-mode default" };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/routes/call/index.ts
|
|
486
|
+
import { Hono } from "hono";
|
|
487
|
+
|
|
488
|
+
// src/core/errors.ts
|
|
489
|
+
function getErrorMessage(error) {
|
|
490
|
+
if (error instanceof Error) {
|
|
491
|
+
return error.message;
|
|
492
|
+
}
|
|
493
|
+
if (typeof error === "string") {
|
|
494
|
+
return error;
|
|
495
|
+
}
|
|
496
|
+
return "Unknown error";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/core/phrases.ts
|
|
500
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4 } from "node:fs";
|
|
501
|
+
import { join as join3 } from "node:path";
|
|
502
|
+
var phrasesCache = {};
|
|
503
|
+
function loadPhrases(language, languageDir) {
|
|
504
|
+
const cacheKey = `${languageDir || "default"}:${language}`;
|
|
505
|
+
if (phrasesCache[cacheKey]) {
|
|
506
|
+
return phrasesCache[cacheKey];
|
|
507
|
+
}
|
|
508
|
+
const dirs = [languageDir, join3(__dirname, "../../language")].filter(Boolean);
|
|
509
|
+
for (const dir of dirs) {
|
|
510
|
+
const filePath = join3(dir, `${language}.json`);
|
|
511
|
+
if (existsSync2(filePath)) {
|
|
512
|
+
try {
|
|
513
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
514
|
+
phrasesCache[cacheKey] = JSON.parse(content);
|
|
515
|
+
return phrasesCache[cacheKey];
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (language !== "en") {
|
|
521
|
+
return loadPhrases("en", languageDir);
|
|
522
|
+
}
|
|
523
|
+
throw new Error("English phrase file not found in any search path");
|
|
524
|
+
}
|
|
525
|
+
function getPhrase(language, key, languageDir) {
|
|
526
|
+
const phrases = loadPhrases(language, languageDir);
|
|
527
|
+
return phrases[key];
|
|
528
|
+
}
|
|
529
|
+
function getFarewellPhrase(language, languageDir) {
|
|
530
|
+
const phrases = loadPhrases(language, languageDir);
|
|
531
|
+
const hour = (/* @__PURE__ */ new Date()).getHours();
|
|
532
|
+
if (hour < 12) {
|
|
533
|
+
return phrases.farewell.morning;
|
|
534
|
+
}
|
|
535
|
+
if (hour < 18) {
|
|
536
|
+
return phrases.farewell.afternoon;
|
|
537
|
+
}
|
|
538
|
+
return phrases.farewell.evening;
|
|
539
|
+
}
|
|
540
|
+
function getFlowPhrase(language, key, languageDir) {
|
|
541
|
+
const phrases = loadPhrases(language, languageDir);
|
|
542
|
+
return phrases.flow[key];
|
|
543
|
+
}
|
|
544
|
+
function getSmsPhrase(language, key, languageDir) {
|
|
545
|
+
const phrases = loadPhrases(language, languageDir);
|
|
546
|
+
return phrases.sms[key];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/core/voice.ts
|
|
550
|
+
var DEFAULT_VOICES = {
|
|
551
|
+
en: { voice: "Polly.Brian", language: "en-GB" },
|
|
552
|
+
fr: { voice: "Polly.Mathieu", language: "fr-FR" },
|
|
553
|
+
nl: { voice: "Polly.Ruben", language: "nl-NL" },
|
|
554
|
+
de: { voice: "Polly.Hans", language: "de-DE" },
|
|
555
|
+
es: { voice: "Polly.Enrique", language: "es-ES" },
|
|
556
|
+
pt: { voice: "Polly.Ricardo", language: "pt-BR" }
|
|
557
|
+
};
|
|
558
|
+
var DEFAULT_VOICE = DEFAULT_VOICES.en;
|
|
559
|
+
function getVoiceConfig(language, customVoices) {
|
|
560
|
+
if (customVoices?.[language]) {
|
|
561
|
+
return customVoices[language];
|
|
562
|
+
}
|
|
563
|
+
return DEFAULT_VOICES[language] || DEFAULT_VOICE;
|
|
564
|
+
}
|
|
565
|
+
function getDefaultVoices() {
|
|
566
|
+
return { ...DEFAULT_VOICES };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/core/xml.ts
|
|
570
|
+
function escapeXml(text) {
|
|
571
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/core/twiml.ts
|
|
575
|
+
function gatherTwiml(prompt, language, config, phoneNumber) {
|
|
576
|
+
const { voice, language: lang } = getVoiceConfig(language, config.voices);
|
|
577
|
+
const prefix = config.routePrefix || "";
|
|
578
|
+
if (phoneNumber) {
|
|
579
|
+
setLastPrompt(phoneNumber, prompt);
|
|
580
|
+
}
|
|
581
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
582
|
+
<Response>
|
|
583
|
+
<Say voice="${voice}" language="${lang}">${prompt}</Say>
|
|
584
|
+
<Gather input="speech" action="${prefix}/call/respond" method="POST" speechTimeout="auto" language="${lang}">
|
|
585
|
+
</Gather>
|
|
586
|
+
<Redirect method="POST">${prefix}/call/no-speech</Redirect>
|
|
587
|
+
</Response>`;
|
|
588
|
+
}
|
|
589
|
+
function sayTwiml(message, language, config) {
|
|
590
|
+
const { voice, language: lang } = getVoiceConfig(language, config.voices);
|
|
591
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
592
|
+
<Response>
|
|
593
|
+
<Say voice="${voice}" language="${lang}">${message}</Say>
|
|
594
|
+
</Response>`;
|
|
595
|
+
}
|
|
596
|
+
function transferTwiml(language, config) {
|
|
597
|
+
const { voice, language: lang } = getVoiceConfig(language, config.voices);
|
|
598
|
+
const message = getPhrase(language, "transfer", config.languageDir);
|
|
599
|
+
const transferNumber = config.transferNumber || "";
|
|
600
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
601
|
+
<Response>
|
|
602
|
+
<Say voice="${voice}" language="${lang}">${message}</Say>
|
|
603
|
+
<Dial>${transferNumber}</Dial>
|
|
604
|
+
</Response>`;
|
|
605
|
+
}
|
|
606
|
+
function acknowledgmentTwiml(language, config) {
|
|
607
|
+
const { voice, language: lang } = getVoiceConfig(language, config.voices);
|
|
608
|
+
const message = getPhrase(language, "acknowledgment", config.languageDir);
|
|
609
|
+
const prefix = config.routePrefix || "";
|
|
610
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
611
|
+
<Response>
|
|
612
|
+
<Say voice="${voice}" language="${lang}">${message}</Say>
|
|
613
|
+
<Redirect method="POST">${prefix}/call/answer</Redirect>
|
|
614
|
+
</Response>`;
|
|
615
|
+
}
|
|
616
|
+
function farewellTwiml(language, config) {
|
|
617
|
+
const { voice, language: lang } = getVoiceConfig(language, config.voices);
|
|
618
|
+
const message = getFarewellPhrase(language, config.languageDir);
|
|
619
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
620
|
+
<Response>
|
|
621
|
+
<Say voice="${voice}" language="${lang}">${message}</Say>
|
|
622
|
+
<Hangup/>
|
|
623
|
+
</Response>`;
|
|
624
|
+
}
|
|
625
|
+
function messageTwiml(message) {
|
|
626
|
+
const escapedMessage = escapeXml(message);
|
|
627
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
628
|
+
<Response>
|
|
629
|
+
<Message>${escapedMessage}</Message>
|
|
630
|
+
</Response>`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/routes/call/handle-answer.ts
|
|
634
|
+
init_pending();
|
|
635
|
+
async function handleAnswer(c, config) {
|
|
636
|
+
const body = await c.req.parseBody();
|
|
637
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
638
|
+
const pending = getPending(phoneNumber);
|
|
639
|
+
if (!pending) {
|
|
640
|
+
logger.warn("no pending query found", { phoneNumber });
|
|
641
|
+
const twiml = gatherTwiml(
|
|
642
|
+
getPhrase("en", "lostQuestion", config.languageDir),
|
|
643
|
+
"en",
|
|
644
|
+
config,
|
|
645
|
+
phoneNumber
|
|
646
|
+
);
|
|
647
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
651
|
+
setTimeout(() => reject(new Error("Processing timeout")), 3e4);
|
|
652
|
+
});
|
|
653
|
+
const result = await Promise.race([pending.promise, timeoutPromise]);
|
|
654
|
+
deletePending(phoneNumber);
|
|
655
|
+
return c.text(result.twiml, 200, { "Content-Type": "text/xml" });
|
|
656
|
+
} catch (error) {
|
|
657
|
+
logger.error("answer error", { phoneNumber, error: getErrorMessage(error) });
|
|
658
|
+
deletePending(phoneNumber);
|
|
659
|
+
const twiml = sayTwiml(getPhrase("en", "timeout", config.languageDir), "en", config);
|
|
660
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/routes/call/handle-initial.ts
|
|
665
|
+
async function handleInitialCall(c, config) {
|
|
666
|
+
const body = await c.req.parseBody();
|
|
667
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
668
|
+
logger.info("call started", { phoneNumber });
|
|
669
|
+
clearContext(phoneNumber);
|
|
670
|
+
const { voice, language: lang } = getVoiceConfig("en", config.voices);
|
|
671
|
+
const greeting = getPhrase("en", "greeting", config.languageDir);
|
|
672
|
+
const didNotHear = getPhrase("en", "didNotHear", config.languageDir);
|
|
673
|
+
const prefix = config.routePrefix || "";
|
|
674
|
+
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
675
|
+
<Response>
|
|
676
|
+
<Say voice="${voice}" language="${lang}">${greeting}</Say>
|
|
677
|
+
<Gather input="speech" action="${prefix}/call/respond" method="POST" speechTimeout="auto" language="${lang}">
|
|
678
|
+
</Gather>
|
|
679
|
+
<Say voice="${voice}" language="${lang}">${didNotHear}</Say>
|
|
680
|
+
</Response>`;
|
|
681
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/routes/call/handle-nospeech.ts
|
|
685
|
+
async function handleNoSpeech(c, config) {
|
|
686
|
+
const body = await c.req.parseBody();
|
|
687
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
688
|
+
const maxRetries = config.maxNoSpeechRetries ?? 3;
|
|
689
|
+
const retryCount = incrementNoSpeechRetries(phoneNumber);
|
|
690
|
+
const language = getDetectedLanguage(phoneNumber) || "en";
|
|
691
|
+
if (retryCount > maxRetries) {
|
|
692
|
+
logger.info("max retries reached, ending call", { phoneNumber, retryCount });
|
|
693
|
+
const finalMessage = getPhrase(language, "didNotHearFinal", config.languageDir);
|
|
694
|
+
return c.text(sayTwiml(finalMessage, language, config), 200, { "Content-Type": "text/xml" });
|
|
695
|
+
}
|
|
696
|
+
logger.info("retrying speech gather", { phoneNumber, retryCount, maxRetries });
|
|
697
|
+
const retryMessage = getPhrase(language, "didNotHearRetry", config.languageDir);
|
|
698
|
+
const lastPrompt = getLastPrompt(phoneNumber);
|
|
699
|
+
let prompt;
|
|
700
|
+
if (retryCount === 1) {
|
|
701
|
+
prompt = lastPrompt ? `${retryMessage} ${lastPrompt}` : retryMessage;
|
|
702
|
+
} else {
|
|
703
|
+
prompt = lastPrompt || retryMessage;
|
|
704
|
+
}
|
|
705
|
+
return c.text(gatherTwiml(prompt, language, config, phoneNumber), 200, {
|
|
706
|
+
"Content-Type": "text/xml"
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/db/sessions.ts
|
|
711
|
+
function generateSessionId(phoneNumber, startTime) {
|
|
712
|
+
const sanitized = phoneNumber.replace(/[^0-9]/g, "");
|
|
713
|
+
return `${sanitized}-${startTime}`;
|
|
714
|
+
}
|
|
715
|
+
async function upsertSession(session) {
|
|
716
|
+
const client2 = getDbClient();
|
|
717
|
+
if (!client2) return false;
|
|
718
|
+
try {
|
|
719
|
+
await client2.execute({
|
|
720
|
+
sql: `
|
|
721
|
+
INSERT INTO talker_sessions (
|
|
722
|
+
id, phone_number, channel, reason, language,
|
|
723
|
+
started_at, ended_at, duration_ms, transfer_reason, conversation_id, updated_at
|
|
724
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
725
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
726
|
+
channel = excluded.channel,
|
|
727
|
+
reason = excluded.reason,
|
|
728
|
+
language = excluded.language,
|
|
729
|
+
ended_at = excluded.ended_at,
|
|
730
|
+
duration_ms = excluded.duration_ms,
|
|
731
|
+
transfer_reason = excluded.transfer_reason,
|
|
732
|
+
conversation_id = excluded.conversation_id,
|
|
733
|
+
updated_at = excluded.updated_at
|
|
734
|
+
`,
|
|
735
|
+
args: [
|
|
736
|
+
session.id,
|
|
737
|
+
session.phoneNumber,
|
|
738
|
+
session.channel,
|
|
739
|
+
session.reason,
|
|
740
|
+
session.language,
|
|
741
|
+
session.startedAt,
|
|
742
|
+
session.endedAt,
|
|
743
|
+
session.durationMs,
|
|
744
|
+
session.transferReason || null,
|
|
745
|
+
session.conversationId || null,
|
|
746
|
+
Date.now()
|
|
747
|
+
]
|
|
748
|
+
});
|
|
749
|
+
logger.info("session upserted", {
|
|
750
|
+
sessionId: session.id,
|
|
751
|
+
phoneNumber: session.phoneNumber,
|
|
752
|
+
channel: session.channel
|
|
753
|
+
});
|
|
754
|
+
return true;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
logger.error("failed to upsert session", {
|
|
757
|
+
sessionId: session.id,
|
|
758
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
759
|
+
});
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
async function insertMessage(message) {
|
|
764
|
+
const client2 = getDbClient();
|
|
765
|
+
if (!client2) return false;
|
|
766
|
+
try {
|
|
767
|
+
await client2.execute({
|
|
768
|
+
sql: `INSERT OR IGNORE INTO talker_messages (id, session_id, role, content, timestamp)
|
|
769
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
770
|
+
args: [message.id, message.sessionId, message.role, message.content, message.timestamp]
|
|
771
|
+
});
|
|
772
|
+
return true;
|
|
773
|
+
} catch (error) {
|
|
774
|
+
logger.error("failed to insert message", {
|
|
775
|
+
messageId: message.id,
|
|
776
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
777
|
+
});
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/db/persist.ts
|
|
783
|
+
function persistSession(phoneNumber, channel, conversationId) {
|
|
784
|
+
const context = getContext(phoneNumber);
|
|
785
|
+
const language = getDetectedLanguage(phoneNumber) || "en";
|
|
786
|
+
const messages = getMessageHistory(phoneNumber);
|
|
787
|
+
if (!context || messages.length === 0) return;
|
|
788
|
+
if (!getDbClient()) return;
|
|
789
|
+
const sanitizedPhone = phoneNumber.replace(/[^0-9]/g, "");
|
|
790
|
+
const sessionId = generateSessionId(phoneNumber, context.createdAt);
|
|
791
|
+
const now = Date.now();
|
|
792
|
+
upsertSession({
|
|
793
|
+
id: sessionId,
|
|
794
|
+
phoneNumber: sanitizedPhone,
|
|
795
|
+
channel,
|
|
796
|
+
reason: "ended",
|
|
797
|
+
language,
|
|
798
|
+
startedAt: context.createdAt,
|
|
799
|
+
endedAt: now,
|
|
800
|
+
durationMs: now - context.createdAt,
|
|
801
|
+
conversationId
|
|
802
|
+
}).catch((err) => {
|
|
803
|
+
logger.error("session persistence failed", { phoneNumber, error: getErrorMessage(err) });
|
|
804
|
+
});
|
|
805
|
+
for (const msg of messages) {
|
|
806
|
+
const messageId = `${sessionId}-${msg.timestamp}-${msg.role}`;
|
|
807
|
+
insertMessage({
|
|
808
|
+
id: messageId,
|
|
809
|
+
sessionId,
|
|
810
|
+
role: msg.role,
|
|
811
|
+
content: msg.content,
|
|
812
|
+
timestamp: msg.timestamp
|
|
813
|
+
}).catch((err) => {
|
|
814
|
+
logger.error("message persistence failed", { phoneNumber, error: getErrorMessage(err) });
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function persistFinalSession(phoneNumber, channel, reason, transferReason) {
|
|
819
|
+
const context = getContext(phoneNumber);
|
|
820
|
+
const language = getDetectedLanguage(phoneNumber) || "en";
|
|
821
|
+
if (!context) return;
|
|
822
|
+
if (!getDbClient()) return;
|
|
823
|
+
const sanitizedPhone = phoneNumber.replace(/[^0-9]/g, "");
|
|
824
|
+
const sessionId = generateSessionId(phoneNumber, context.createdAt);
|
|
825
|
+
const now = Date.now();
|
|
826
|
+
upsertSession({
|
|
827
|
+
id: sessionId,
|
|
828
|
+
phoneNumber: sanitizedPhone,
|
|
829
|
+
channel,
|
|
830
|
+
reason,
|
|
831
|
+
language,
|
|
832
|
+
startedAt: context.createdAt,
|
|
833
|
+
endedAt: now,
|
|
834
|
+
durationMs: now - context.createdAt,
|
|
835
|
+
transferReason
|
|
836
|
+
}).catch((err) => {
|
|
837
|
+
logger.error("final session persistence failed", {
|
|
838
|
+
phoneNumber,
|
|
839
|
+
error: getErrorMessage(err)
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/routes/call/handle-respond.ts
|
|
845
|
+
init_pending();
|
|
846
|
+
|
|
847
|
+
// src/core/chatbot/conversations.ts
|
|
848
|
+
var conversations = /* @__PURE__ */ new Map();
|
|
849
|
+
function getOrCreateConversation(phoneNumber, systemMessage) {
|
|
850
|
+
const existing = conversations.get(phoneNumber);
|
|
851
|
+
if (existing) {
|
|
852
|
+
existing.lastActivityAt = Date.now();
|
|
853
|
+
return existing;
|
|
854
|
+
}
|
|
855
|
+
const conversation = {
|
|
856
|
+
conversationId: crypto.randomUUID(),
|
|
857
|
+
chatHistory: systemMessage ? [{ role: "system", content: systemMessage }] : [],
|
|
858
|
+
createdAt: Date.now(),
|
|
859
|
+
lastActivityAt: Date.now()
|
|
860
|
+
};
|
|
861
|
+
conversations.set(phoneNumber, conversation);
|
|
862
|
+
logger.info("chatbot conversation created", {
|
|
863
|
+
phoneNumber,
|
|
864
|
+
conversationId: conversation.conversationId
|
|
865
|
+
});
|
|
866
|
+
return conversation;
|
|
867
|
+
}
|
|
868
|
+
function addUserMessage(phoneNumber, content, systemMessage) {
|
|
869
|
+
const conversation = getOrCreateConversation(phoneNumber, systemMessage);
|
|
870
|
+
conversation.chatHistory.push({ role: "user", content });
|
|
871
|
+
conversation.lastActivityAt = Date.now();
|
|
872
|
+
}
|
|
873
|
+
function addBotMessage(phoneNumber, content, systemMessage) {
|
|
874
|
+
const conversation = getOrCreateConversation(phoneNumber, systemMessage);
|
|
875
|
+
conversation.chatHistory.push({ role: "assistant", content });
|
|
876
|
+
conversation.lastActivityAt = Date.now();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/core/chatbot/client.ts
|
|
880
|
+
var DEFAULT_SYSTEM_MESSAGE = "You are a voice assistant. Always refer to the subject in third person.";
|
|
881
|
+
async function chatViaHTTP(config, phoneNumber, message) {
|
|
882
|
+
const systemMessage = config.systemMessage || DEFAULT_SYSTEM_MESSAGE;
|
|
883
|
+
addUserMessage(phoneNumber, message, systemMessage);
|
|
884
|
+
const conversation = getOrCreateConversation(phoneNumber, systemMessage);
|
|
885
|
+
const headers = {
|
|
886
|
+
"Content-Type": "application/json"
|
|
887
|
+
};
|
|
888
|
+
if (config.apiKey) {
|
|
889
|
+
headers["x-api-key"] = config.apiKey;
|
|
890
|
+
}
|
|
891
|
+
const body = JSON.stringify({
|
|
892
|
+
messages: conversation.chatHistory
|
|
893
|
+
});
|
|
894
|
+
logger.info("chatbot request", {
|
|
895
|
+
phoneNumber,
|
|
896
|
+
conversationId: conversation.conversationId,
|
|
897
|
+
message: message.substring(0, 100),
|
|
898
|
+
historyLength: conversation.chatHistory.length
|
|
899
|
+
});
|
|
900
|
+
try {
|
|
901
|
+
const response = await fetch(config.url, {
|
|
902
|
+
method: "POST",
|
|
903
|
+
headers,
|
|
904
|
+
body
|
|
905
|
+
});
|
|
906
|
+
if (!response.ok) {
|
|
907
|
+
const errorText = await response.text();
|
|
908
|
+
logger.error("chatbot error", {
|
|
909
|
+
status: response.status,
|
|
910
|
+
statusText: response.statusText,
|
|
911
|
+
error: errorText
|
|
912
|
+
});
|
|
913
|
+
throw new Error(`Chatbot API error: ${response.status} - ${errorText}`);
|
|
914
|
+
}
|
|
915
|
+
const data = await response.json();
|
|
916
|
+
logger.info("chatbot response", {
|
|
917
|
+
phoneNumber,
|
|
918
|
+
conversationId: conversation.conversationId,
|
|
919
|
+
reply: data.reply.substring(0, 200)
|
|
920
|
+
});
|
|
921
|
+
const answer = data.reply || "Sorry, I could not process your request.";
|
|
922
|
+
addBotMessage(phoneNumber, answer, systemMessage);
|
|
923
|
+
return answer;
|
|
924
|
+
} catch (error) {
|
|
925
|
+
logger.error("chatbot request failed", {
|
|
926
|
+
phoneNumber,
|
|
927
|
+
error: getErrorMessage(error)
|
|
928
|
+
});
|
|
929
|
+
return "Sorry, I encountered an error processing your question.";
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/core/chat.ts
|
|
934
|
+
async function chat(deps, phoneNumber, message) {
|
|
935
|
+
if (deps.config.chatFn) {
|
|
936
|
+
return deps.config.chatFn(phoneNumber, message);
|
|
937
|
+
}
|
|
938
|
+
if (deps.config.chatbot?.url) {
|
|
939
|
+
return chatViaHTTP(deps.config.chatbot, phoneNumber, message);
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
const { completeOnce } = await import("@diegoaltoworks/chatter");
|
|
943
|
+
const { client: client2, store, prompts } = deps.chatter;
|
|
944
|
+
const ragContext = await store.query(message, 6, ["base", "public"]);
|
|
945
|
+
const system = [
|
|
946
|
+
prompts.baseSystemRules,
|
|
947
|
+
prompts.publicPersona,
|
|
948
|
+
`Context:
|
|
949
|
+
${ragContext.join("\n\n")}`
|
|
950
|
+
].join("\n\n");
|
|
951
|
+
const result = await completeOnce({
|
|
952
|
+
client: client2,
|
|
953
|
+
system,
|
|
954
|
+
messages: [{ role: "user", content: message }]
|
|
955
|
+
});
|
|
956
|
+
return result.content;
|
|
957
|
+
} catch (error) {
|
|
958
|
+
logger.error("chat error", { phoneNumber, error: getErrorMessage(error) });
|
|
959
|
+
return "Sorry, I encountered an error processing your question.";
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/core/processing/openai.ts
|
|
964
|
+
var OPENAI_API_URL2 = "https://api.openai.com/v1/chat/completions";
|
|
965
|
+
async function callOpenAI(deps, systemPrompt, userMessage, context) {
|
|
966
|
+
const headers = {
|
|
967
|
+
Authorization: `Bearer ${deps.openaiApiKey}`,
|
|
968
|
+
"Content-Type": "application/json"
|
|
969
|
+
};
|
|
970
|
+
const requestBody = {
|
|
971
|
+
model: deps.openaiModel,
|
|
972
|
+
messages: [
|
|
973
|
+
{ role: "system", content: systemPrompt },
|
|
974
|
+
{ role: "user", content: userMessage }
|
|
975
|
+
],
|
|
976
|
+
temperature: 0.3
|
|
977
|
+
};
|
|
978
|
+
logger.info(`${context.stage} request`, {
|
|
979
|
+
phoneNumber: context.phoneNumber,
|
|
980
|
+
stage: context.stage,
|
|
981
|
+
input: userMessage.substring(0, 160) + (userMessage.length > 160 ? "..." : "")
|
|
982
|
+
});
|
|
983
|
+
const response = await fetch(OPENAI_API_URL2, {
|
|
984
|
+
method: "POST",
|
|
985
|
+
headers,
|
|
986
|
+
body: JSON.stringify(requestBody)
|
|
987
|
+
});
|
|
988
|
+
if (!response.ok) {
|
|
989
|
+
const error = await response.text();
|
|
990
|
+
logger.error(`${context.stage} openai error`, {
|
|
991
|
+
phoneNumber: context.phoneNumber,
|
|
992
|
+
status: response.status,
|
|
993
|
+
error
|
|
994
|
+
});
|
|
995
|
+
throw new Error(`OpenAI API error: ${response.status}`);
|
|
996
|
+
}
|
|
997
|
+
const data = await response.json();
|
|
998
|
+
const content = data.choices[0]?.message?.content || "";
|
|
999
|
+
logger.info(`${context.stage} response`, {
|
|
1000
|
+
phoneNumber: context.phoneNumber,
|
|
1001
|
+
stage: context.stage,
|
|
1002
|
+
output: content.substring(0, 160) + (content.length > 160 ? "..." : ""),
|
|
1003
|
+
tokens: data.usage?.total_tokens
|
|
1004
|
+
});
|
|
1005
|
+
return content;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/core/processing/prompts.ts
|
|
1009
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
1010
|
+
import { join as join4 } from "node:path";
|
|
1011
|
+
var incomingPrompt = null;
|
|
1012
|
+
var outgoingPrompt = null;
|
|
1013
|
+
function loadPromptFile(filename, customPath) {
|
|
1014
|
+
if (customPath && existsSync3(customPath)) {
|
|
1015
|
+
return readFileSync5(customPath, "utf-8");
|
|
1016
|
+
}
|
|
1017
|
+
const builtinPath = join4(__dirname, "../../../prompts", filename);
|
|
1018
|
+
if (existsSync3(builtinPath)) {
|
|
1019
|
+
return readFileSync5(builtinPath, "utf-8");
|
|
1020
|
+
}
|
|
1021
|
+
throw new Error(`Prompt file not found: ${filename}`);
|
|
1022
|
+
}
|
|
1023
|
+
function getIncomingPrompt(deps) {
|
|
1024
|
+
if (!incomingPrompt) {
|
|
1025
|
+
try {
|
|
1026
|
+
incomingPrompt = loadPromptFile("incoming.md", deps.config.processing?.incomingPromptPath);
|
|
1027
|
+
logger.info("incoming prompt loaded");
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
logger.error("failed to load incoming prompt", { error: getErrorMessage(error) });
|
|
1030
|
+
incomingPrompt = "Return JSON with shouldTransfer (boolean), shouldEndCall (boolean), detectedLanguage (string), processedMessage (string)";
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return incomingPrompt;
|
|
1034
|
+
}
|
|
1035
|
+
function getOutgoingPrompt(deps) {
|
|
1036
|
+
if (!outgoingPrompt) {
|
|
1037
|
+
try {
|
|
1038
|
+
outgoingPrompt = loadPromptFile("outgoing.md", deps.config.processing?.outgoingPromptPath);
|
|
1039
|
+
logger.info("outgoing prompt loaded");
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
logger.error("failed to load outgoing prompt", { error: getErrorMessage(error) });
|
|
1042
|
+
outgoingPrompt = "Make this response phone-friendly and brief.";
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return outgoingPrompt;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/core/processing/incoming.ts
|
|
1049
|
+
async function processIncoming(deps, phoneNumber, userMessage, channel = "call") {
|
|
1050
|
+
try {
|
|
1051
|
+
getOrCreateContext(phoneNumber, channel);
|
|
1052
|
+
const history = getMessageHistory(phoneNumber);
|
|
1053
|
+
addMessage(phoneNumber, "user", userMessage, channel);
|
|
1054
|
+
let contextualMessage = userMessage;
|
|
1055
|
+
if (history.length > 0) {
|
|
1056
|
+
const recentHistory = history.slice(-4);
|
|
1057
|
+
const historyText = recentHistory.map((m) => `${m.role === "user" ? "Customer" : "Bot"}: ${m.content}`).join("\n");
|
|
1058
|
+
contextualMessage = `CONVERSATION HISTORY:
|
|
1059
|
+
${historyText}
|
|
1060
|
+
|
|
1061
|
+
CURRENT MESSAGE:
|
|
1062
|
+
${userMessage}`;
|
|
1063
|
+
}
|
|
1064
|
+
const result = await callOpenAI(deps, getIncomingPrompt(deps), contextualMessage, {
|
|
1065
|
+
phoneNumber,
|
|
1066
|
+
stage: "incoming"
|
|
1067
|
+
});
|
|
1068
|
+
const cleanedResult = result.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
1069
|
+
const parsed = JSON.parse(cleanedResult);
|
|
1070
|
+
const detectedLang = parsed.detectedLanguage || "en";
|
|
1071
|
+
setDetectedLanguage(phoneNumber, detectedLang);
|
|
1072
|
+
const storedLanguage = getDetectedLanguage(phoneNumber) || "en";
|
|
1073
|
+
logger.info("INCOMING", {
|
|
1074
|
+
phoneNumber,
|
|
1075
|
+
channel,
|
|
1076
|
+
in: userMessage,
|
|
1077
|
+
out: parsed.processedMessage,
|
|
1078
|
+
lang: storedLanguage,
|
|
1079
|
+
...parsed.shouldTransfer && { transfer: true },
|
|
1080
|
+
...parsed.shouldEndCall && { endCall: true }
|
|
1081
|
+
});
|
|
1082
|
+
return {
|
|
1083
|
+
shouldTransfer: parsed.shouldTransfer ?? false,
|
|
1084
|
+
shouldEndCall: parsed.shouldEndCall ?? false,
|
|
1085
|
+
detectedLanguage: storedLanguage,
|
|
1086
|
+
processedMessage: parsed.processedMessage || userMessage
|
|
1087
|
+
};
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
logger.error("incoming processing error", {
|
|
1090
|
+
phoneNumber,
|
|
1091
|
+
error: getErrorMessage(error)
|
|
1092
|
+
});
|
|
1093
|
+
return {
|
|
1094
|
+
shouldTransfer: false,
|
|
1095
|
+
shouldEndCall: false,
|
|
1096
|
+
detectedLanguage: getDetectedLanguage(phoneNumber) || "en",
|
|
1097
|
+
processedMessage: userMessage
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/core/processing/outgoing.ts
|
|
1103
|
+
async function processOutgoing(deps, phoneNumber, botResponse, channel = "call") {
|
|
1104
|
+
try {
|
|
1105
|
+
const language = getDetectedLanguage(phoneNumber) || "en";
|
|
1106
|
+
const prompt = getOutgoingPrompt(deps);
|
|
1107
|
+
const promptWithContext = `${prompt}
|
|
1108
|
+
|
|
1109
|
+
---
|
|
1110
|
+
Channel: ${channel}
|
|
1111
|
+
Respond in: ${language}`;
|
|
1112
|
+
const result = await callOpenAI(deps, promptWithContext, botResponse, {
|
|
1113
|
+
phoneNumber,
|
|
1114
|
+
stage: "outgoing"
|
|
1115
|
+
});
|
|
1116
|
+
addMessage(phoneNumber, "assistant", result || botResponse, channel);
|
|
1117
|
+
logger.info("OUTGOING", {
|
|
1118
|
+
phoneNumber,
|
|
1119
|
+
channel,
|
|
1120
|
+
in: botResponse,
|
|
1121
|
+
out: result,
|
|
1122
|
+
lang: language
|
|
1123
|
+
});
|
|
1124
|
+
return result || botResponse;
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
logger.error("outgoing processing error", {
|
|
1127
|
+
phoneNumber,
|
|
1128
|
+
channel,
|
|
1129
|
+
error: getErrorMessage(error)
|
|
1130
|
+
});
|
|
1131
|
+
return botResponse;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// src/flows/params.ts
|
|
1136
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1137
|
+
var OPENAI_API_URL3 = "https://api.openai.com/v1/chat/completions";
|
|
1138
|
+
async function extractParameters(deps, flow, phoneNumber, userMessage, existingParams) {
|
|
1139
|
+
const instructions = readFileSync6(flow.instructionsPath, "utf-8");
|
|
1140
|
+
const schema = flow.definition.schema;
|
|
1141
|
+
const properties = schema.properties || {};
|
|
1142
|
+
const required = schema.required || [];
|
|
1143
|
+
const schemaDescription = Object.entries(properties).map(
|
|
1144
|
+
([key, field]) => `- ${key} (${field.type}${required.includes(key) ? ", required" : ""}): ${field.description || ""}`
|
|
1145
|
+
).join("\n");
|
|
1146
|
+
const existingParamsStr = Object.keys(existingParams).length > 0 ? `
|
|
1147
|
+
|
|
1148
|
+
Already collected:
|
|
1149
|
+
${JSON.stringify(existingParams, null, 2)}` : "";
|
|
1150
|
+
const missingFields = required.filter((key) => !existingParams[key]);
|
|
1151
|
+
const currentlyAsking = missingFields.length > 0 ? missingFields[0] : null;
|
|
1152
|
+
const now = /* @__PURE__ */ new Date();
|
|
1153
|
+
const todayStr = now.toISOString().split("T")[0];
|
|
1154
|
+
const currentYear = now.getFullYear();
|
|
1155
|
+
const currentMonth = now.getMonth() + 1;
|
|
1156
|
+
const nextYear = currentYear + 1;
|
|
1157
|
+
const tomorrow = new Date(now);
|
|
1158
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
1159
|
+
const tomorrowStr = tomorrow.toISOString().split("T")[0];
|
|
1160
|
+
const dateContext = `
|
|
1161
|
+
|
|
1162
|
+
## CRITICAL DATE CONTEXT
|
|
1163
|
+
TODAY IS: ${todayStr} (year ${currentYear}, month ${currentMonth})
|
|
1164
|
+
|
|
1165
|
+
**DATE EXTRACTION RULES:**
|
|
1166
|
+
- "tomorrow" = ${tomorrowStr}
|
|
1167
|
+
- Months 1-${currentMonth} = year ${nextYear}
|
|
1168
|
+
- Months ${currentMonth + 1}-12 = year ${currentYear}
|
|
1169
|
+
- NEVER output a date before ${todayStr}`;
|
|
1170
|
+
const currentFieldHint = currentlyAsking ? `
|
|
1171
|
+
|
|
1172
|
+
## IMPORTANT CONTEXT
|
|
1173
|
+
We just asked the user for: "${currentlyAsking}"
|
|
1174
|
+
If the user's response is a simple value, interpret it as the value for the "${currentlyAsking}" parameter.` : "";
|
|
1175
|
+
const systemPrompt = `${instructions}
|
|
1176
|
+
${dateContext}
|
|
1177
|
+
|
|
1178
|
+
## Schema
|
|
1179
|
+
${schemaDescription}
|
|
1180
|
+
${existingParamsStr}
|
|
1181
|
+
${currentFieldHint}
|
|
1182
|
+
|
|
1183
|
+
## Task
|
|
1184
|
+
Extract any parameters from the user's message that match the schema above.
|
|
1185
|
+
Return JSON with:
|
|
1186
|
+
- "extractedParams": object with any NEW parameters found
|
|
1187
|
+
- "allParamsFilled": boolean indicating if ALL required params are now complete
|
|
1188
|
+
|
|
1189
|
+
If not all params are filled, do NOT include "nextPrompt" - we'll generate that separately.`;
|
|
1190
|
+
const requestBody = {
|
|
1191
|
+
model: deps.openaiModel,
|
|
1192
|
+
messages: [
|
|
1193
|
+
{ role: "system", content: systemPrompt },
|
|
1194
|
+
{ role: "user", content: userMessage }
|
|
1195
|
+
],
|
|
1196
|
+
temperature: 0.2,
|
|
1197
|
+
response_format: { type: "json_object" }
|
|
1198
|
+
};
|
|
1199
|
+
logger.info("flow extracting params", {
|
|
1200
|
+
phoneNumber,
|
|
1201
|
+
flow: flow.definition.id,
|
|
1202
|
+
msg: userMessage.substring(0, 160),
|
|
1203
|
+
existing: Object.keys(existingParams).length > 0 ? existingParams : void 0,
|
|
1204
|
+
askingFor: currentlyAsking || "none"
|
|
1205
|
+
});
|
|
1206
|
+
const response = await fetch(OPENAI_API_URL3, {
|
|
1207
|
+
method: "POST",
|
|
1208
|
+
headers: {
|
|
1209
|
+
Authorization: `Bearer ${deps.openaiApiKey}`,
|
|
1210
|
+
"Content-Type": "application/json"
|
|
1211
|
+
},
|
|
1212
|
+
body: JSON.stringify(requestBody)
|
|
1213
|
+
});
|
|
1214
|
+
if (!response.ok) {
|
|
1215
|
+
const error = await response.text();
|
|
1216
|
+
logger.error("flow parameter extraction error", {
|
|
1217
|
+
phoneNumber,
|
|
1218
|
+
flowName: flow.definition.id,
|
|
1219
|
+
status: response.status,
|
|
1220
|
+
error
|
|
1221
|
+
});
|
|
1222
|
+
throw new Error(`OpenAI API error: ${response.status}`);
|
|
1223
|
+
}
|
|
1224
|
+
const data = await response.json();
|
|
1225
|
+
const content = data.choices[0]?.message?.content || "{}";
|
|
1226
|
+
const result = JSON.parse(content);
|
|
1227
|
+
const mergedParams = { ...existingParams, ...result.extractedParams || {} };
|
|
1228
|
+
const requiredFields = schema.required || [];
|
|
1229
|
+
const allRequiredFilled = requiredFields.every(
|
|
1230
|
+
(key) => mergedParams[key] !== void 0 && mergedParams[key] !== null
|
|
1231
|
+
);
|
|
1232
|
+
logger.info("flow params extracted", {
|
|
1233
|
+
phoneNumber,
|
|
1234
|
+
flow: flow.definition.id,
|
|
1235
|
+
extracted: result.extractedParams,
|
|
1236
|
+
complete: allRequiredFilled
|
|
1237
|
+
});
|
|
1238
|
+
let nextPrompt;
|
|
1239
|
+
if (!allRequiredFilled) {
|
|
1240
|
+
const missingKey = requiredFields.find(
|
|
1241
|
+
(key) => mergedParams[key] === void 0 || mergedParams[key] === null
|
|
1242
|
+
);
|
|
1243
|
+
if (missingKey) {
|
|
1244
|
+
nextPrompt = `What is the ${missingKey.replace(/([A-Z])/g, " $1").toLowerCase()}?`;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
extractedParams: result.extractedParams || {},
|
|
1249
|
+
allParamsFilled: allRequiredFilled,
|
|
1250
|
+
nextPrompt
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/flows/utils.ts
|
|
1255
|
+
var CANCELLATION_KEYWORDS = ["cancel", "nevermind", "stop", "forget it", "quit"];
|
|
1256
|
+
function shouldExitFlow(message) {
|
|
1257
|
+
const lowerMessage = message.toLowerCase();
|
|
1258
|
+
return CANCELLATION_KEYWORDS.some((keyword) => lowerMessage.includes(keyword));
|
|
1259
|
+
}
|
|
1260
|
+
function getExitMessage(language, languageDir) {
|
|
1261
|
+
return getFlowPhrase(language, "cancelled", languageDir);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/flows/manager.ts
|
|
1265
|
+
async function processFlow(deps, registry, phoneNumber, userMessage, channel) {
|
|
1266
|
+
const activeFlow = getActiveFlow(phoneNumber);
|
|
1267
|
+
if (activeFlow && shouldExitFlow(userMessage)) {
|
|
1268
|
+
logger.info("flow cancelled", { phoneNumber, flow: activeFlow.flowName });
|
|
1269
|
+
clearActiveFlow(phoneNumber);
|
|
1270
|
+
const language = getDetectedLanguage(phoneNumber) || "en";
|
|
1271
|
+
return {
|
|
1272
|
+
isFlowActive: false,
|
|
1273
|
+
response: getExitMessage(language, deps.config.languageDir),
|
|
1274
|
+
flowCompleted: false
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
if (activeFlow) {
|
|
1278
|
+
const flow = registry.getFlow(activeFlow.flowName);
|
|
1279
|
+
if (!flow) {
|
|
1280
|
+
logger.error("active flow not found in registry", {
|
|
1281
|
+
phoneNumber,
|
|
1282
|
+
flowName: activeFlow.flowName
|
|
1283
|
+
});
|
|
1284
|
+
clearActiveFlow(phoneNumber);
|
|
1285
|
+
return { isFlowActive: false, response: "", flowCompleted: false };
|
|
1286
|
+
}
|
|
1287
|
+
try {
|
|
1288
|
+
const extraction = await extractParameters(
|
|
1289
|
+
deps,
|
|
1290
|
+
flow,
|
|
1291
|
+
phoneNumber,
|
|
1292
|
+
userMessage,
|
|
1293
|
+
activeFlow.params
|
|
1294
|
+
);
|
|
1295
|
+
updateFlowParams(phoneNumber, extraction.extractedParams);
|
|
1296
|
+
if (extraction.allParamsFilled) {
|
|
1297
|
+
logger.info("flow executing", {
|
|
1298
|
+
phoneNumber,
|
|
1299
|
+
flow: activeFlow.flowName,
|
|
1300
|
+
params: { ...activeFlow.params, ...extraction.extractedParams }
|
|
1301
|
+
});
|
|
1302
|
+
const result = await flow.handler(
|
|
1303
|
+
{ ...activeFlow.params, ...extraction.extractedParams },
|
|
1304
|
+
{ phoneNumber, channel }
|
|
1305
|
+
);
|
|
1306
|
+
clearActiveFlow(phoneNumber);
|
|
1307
|
+
return {
|
|
1308
|
+
isFlowActive: false,
|
|
1309
|
+
response: result.say,
|
|
1310
|
+
flowCompleted: true,
|
|
1311
|
+
smsContent: result.sms,
|
|
1312
|
+
flowSuccess: result.success
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
isFlowActive: true,
|
|
1317
|
+
response: extraction.nextPrompt || "Could you provide more details?",
|
|
1318
|
+
flowCompleted: false
|
|
1319
|
+
};
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
logger.error("flow error", {
|
|
1322
|
+
phoneNumber,
|
|
1323
|
+
flow: activeFlow.flowName,
|
|
1324
|
+
error: getErrorMessage(error)
|
|
1325
|
+
});
|
|
1326
|
+
clearActiveFlow(phoneNumber);
|
|
1327
|
+
return {
|
|
1328
|
+
isFlowActive: false,
|
|
1329
|
+
response: "Sorry, I encountered an error. Let's start over.",
|
|
1330
|
+
flowCompleted: false
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
const conversationHistory = getMessageHistory(phoneNumber);
|
|
1335
|
+
const conversationContext = conversationHistory.slice(-5).map((msg) => `${msg.role === "assistant" ? "Bot" : "User"}: ${msg.content}`);
|
|
1336
|
+
const matchedFlow = await registry.matchFlow(deps, phoneNumber, userMessage, conversationContext);
|
|
1337
|
+
if (matchedFlow) {
|
|
1338
|
+
const globalParams = matchedFlow.prefill ? matchedFlow.prefill(phoneNumber, {}) : {};
|
|
1339
|
+
logger.info("flow started", {
|
|
1340
|
+
phoneNumber,
|
|
1341
|
+
flow: matchedFlow.definition.id,
|
|
1342
|
+
msg: userMessage.substring(0, 160)
|
|
1343
|
+
});
|
|
1344
|
+
try {
|
|
1345
|
+
const extraction = await extractParameters(
|
|
1346
|
+
deps,
|
|
1347
|
+
matchedFlow,
|
|
1348
|
+
phoneNumber,
|
|
1349
|
+
userMessage,
|
|
1350
|
+
globalParams
|
|
1351
|
+
);
|
|
1352
|
+
const mergedParams = { ...globalParams, ...extraction.extractedParams };
|
|
1353
|
+
setActiveFlow(phoneNumber, matchedFlow.definition.id, mergedParams);
|
|
1354
|
+
if (extraction.allParamsFilled) {
|
|
1355
|
+
logger.info("flow instant complete", {
|
|
1356
|
+
phoneNumber,
|
|
1357
|
+
flow: matchedFlow.definition.id,
|
|
1358
|
+
params: mergedParams
|
|
1359
|
+
});
|
|
1360
|
+
const result = await matchedFlow.handler(mergedParams, { phoneNumber, channel });
|
|
1361
|
+
clearActiveFlow(phoneNumber);
|
|
1362
|
+
return {
|
|
1363
|
+
isFlowActive: false,
|
|
1364
|
+
response: result.say,
|
|
1365
|
+
flowCompleted: true,
|
|
1366
|
+
smsContent: result.sms,
|
|
1367
|
+
flowSuccess: result.success
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
return {
|
|
1371
|
+
isFlowActive: true,
|
|
1372
|
+
response: extraction.nextPrompt || "Could you provide more details?",
|
|
1373
|
+
flowCompleted: false
|
|
1374
|
+
};
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
logger.error("flow init error", {
|
|
1377
|
+
phoneNumber,
|
|
1378
|
+
flow: matchedFlow.definition.id,
|
|
1379
|
+
error: getErrorMessage(error)
|
|
1380
|
+
});
|
|
1381
|
+
return { isFlowActive: false, response: "", flowCompleted: false };
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return { isFlowActive: false, response: "", flowCompleted: false };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// src/routes/call/processor.ts
|
|
1388
|
+
async function processCall(deps, registry, phoneNumber, speechResult) {
|
|
1389
|
+
const incoming = await processIncoming(deps, phoneNumber, speechResult, "call");
|
|
1390
|
+
if (incoming.shouldTransfer) {
|
|
1391
|
+
logger.info("transferring to human", { phoneNumber, language: incoming.detectedLanguage });
|
|
1392
|
+
persistSession(phoneNumber, "call");
|
|
1393
|
+
persistFinalSession(phoneNumber, "call", "redirected", incoming.processedMessage);
|
|
1394
|
+
return transferTwiml(incoming.detectedLanguage, deps.config);
|
|
1395
|
+
}
|
|
1396
|
+
if (incoming.shouldEndCall) {
|
|
1397
|
+
logger.info("ending call - user done", { phoneNumber, language: incoming.detectedLanguage });
|
|
1398
|
+
persistSession(phoneNumber, "call");
|
|
1399
|
+
persistFinalSession(phoneNumber, "call", "ended");
|
|
1400
|
+
clearContext(phoneNumber);
|
|
1401
|
+
return farewellTwiml(incoming.detectedLanguage, deps.config);
|
|
1402
|
+
}
|
|
1403
|
+
const flowResult = await processFlow(
|
|
1404
|
+
deps,
|
|
1405
|
+
registry,
|
|
1406
|
+
phoneNumber,
|
|
1407
|
+
incoming.processedMessage,
|
|
1408
|
+
"call"
|
|
1409
|
+
);
|
|
1410
|
+
if (flowResult.isFlowActive || flowResult.flowCompleted) {
|
|
1411
|
+
logger.info("FLOW RESULT", {
|
|
1412
|
+
phoneNumber,
|
|
1413
|
+
active: flowResult.isFlowActive,
|
|
1414
|
+
done: flowResult.flowCompleted,
|
|
1415
|
+
success: flowResult.flowSuccess
|
|
1416
|
+
});
|
|
1417
|
+
if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
|
|
1418
|
+
const { voice, language: lang } = getVoiceConfig(
|
|
1419
|
+
incoming.detectedLanguage,
|
|
1420
|
+
deps.config.voices
|
|
1421
|
+
);
|
|
1422
|
+
const transferNumber = deps.config.transferNumber || "";
|
|
1423
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1424
|
+
<Response>
|
|
1425
|
+
<Say voice="${voice}" language="${lang}">${flowResult.response}</Say>
|
|
1426
|
+
<Dial>${transferNumber}</Dial>
|
|
1427
|
+
</Response>`;
|
|
1428
|
+
}
|
|
1429
|
+
const escapedResponse2 = escapeXml(flowResult.response);
|
|
1430
|
+
return gatherTwiml(escapedResponse2, incoming.detectedLanguage, deps.config, phoneNumber);
|
|
1431
|
+
}
|
|
1432
|
+
const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
|
|
1433
|
+
const phoneResponse = await processOutgoing(deps, phoneNumber, botResponse, "call");
|
|
1434
|
+
const escapedResponse = escapeXml(phoneResponse);
|
|
1435
|
+
return gatherTwiml(escapedResponse, incoming.detectedLanguage, deps.config, phoneNumber);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// src/routes/call/handle-respond.ts
|
|
1439
|
+
async function handleRespond(c, deps, registry) {
|
|
1440
|
+
const body = await c.req.parseBody();
|
|
1441
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
1442
|
+
const speechResult = body.SpeechResult;
|
|
1443
|
+
const config = deps.config;
|
|
1444
|
+
logger.info("speech received", { phoneNumber, speechResult });
|
|
1445
|
+
if (!speechResult) {
|
|
1446
|
+
const twiml = gatherTwiml(
|
|
1447
|
+
getPhrase("en", "didNotCatch", config.languageDir),
|
|
1448
|
+
"en",
|
|
1449
|
+
config,
|
|
1450
|
+
phoneNumber
|
|
1451
|
+
);
|
|
1452
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
1453
|
+
}
|
|
1454
|
+
resetNoSpeechRetries(phoneNumber);
|
|
1455
|
+
const messageHistory = getMessageHistory(phoneNumber);
|
|
1456
|
+
const isFirstMessage = messageHistory.filter((m) => m.role === "user").length === 0;
|
|
1457
|
+
const ackEnabled = config.features?.thinkingAcknowledgmentEnabled ?? false;
|
|
1458
|
+
if (ackEnabled && isFirstMessage) {
|
|
1459
|
+
let resolveQuery;
|
|
1460
|
+
const promise = new Promise((resolve) => {
|
|
1461
|
+
resolveQuery = resolve;
|
|
1462
|
+
});
|
|
1463
|
+
setPending(phoneNumber, {
|
|
1464
|
+
speechResult,
|
|
1465
|
+
promise,
|
|
1466
|
+
resolve: resolveQuery
|
|
1467
|
+
});
|
|
1468
|
+
processCall(deps, registry, phoneNumber, speechResult).then((twiml) => {
|
|
1469
|
+
const pending = getPendingForResolve(phoneNumber);
|
|
1470
|
+
if (pending) pending.resolve({ twiml });
|
|
1471
|
+
persistSession(phoneNumber, "call");
|
|
1472
|
+
}).catch((error) => {
|
|
1473
|
+
logger.error("background processing error", {
|
|
1474
|
+
phoneNumber,
|
|
1475
|
+
error: getErrorMessage(error)
|
|
1476
|
+
});
|
|
1477
|
+
const pending = getPendingForResolve(phoneNumber);
|
|
1478
|
+
if (pending) {
|
|
1479
|
+
pending.resolve({
|
|
1480
|
+
twiml: sayTwiml(getPhrase("en", "error", config.languageDir), "en", config)
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
return c.text(acknowledgmentTwiml("en", config), 200, { "Content-Type": "text/xml" });
|
|
1485
|
+
}
|
|
1486
|
+
try {
|
|
1487
|
+
const twiml = await processCall(deps, registry, phoneNumber, speechResult);
|
|
1488
|
+
persistSession(phoneNumber, "call");
|
|
1489
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
logger.error("call processing error", { error: getErrorMessage(error) });
|
|
1492
|
+
const twiml = sayTwiml(getPhrase("en", "error", config.languageDir), "en", config);
|
|
1493
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function getPendingForResolve(phoneNumber) {
|
|
1497
|
+
const { getPending: getPending2 } = (init_pending(), __toCommonJS(pending_exports));
|
|
1498
|
+
return getPending2(phoneNumber);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// src/routes/call/handle-status.ts
|
|
1502
|
+
async function handleStatus(c) {
|
|
1503
|
+
const body = await c.req.parseBody();
|
|
1504
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
1505
|
+
const callStatus = body.CallStatus;
|
|
1506
|
+
logger.info("call status update", { phoneNumber, callStatus });
|
|
1507
|
+
if (callStatus === "completed") {
|
|
1508
|
+
persistSession(phoneNumber, "call");
|
|
1509
|
+
persistFinalSession(phoneNumber, "call", "ended");
|
|
1510
|
+
clearContext(phoneNumber);
|
|
1511
|
+
}
|
|
1512
|
+
return c.text("", 200);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/routes/call/index.ts
|
|
1516
|
+
function callRoutes(deps, registry) {
|
|
1517
|
+
const app = new Hono();
|
|
1518
|
+
app.post("/call", (c) => handleInitialCall(c, deps.config));
|
|
1519
|
+
app.post("/call/respond", (c) => handleRespond(c, deps, registry));
|
|
1520
|
+
app.post("/call/answer", (c) => handleAnswer(c, deps.config));
|
|
1521
|
+
app.post("/call/no-speech", (c) => handleNoSpeech(c, deps.config));
|
|
1522
|
+
app.post("/call/status", (c) => handleStatus(c));
|
|
1523
|
+
return app;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/routes/sms/index.ts
|
|
1527
|
+
import { Hono as Hono2 } from "hono";
|
|
1528
|
+
|
|
1529
|
+
// src/routes/sms/processor.ts
|
|
1530
|
+
async function processSms(deps, registry, phoneNumber, messageBody) {
|
|
1531
|
+
const incoming = await processIncoming(deps, phoneNumber, messageBody, "sms");
|
|
1532
|
+
if (incoming.shouldTransfer) {
|
|
1533
|
+
const message = getSmsPhrase(incoming.detectedLanguage, "callForHelp", deps.config.languageDir);
|
|
1534
|
+
return messageTwiml(message);
|
|
1535
|
+
}
|
|
1536
|
+
const flowResult = await processFlow(
|
|
1537
|
+
deps,
|
|
1538
|
+
registry,
|
|
1539
|
+
phoneNumber,
|
|
1540
|
+
incoming.processedMessage,
|
|
1541
|
+
"sms"
|
|
1542
|
+
);
|
|
1543
|
+
if (flowResult.isFlowActive || flowResult.flowCompleted) {
|
|
1544
|
+
if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
|
|
1545
|
+
const message = getSmsPhrase(
|
|
1546
|
+
incoming.detectedLanguage,
|
|
1547
|
+
"processingError",
|
|
1548
|
+
deps.config.languageDir
|
|
1549
|
+
);
|
|
1550
|
+
return messageTwiml(message);
|
|
1551
|
+
}
|
|
1552
|
+
return messageTwiml(flowResult.response);
|
|
1553
|
+
}
|
|
1554
|
+
const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
|
|
1555
|
+
const smsResponse = await processOutgoing(deps, phoneNumber, botResponse, "sms");
|
|
1556
|
+
return messageTwiml(smsResponse);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// src/routes/sms/handle-incoming.ts
|
|
1560
|
+
async function handleIncomingSMS(c, deps, registry) {
|
|
1561
|
+
const body = await c.req.parseBody();
|
|
1562
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
1563
|
+
const messageBody = body.Body || "";
|
|
1564
|
+
logger.info("sms received", { phoneNumber, messageBody });
|
|
1565
|
+
if (!messageBody.trim()) {
|
|
1566
|
+
return c.text(messageTwiml(getSmsPhrase("en", "greeting", deps.config.languageDir)), 200, {
|
|
1567
|
+
"Content-Type": "text/xml"
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
const twiml = await processSms(deps, registry, phoneNumber, messageBody);
|
|
1572
|
+
persistSession(phoneNumber, "sms");
|
|
1573
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
logger.error("sms processing error", {
|
|
1576
|
+
phoneNumber,
|
|
1577
|
+
error: getErrorMessage(error)
|
|
1578
|
+
});
|
|
1579
|
+
return c.text(messageTwiml(getSmsPhrase("en", "genericError", deps.config.languageDir)), 200, {
|
|
1580
|
+
"Content-Type": "text/xml"
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// src/routes/sms/index.ts
|
|
1586
|
+
function smsRoutes(deps, registry) {
|
|
1587
|
+
const app = new Hono2();
|
|
1588
|
+
app.post("/sms", (c) => handleIncomingSMS(c, deps, registry));
|
|
1589
|
+
app.get("/sms", (c) => c.text("SMS endpoint active"));
|
|
1590
|
+
return app;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/plugin.ts
|
|
1594
|
+
var DEFAULT_CONTEXT_TTL_MS = 30 * 60 * 1e3;
|
|
1595
|
+
var DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1596
|
+
var DEFAULT_MODEL = "gpt-4o-mini";
|
|
1597
|
+
async function createTelephonyRoutes(app, chatterDeps, config) {
|
|
1598
|
+
logger.info("initializing telephony routes");
|
|
1599
|
+
const openaiApiKey = config.openaiApiKey || chatterDeps.config.openai.apiKey;
|
|
1600
|
+
if (!openaiApiKey) {
|
|
1601
|
+
throw new Error("OpenAI API key required for talker");
|
|
1602
|
+
}
|
|
1603
|
+
const deps = {
|
|
1604
|
+
chatter: chatterDeps,
|
|
1605
|
+
config,
|
|
1606
|
+
openaiApiKey,
|
|
1607
|
+
openaiModel: config.processing?.model || DEFAULT_MODEL
|
|
1608
|
+
};
|
|
1609
|
+
const dbUrl = config.database?.url || chatterDeps.config.database?.url;
|
|
1610
|
+
const dbToken = config.database?.authToken || chatterDeps.config.database?.authToken;
|
|
1611
|
+
if (dbUrl && dbToken) {
|
|
1612
|
+
initDbClient(dbUrl, dbToken);
|
|
1613
|
+
await runMigrations();
|
|
1614
|
+
}
|
|
1615
|
+
const registry = new FlowRegistry(config.flowsDir || "");
|
|
1616
|
+
if (config.flowsDir) {
|
|
1617
|
+
await registry.loadFlows();
|
|
1618
|
+
}
|
|
1619
|
+
startCleanup(
|
|
1620
|
+
config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS,
|
|
1621
|
+
config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS
|
|
1622
|
+
);
|
|
1623
|
+
const prefix = config.routePrefix || "";
|
|
1624
|
+
app.route(prefix, callRoutes(deps, registry));
|
|
1625
|
+
app.route(prefix, smsRoutes(deps, registry));
|
|
1626
|
+
logger.info("telephony routes mounted", {
|
|
1627
|
+
prefix: prefix || "/",
|
|
1628
|
+
hasFlows: !!config.flowsDir,
|
|
1629
|
+
flowCount: registry.getAllFlows().length,
|
|
1630
|
+
hasTransferNumber: !!config.transferNumber
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// src/standalone.ts
|
|
1635
|
+
import { Hono as Hono3 } from "hono";
|
|
1636
|
+
var DEFAULT_CONTEXT_TTL_MS2 = 30 * 60 * 1e3;
|
|
1637
|
+
var DEFAULT_CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
|
|
1638
|
+
var DEFAULT_MODEL2 = "gpt-4o-mini";
|
|
1639
|
+
async function createStandaloneServer(config) {
|
|
1640
|
+
logger.info("initializing standalone talker server");
|
|
1641
|
+
if (!config.openaiApiKey) {
|
|
1642
|
+
throw new Error("openaiApiKey is required for standalone mode");
|
|
1643
|
+
}
|
|
1644
|
+
const stubChatterDeps = {
|
|
1645
|
+
config: { openai: { apiKey: config.openaiApiKey } }
|
|
1646
|
+
};
|
|
1647
|
+
const deps = {
|
|
1648
|
+
chatter: stubChatterDeps,
|
|
1649
|
+
config,
|
|
1650
|
+
openaiApiKey: config.openaiApiKey,
|
|
1651
|
+
openaiModel: config.processing?.model || DEFAULT_MODEL2
|
|
1652
|
+
};
|
|
1653
|
+
if (config.database?.url && config.database?.authToken) {
|
|
1654
|
+
initDbClient(config.database.url, config.database.authToken);
|
|
1655
|
+
await runMigrations();
|
|
1656
|
+
}
|
|
1657
|
+
const registry = new FlowRegistry(config.flowsDir || "");
|
|
1658
|
+
if (config.flowsDir) {
|
|
1659
|
+
await registry.loadFlows();
|
|
1660
|
+
}
|
|
1661
|
+
startCleanup(
|
|
1662
|
+
config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS2,
|
|
1663
|
+
config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS2
|
|
1664
|
+
);
|
|
1665
|
+
const app = new Hono3();
|
|
1666
|
+
app.get("/healthz", (c) => c.text("ok"));
|
|
1667
|
+
const prefix = config.routePrefix || "";
|
|
1668
|
+
app.route(prefix, callRoutes(deps, registry));
|
|
1669
|
+
app.route(prefix, smsRoutes(deps, registry));
|
|
1670
|
+
logger.info("standalone talker server ready", {
|
|
1671
|
+
prefix: prefix || "/",
|
|
1672
|
+
hasFlows: !!config.flowsDir,
|
|
1673
|
+
flowCount: registry.getAllFlows().length,
|
|
1674
|
+
hasChatFn: !!config.chatFn,
|
|
1675
|
+
hasTransferNumber: !!config.transferNumber
|
|
1676
|
+
});
|
|
1677
|
+
return app;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// src/adapters/twilio.ts
|
|
1681
|
+
async function sendSMS(config, to, message) {
|
|
1682
|
+
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
1683
|
+
logger.warn("Twilio credentials not configured, skipping SMS send", { to });
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
1688
|
+
const response = await fetch(
|
|
1689
|
+
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
1690
|
+
{
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: {
|
|
1693
|
+
Authorization: `Basic ${auth}`,
|
|
1694
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1695
|
+
},
|
|
1696
|
+
body: new URLSearchParams({
|
|
1697
|
+
From: config.phoneNumber,
|
|
1698
|
+
To: to,
|
|
1699
|
+
Body: message
|
|
1700
|
+
})
|
|
1701
|
+
}
|
|
1702
|
+
);
|
|
1703
|
+
if (!response.ok) {
|
|
1704
|
+
const error = await response.text();
|
|
1705
|
+
logger.error("SMS send failed", { to, status: response.status, error });
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
const data = await response.json();
|
|
1709
|
+
logger.info("SMS sent successfully", { to, messageSid: data.sid });
|
|
1710
|
+
return true;
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
logger.error("SMS send error", {
|
|
1713
|
+
to,
|
|
1714
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
1715
|
+
});
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
export {
|
|
1720
|
+
FlowRegistry,
|
|
1721
|
+
acknowledgmentTwiml,
|
|
1722
|
+
addMessage,
|
|
1723
|
+
callRoutes,
|
|
1724
|
+
clearActiveFlow,
|
|
1725
|
+
clearAllContexts,
|
|
1726
|
+
clearContext,
|
|
1727
|
+
closeDbClient,
|
|
1728
|
+
createStandaloneServer,
|
|
1729
|
+
createTelephonyRoutes,
|
|
1730
|
+
escapeXml,
|
|
1731
|
+
farewellTwiml,
|
|
1732
|
+
gatherTwiml,
|
|
1733
|
+
getActiveFlow,
|
|
1734
|
+
getContext,
|
|
1735
|
+
getDbClient,
|
|
1736
|
+
getDefaultVoices,
|
|
1737
|
+
getDetectedLanguage,
|
|
1738
|
+
getExitMessage,
|
|
1739
|
+
getFarewellPhrase,
|
|
1740
|
+
getFlowPhrase,
|
|
1741
|
+
getLastPrompt,
|
|
1742
|
+
getMessageHistory,
|
|
1743
|
+
getOrCreateContext,
|
|
1744
|
+
getPhrase,
|
|
1745
|
+
getSmsPhrase,
|
|
1746
|
+
getVoiceConfig,
|
|
1747
|
+
incrementNoSpeechRetries,
|
|
1748
|
+
initDbClient,
|
|
1749
|
+
loadFlowsFromDirectory,
|
|
1750
|
+
loadPhrases,
|
|
1751
|
+
logger,
|
|
1752
|
+
messageTwiml,
|
|
1753
|
+
persistFinalSession,
|
|
1754
|
+
persistSession,
|
|
1755
|
+
processFlow,
|
|
1756
|
+
processIncoming,
|
|
1757
|
+
processOutgoing,
|
|
1758
|
+
resetNoSpeechRetries,
|
|
1759
|
+
runMigrations,
|
|
1760
|
+
sayTwiml,
|
|
1761
|
+
sendSMS,
|
|
1762
|
+
setActiveFlow,
|
|
1763
|
+
setDetectedLanguage,
|
|
1764
|
+
setLastPrompt,
|
|
1765
|
+
shouldExitFlow,
|
|
1766
|
+
smsRoutes,
|
|
1767
|
+
startCleanup,
|
|
1768
|
+
stopCleanup,
|
|
1769
|
+
transferTwiml,
|
|
1770
|
+
updateFlowParams
|
|
1771
|
+
};
|