@diegoaltoworks/talker 0.9.0 → 0.11.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/dist/adapters/twilio.d.ts +13 -1
- package/dist/adapters/twilio.d.ts.map +1 -1
- package/dist/adapters/twilio.test.d.ts +7 -0
- package/dist/adapters/twilio.test.d.ts.map +1 -0
- package/dist/core/logger.d.ts +6 -0
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.test.d.ts +2 -0
- package/dist/core/logger.test.d.ts.map +1 -0
- package/dist/core/phrases.d.ts +5 -0
- package/dist/core/phrases.d.ts.map +1 -1
- package/dist/core/processing/prompts.d.ts +1 -1
- package/dist/core/processing/prompts.d.ts.map +1 -1
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +555 -99
- package/dist/index.mjs +537 -90
- package/dist/middleware/input-sanitize.d.ts +20 -0
- package/dist/middleware/input-sanitize.d.ts.map +1 -0
- package/dist/middleware/input-sanitize.test.d.ts +2 -0
- package/dist/middleware/input-sanitize.test.d.ts.map +1 -0
- package/dist/middleware/rate-limit.d.ts +27 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.test.d.ts +2 -0
- package/dist/middleware/rate-limit.test.d.ts.map +1 -0
- package/dist/middleware/twilio-signature.d.ts +32 -0
- package/dist/middleware/twilio-signature.d.ts.map +1 -0
- package/dist/middleware/twilio-signature.test.d.ts +2 -0
- package/dist/middleware/twilio-signature.test.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/routes/call/handle-answer.test.d.ts +7 -0
- package/dist/routes/call/handle-answer.test.d.ts.map +1 -0
- package/dist/routes/call/handle-initial.test.d.ts +7 -0
- package/dist/routes/call/handle-initial.test.d.ts.map +1 -0
- package/dist/routes/call/handle-nospeech.test.d.ts +7 -0
- package/dist/routes/call/handle-nospeech.test.d.ts.map +1 -0
- package/dist/routes/call/handle-respond.test.d.ts +7 -0
- package/dist/routes/call/handle-respond.test.d.ts.map +1 -0
- package/dist/routes/call/handle-status.test.d.ts +7 -0
- package/dist/routes/call/handle-status.test.d.ts.map +1 -0
- package/dist/routes/call/index.d.ts.map +1 -1
- package/dist/routes/call/pending.test.d.ts +7 -0
- package/dist/routes/call/pending.test.d.ts.map +1 -0
- package/dist/routes/call/processor.d.ts.map +1 -1
- package/dist/routes/sms/handle-incoming.test.d.ts +7 -0
- package/dist/routes/sms/handle-incoming.test.d.ts.map +1 -0
- package/dist/routes/sms/index.d.ts.map +1 -1
- package/dist/routes/whatsapp/handle-incoming.d.ts +12 -0
- package/dist/routes/whatsapp/handle-incoming.d.ts.map +1 -0
- package/dist/routes/whatsapp/handle-incoming.test.d.ts +7 -0
- package/dist/routes/whatsapp/handle-incoming.test.d.ts.map +1 -0
- package/dist/routes/whatsapp/index.d.ts +14 -0
- package/dist/routes/whatsapp/index.d.ts.map +1 -0
- package/dist/routes/whatsapp/processor.d.ts +14 -0
- package/dist/routes/whatsapp/processor.d.ts.map +1 -0
- package/dist/standalone.d.ts.map +1 -1
- package/dist/types.d.ts +20 -2
- package/dist/types.d.ts.map +1 -1
- package/language/de.json +7 -0
- package/language/en.json +7 -0
- package/language/es.json +7 -0
- package/language/fr.json +7 -0
- package/language/nl.json +7 -0
- package/language/pt.json +7 -0
- package/package.json +1 -1
- package/prompts/outgoing.md +16 -1
package/dist/index.js
CHANGED
|
@@ -84,8 +84,10 @@ __export(index_exports, {
|
|
|
84
84
|
getPhrase: () => getPhrase,
|
|
85
85
|
getSmsPhrase: () => getSmsPhrase,
|
|
86
86
|
getVoiceConfig: () => getVoiceConfig,
|
|
87
|
+
getWhatsAppPhrase: () => getWhatsAppPhrase,
|
|
87
88
|
incrementNoSpeechRetries: () => incrementNoSpeechRetries,
|
|
88
89
|
initDbClient: () => initDbClient,
|
|
90
|
+
inputSanitizeMiddleware: () => inputSanitizeMiddleware,
|
|
89
91
|
loadFlowsFromDirectory: () => loadFlowsFromDirectory,
|
|
90
92
|
loadPhrases: () => loadPhrases,
|
|
91
93
|
logger: () => logger,
|
|
@@ -95,10 +97,13 @@ __export(index_exports, {
|
|
|
95
97
|
processFlow: () => processFlow,
|
|
96
98
|
processIncoming: () => processIncoming,
|
|
97
99
|
processOutgoing: () => processOutgoing,
|
|
100
|
+
rateLimitMiddleware: () => rateLimitMiddleware,
|
|
101
|
+
redactPhone: () => redactPhone,
|
|
98
102
|
resetNoSpeechRetries: () => resetNoSpeechRetries,
|
|
99
103
|
runMigrations: () => runMigrations,
|
|
100
104
|
sayTwiml: () => sayTwiml,
|
|
101
105
|
sendSMS: () => sendSMS,
|
|
106
|
+
sendWhatsApp: () => sendWhatsApp,
|
|
102
107
|
setActiveFlow: () => setActiveFlow,
|
|
103
108
|
setDetectedLanguage: () => setDetectedLanguage,
|
|
104
109
|
setLastPrompt: () => setLastPrompt,
|
|
@@ -106,8 +111,12 @@ __export(index_exports, {
|
|
|
106
111
|
smsRoutes: () => smsRoutes,
|
|
107
112
|
startCleanup: () => startCleanup,
|
|
108
113
|
stopCleanup: () => stopCleanup,
|
|
114
|
+
stripWhatsAppPrefix: () => stripWhatsAppPrefix,
|
|
109
115
|
transferTwiml: () => transferTwiml,
|
|
110
|
-
|
|
116
|
+
twilioSignatureMiddleware: () => twilioSignatureMiddleware,
|
|
117
|
+
updateFlowParams: () => updateFlowParams,
|
|
118
|
+
validateTwilioSignature: () => validateTwilioSignature,
|
|
119
|
+
whatsappRoutes: () => whatsappRoutes
|
|
111
120
|
});
|
|
112
121
|
module.exports = __toCommonJS(index_exports);
|
|
113
122
|
|
|
@@ -116,13 +125,31 @@ var isTestEnv = process.env.NODE_ENV === "test" || typeof Bun !== "undefined" &&
|
|
|
116
125
|
var isDebug = process.env.DEBUG === "true";
|
|
117
126
|
var isSilent = isTestEnv && !isDebug;
|
|
118
127
|
var timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
128
|
+
function redactPhone(phone) {
|
|
129
|
+
if (!phone || phone === "unknown") return phone;
|
|
130
|
+
const digits = phone.replace(/\D/g, "");
|
|
131
|
+
if (digits.length <= 4) return "***";
|
|
132
|
+
return `***${digits.slice(-4)}`;
|
|
133
|
+
}
|
|
134
|
+
function redactData(data) {
|
|
135
|
+
if (!data) return data;
|
|
136
|
+
const redacted = {};
|
|
137
|
+
for (const [key, value] of Object.entries(data)) {
|
|
138
|
+
if ((key === "phoneNumber" || key === "phone") && typeof value === "string") {
|
|
139
|
+
redacted[key] = redactPhone(value);
|
|
140
|
+
} else {
|
|
141
|
+
redacted[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return redacted;
|
|
145
|
+
}
|
|
119
146
|
var log = (level, message, data) => {
|
|
120
147
|
if (isSilent) return;
|
|
121
148
|
const entry = {
|
|
122
149
|
timestamp: timestamp(),
|
|
123
150
|
level,
|
|
124
151
|
message,
|
|
125
|
-
...data
|
|
152
|
+
...redactData(data)
|
|
126
153
|
};
|
|
127
154
|
console.log(JSON.stringify(entry));
|
|
128
155
|
};
|
|
@@ -283,8 +310,35 @@ async function closeDbClient() {
|
|
|
283
310
|
}
|
|
284
311
|
|
|
285
312
|
// src/db/migrate.ts
|
|
286
|
-
var
|
|
287
|
-
|
|
313
|
+
var SCHEMA_STATEMENTS = [
|
|
314
|
+
`CREATE TABLE IF NOT EXISTS talker_sessions (
|
|
315
|
+
id TEXT PRIMARY KEY,
|
|
316
|
+
phone_number TEXT NOT NULL,
|
|
317
|
+
channel TEXT NOT NULL CHECK(channel IN ('call', 'sms')),
|
|
318
|
+
reason TEXT NOT NULL CHECK(reason IN ('ended', 'redirected')),
|
|
319
|
+
language TEXT NOT NULL,
|
|
320
|
+
started_at INTEGER NOT NULL,
|
|
321
|
+
ended_at INTEGER NOT NULL,
|
|
322
|
+
duration_ms INTEGER NOT NULL,
|
|
323
|
+
transfer_reason TEXT,
|
|
324
|
+
conversation_id TEXT,
|
|
325
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
326
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
327
|
+
)`,
|
|
328
|
+
`CREATE TABLE IF NOT EXISTS talker_messages (
|
|
329
|
+
id TEXT PRIMARY KEY,
|
|
330
|
+
session_id TEXT NOT NULL,
|
|
331
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
|
332
|
+
content TEXT NOT NULL,
|
|
333
|
+
timestamp INTEGER NOT NULL,
|
|
334
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
335
|
+
FOREIGN KEY (session_id) REFERENCES talker_sessions(id) ON DELETE CASCADE
|
|
336
|
+
)`,
|
|
337
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_sessions_phone ON talker_sessions(phone_number)",
|
|
338
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_sessions_created ON talker_sessions(created_at DESC)",
|
|
339
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_messages_session ON talker_messages(session_id)",
|
|
340
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_messages_ts ON talker_messages(timestamp)"
|
|
341
|
+
];
|
|
288
342
|
async function runMigrations() {
|
|
289
343
|
const client2 = getDbClient();
|
|
290
344
|
if (!client2) {
|
|
@@ -292,11 +346,8 @@ async function runMigrations() {
|
|
|
292
346
|
return;
|
|
293
347
|
}
|
|
294
348
|
try {
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
const statements = schema.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
298
|
-
logger.info("running database migrations", { statementCount: statements.length });
|
|
299
|
-
for (const statement of statements) {
|
|
349
|
+
logger.info("running database migrations", { statementCount: SCHEMA_STATEMENTS.length });
|
|
350
|
+
for (const statement of SCHEMA_STATEMENTS) {
|
|
300
351
|
await client2.execute(statement);
|
|
301
352
|
}
|
|
302
353
|
logger.info("database migrations completed");
|
|
@@ -309,7 +360,7 @@ async function runMigrations() {
|
|
|
309
360
|
}
|
|
310
361
|
|
|
311
362
|
// src/flows/registry.ts
|
|
312
|
-
var
|
|
363
|
+
var import_node_fs2 = require("node:fs");
|
|
313
364
|
|
|
314
365
|
// src/flows/intent.ts
|
|
315
366
|
var OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
@@ -398,15 +449,15 @@ Rules:
|
|
|
398
449
|
}
|
|
399
450
|
|
|
400
451
|
// src/flows/loader.ts
|
|
401
|
-
var
|
|
402
|
-
var
|
|
452
|
+
var import_node_fs = require("node:fs");
|
|
453
|
+
var import_node_path = require("node:path");
|
|
403
454
|
async function loadFlowsFromDirectory(flowsDir) {
|
|
404
455
|
const flows = /* @__PURE__ */ new Map();
|
|
405
|
-
if (!(0,
|
|
456
|
+
if (!(0, import_node_fs.existsSync)(flowsDir)) {
|
|
406
457
|
logger.warn("flows directory does not exist", { flowsDir });
|
|
407
458
|
return flows;
|
|
408
459
|
}
|
|
409
|
-
const flowDirs = (0,
|
|
460
|
+
const flowDirs = (0, import_node_fs.readdirSync)(flowsDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).filter((dirent) => ["lib", "tests", "registry"].indexOf(dirent.name) === -1).map((dirent) => dirent.name);
|
|
410
461
|
logger.info("loading flows", { count: flowDirs.length, flowDirs });
|
|
411
462
|
for (const flowName of flowDirs) {
|
|
412
463
|
try {
|
|
@@ -424,20 +475,20 @@ async function loadFlowsFromDirectory(flowsDir) {
|
|
|
424
475
|
return flows;
|
|
425
476
|
}
|
|
426
477
|
async function loadFlow(flowsDir, flowName) {
|
|
427
|
-
const flowPath = (0,
|
|
428
|
-
const definitionPath = (0,
|
|
429
|
-
const instructionsPath = (0,
|
|
430
|
-
const handlerPath = (0,
|
|
431
|
-
if (!(0,
|
|
478
|
+
const flowPath = (0, import_node_path.join)(flowsDir, flowName);
|
|
479
|
+
const definitionPath = (0, import_node_path.join)(flowPath, "flow.json");
|
|
480
|
+
const instructionsPath = (0, import_node_path.join)(flowPath, "instructions.md");
|
|
481
|
+
const handlerPath = (0, import_node_path.join)(flowPath, "handler.ts");
|
|
482
|
+
if (!(0, import_node_fs.existsSync)(definitionPath)) {
|
|
432
483
|
throw new Error(`flow.json not found for ${flowName}`);
|
|
433
484
|
}
|
|
434
|
-
if (!(0,
|
|
485
|
+
if (!(0, import_node_fs.existsSync)(instructionsPath)) {
|
|
435
486
|
throw new Error(`instructions.md not found for ${flowName}`);
|
|
436
487
|
}
|
|
437
|
-
if (!(0,
|
|
488
|
+
if (!(0, import_node_fs.existsSync)(handlerPath)) {
|
|
438
489
|
throw new Error(`handler.ts not found for ${flowName}`);
|
|
439
490
|
}
|
|
440
|
-
const definitionContent = (0,
|
|
491
|
+
const definitionContent = (0, import_node_fs.readFileSync)(definitionPath, "utf-8");
|
|
441
492
|
const definition = JSON.parse(definitionContent);
|
|
442
493
|
if (definition.id !== flowName) {
|
|
443
494
|
throw new Error(`Flow id mismatch: ${definition.id} !== ${flowName}`);
|
|
@@ -453,9 +504,9 @@ async function loadFlow(flowsDir, flowName) {
|
|
|
453
504
|
if (typeof handler !== "function") {
|
|
454
505
|
throw new Error(`Flow ${flowName} handler.ts must export an 'execute' function`);
|
|
455
506
|
}
|
|
456
|
-
const prefillPath = (0,
|
|
507
|
+
const prefillPath = (0, import_node_path.join)(flowPath, "prefill.ts");
|
|
457
508
|
let prefill;
|
|
458
|
-
if ((0,
|
|
509
|
+
if ((0, import_node_fs.existsSync)(prefillPath)) {
|
|
459
510
|
const prefillModule = await import(prefillPath);
|
|
460
511
|
prefill = prefillModule.prefillFromContext;
|
|
461
512
|
if (typeof prefill !== "function") {
|
|
@@ -539,7 +590,7 @@ var FlowRegistry = class {
|
|
|
539
590
|
if (!flow) {
|
|
540
591
|
throw new Error(`Flow ${flowName} not found`);
|
|
541
592
|
}
|
|
542
|
-
return (0,
|
|
593
|
+
return (0, import_node_fs2.readFileSync)(flow.instructionsPath, "utf-8");
|
|
543
594
|
}
|
|
544
595
|
};
|
|
545
596
|
function testModeDetectIntent(message) {
|
|
@@ -553,6 +604,140 @@ function testModeDetectIntent(message) {
|
|
|
553
604
|
// src/routes/call/index.ts
|
|
554
605
|
var import_hono = require("hono");
|
|
555
606
|
|
|
607
|
+
// src/middleware/input-sanitize.ts
|
|
608
|
+
var DEFAULT_MAX_INPUT_LENGTH = 1e3;
|
|
609
|
+
function truncateInput(input, maxLength) {
|
|
610
|
+
if (input.length <= maxLength) return input;
|
|
611
|
+
return input.substring(0, maxLength);
|
|
612
|
+
}
|
|
613
|
+
function inputSanitizeMiddleware(maxInputLength) {
|
|
614
|
+
const maxLen = maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
|
|
615
|
+
return async (c, next) => {
|
|
616
|
+
const body = await c.req.parseBody();
|
|
617
|
+
let truncated = false;
|
|
618
|
+
if (typeof body.SpeechResult === "string" && body.SpeechResult.length > maxLen) {
|
|
619
|
+
logger.warn("input truncated: SpeechResult", {
|
|
620
|
+
original: body.SpeechResult.length,
|
|
621
|
+
max: maxLen
|
|
622
|
+
});
|
|
623
|
+
body.SpeechResult = truncateInput(body.SpeechResult, maxLen);
|
|
624
|
+
truncated = true;
|
|
625
|
+
}
|
|
626
|
+
if (typeof body.Body === "string" && body.Body.length > maxLen) {
|
|
627
|
+
logger.warn("input truncated: Body", {
|
|
628
|
+
original: body.Body.length,
|
|
629
|
+
max: maxLen
|
|
630
|
+
});
|
|
631
|
+
body.Body = truncateInput(body.Body, maxLen);
|
|
632
|
+
truncated = true;
|
|
633
|
+
}
|
|
634
|
+
if (truncated) {
|
|
635
|
+
c.set("sanitizedBody", body);
|
|
636
|
+
}
|
|
637
|
+
return next();
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/middleware/rate-limit.ts
|
|
642
|
+
var DEFAULT_MAX_REQUESTS = 30;
|
|
643
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
644
|
+
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
645
|
+
var cleanupTimer2 = null;
|
|
646
|
+
function ensureCleanup(windowMs) {
|
|
647
|
+
if (cleanupTimer2) return;
|
|
648
|
+
cleanupTimer2 = setInterval(() => {
|
|
649
|
+
const cutoff = Date.now() - windowMs * 2;
|
|
650
|
+
for (const [key, entry] of rateLimitStore) {
|
|
651
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
652
|
+
if (entry.timestamps.length === 0) {
|
|
653
|
+
rateLimitStore.delete(key);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}, windowMs);
|
|
657
|
+
}
|
|
658
|
+
function checkRateLimit(phoneNumber, maxRequests, windowMs) {
|
|
659
|
+
const now = Date.now();
|
|
660
|
+
const cutoff = now - windowMs;
|
|
661
|
+
let entry = rateLimitStore.get(phoneNumber);
|
|
662
|
+
if (!entry) {
|
|
663
|
+
entry = { timestamps: [] };
|
|
664
|
+
rateLimitStore.set(phoneNumber, entry);
|
|
665
|
+
}
|
|
666
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
667
|
+
if (entry.timestamps.length >= maxRequests) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
entry.timestamps.push(now);
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
function rateLimitMiddleware(config) {
|
|
674
|
+
const maxRequests = config?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
675
|
+
const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
676
|
+
ensureCleanup(windowMs);
|
|
677
|
+
return async (c, next) => {
|
|
678
|
+
const body = await c.req.parseBody();
|
|
679
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
680
|
+
if (!checkRateLimit(phoneNumber, maxRequests, windowMs)) {
|
|
681
|
+
logger.warn("rate limit exceeded", { phoneNumber, path: c.req.path });
|
|
682
|
+
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
683
|
+
<Response>
|
|
684
|
+
<Say>Please try again in a moment.</Say>
|
|
685
|
+
</Response>`;
|
|
686
|
+
return c.text(twiml, 429, { "Content-Type": "text/xml" });
|
|
687
|
+
}
|
|
688
|
+
return next();
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/middleware/twilio-signature.ts
|
|
693
|
+
var import_node_crypto = require("node:crypto");
|
|
694
|
+
function computeTwilioSignature(authToken, url, params) {
|
|
695
|
+
const sortedKeys = Object.keys(params).sort();
|
|
696
|
+
let data = url;
|
|
697
|
+
for (const key of sortedKeys) {
|
|
698
|
+
data += key + params[key];
|
|
699
|
+
}
|
|
700
|
+
return (0, import_node_crypto.createHmac)("sha1", authToken).update(data).digest("base64");
|
|
701
|
+
}
|
|
702
|
+
function validateTwilioSignature(authToken, signature, url, params) {
|
|
703
|
+
const expected = computeTwilioSignature(authToken, url, params);
|
|
704
|
+
if (expected.length !== signature.length) return false;
|
|
705
|
+
let mismatch = 0;
|
|
706
|
+
for (let i = 0; i < expected.length; i++) {
|
|
707
|
+
mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
708
|
+
}
|
|
709
|
+
return mismatch === 0;
|
|
710
|
+
}
|
|
711
|
+
function twilioSignatureMiddleware(authToken, baseUrl) {
|
|
712
|
+
return async (c, next) => {
|
|
713
|
+
if (!authToken) {
|
|
714
|
+
return next();
|
|
715
|
+
}
|
|
716
|
+
const signature = c.req.header("x-twilio-signature");
|
|
717
|
+
if (!signature) {
|
|
718
|
+
logger.warn("rejected request: missing X-Twilio-Signature header", {
|
|
719
|
+
path: c.req.path
|
|
720
|
+
});
|
|
721
|
+
return c.text("", 403);
|
|
722
|
+
}
|
|
723
|
+
const requestUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}${c.req.path}` : c.req.url;
|
|
724
|
+
const body = await c.req.parseBody();
|
|
725
|
+
const params = {};
|
|
726
|
+
for (const [key, value] of Object.entries(body)) {
|
|
727
|
+
if (typeof value === "string") {
|
|
728
|
+
params[key] = value;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (!validateTwilioSignature(authToken, signature, requestUrl, params)) {
|
|
732
|
+
logger.warn("rejected request: invalid Twilio signature", {
|
|
733
|
+
path: c.req.path
|
|
734
|
+
});
|
|
735
|
+
return c.text("", 403);
|
|
736
|
+
}
|
|
737
|
+
return next();
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
556
741
|
// src/core/errors.ts
|
|
557
742
|
function getErrorMessage(error) {
|
|
558
743
|
if (error instanceof Error) {
|
|
@@ -565,20 +750,69 @@ function getErrorMessage(error) {
|
|
|
565
750
|
}
|
|
566
751
|
|
|
567
752
|
// src/core/phrases.ts
|
|
568
|
-
var
|
|
569
|
-
var
|
|
753
|
+
var import_node_fs3 = require("node:fs");
|
|
754
|
+
var import_node_path2 = require("node:path");
|
|
570
755
|
var phrasesCache = {};
|
|
756
|
+
var ENGLISH_FALLBACK = {
|
|
757
|
+
greeting: "Hello! I'm your voice assistant. How can I help you today?",
|
|
758
|
+
didNotCatch: "I didn't catch that. Could you please repeat?",
|
|
759
|
+
didNotHear: "I didn't hear anything. Goodbye.",
|
|
760
|
+
didNotHearRetry: "Sorry, I didn't catch that. Could you try again?",
|
|
761
|
+
didNotHearFinal: "I'm really sorry but I cannot hear what you're saying. Please call again. Bye for now.",
|
|
762
|
+
transfer: "Let me connect you with someone directly.",
|
|
763
|
+
acknowledgment: "One moment please...",
|
|
764
|
+
farewell: {
|
|
765
|
+
morning: "You're welcome! Have a wonderful day. Goodbye!",
|
|
766
|
+
afternoon: "You're welcome! Have a lovely afternoon. Goodbye!",
|
|
767
|
+
evening: "You're welcome! Have a good evening. Goodbye!"
|
|
768
|
+
},
|
|
769
|
+
error: "Sorry, I encountered an error. Please try again later. Goodbye.",
|
|
770
|
+
timeout: "Sorry, I took too long to respond. Please try again. Goodbye.",
|
|
771
|
+
lostQuestion: "I'm sorry, I lost track of your question. Could you please repeat?",
|
|
772
|
+
flow: {
|
|
773
|
+
cancelled: "No problem! I've cancelled that. What else would you like to know?"
|
|
774
|
+
},
|
|
775
|
+
sms: {
|
|
776
|
+
greeting: "Hi! I'm your voice assistant. Ask me anything!",
|
|
777
|
+
greetingShort: "Hi! Ask me anything!",
|
|
778
|
+
callForHelp: "For more complex questions, feel free to call back or reach us directly.",
|
|
779
|
+
processingError: "I'm having trouble processing that. Please try texting again or call back.",
|
|
780
|
+
genericError: "Sorry, something went wrong. Please try again."
|
|
781
|
+
},
|
|
782
|
+
whatsapp: {
|
|
783
|
+
greeting: "Hi! I'm your assistant. Send me a message and I'll help you out!",
|
|
784
|
+
greetingShort: "Hi! How can I help?",
|
|
785
|
+
callForHelp: "For more complex questions, feel free to call us directly or reply here with more details.",
|
|
786
|
+
processingError: "I'm having trouble processing that. Please try sending your message again.",
|
|
787
|
+
genericError: "Sorry, something went wrong. Please try again."
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
function resolveBuiltinLanguageDir() {
|
|
791
|
+
const candidates = [
|
|
792
|
+
(0, import_node_path2.join)(__dirname, "../../language"),
|
|
793
|
+
// source: src/core/ -> language/
|
|
794
|
+
(0, import_node_path2.join)(__dirname, "../language")
|
|
795
|
+
// dist: dist/ -> language/
|
|
796
|
+
];
|
|
797
|
+
for (const dir of candidates) {
|
|
798
|
+
if ((0, import_node_fs3.existsSync)(dir)) {
|
|
799
|
+
return dir;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return void 0;
|
|
803
|
+
}
|
|
571
804
|
function loadPhrases(language, languageDir) {
|
|
572
805
|
const cacheKey = `${languageDir || "default"}:${language}`;
|
|
573
806
|
if (phrasesCache[cacheKey]) {
|
|
574
807
|
return phrasesCache[cacheKey];
|
|
575
808
|
}
|
|
576
|
-
const
|
|
809
|
+
const builtinDir = resolveBuiltinLanguageDir();
|
|
810
|
+
const dirs = [languageDir, builtinDir].filter(Boolean);
|
|
577
811
|
for (const dir of dirs) {
|
|
578
|
-
const filePath = (0,
|
|
579
|
-
if ((0,
|
|
812
|
+
const filePath = (0, import_node_path2.join)(dir, `${language}.json`);
|
|
813
|
+
if ((0, import_node_fs3.existsSync)(filePath)) {
|
|
580
814
|
try {
|
|
581
|
-
const content = (0,
|
|
815
|
+
const content = (0, import_node_fs3.readFileSync)(filePath, "utf-8");
|
|
582
816
|
phrasesCache[cacheKey] = JSON.parse(content);
|
|
583
817
|
return phrasesCache[cacheKey];
|
|
584
818
|
} catch {
|
|
@@ -588,7 +822,8 @@ function loadPhrases(language, languageDir) {
|
|
|
588
822
|
if (language !== "en") {
|
|
589
823
|
return loadPhrases("en", languageDir);
|
|
590
824
|
}
|
|
591
|
-
|
|
825
|
+
phrasesCache[cacheKey] = ENGLISH_FALLBACK;
|
|
826
|
+
return ENGLISH_FALLBACK;
|
|
592
827
|
}
|
|
593
828
|
function getPhrase(language, key, languageDir) {
|
|
594
829
|
const phrases = loadPhrases(language, languageDir);
|
|
@@ -613,6 +848,13 @@ function getSmsPhrase(language, key, languageDir) {
|
|
|
613
848
|
const phrases = loadPhrases(language, languageDir);
|
|
614
849
|
return phrases.sms[key];
|
|
615
850
|
}
|
|
851
|
+
function getWhatsAppPhrase(language, key, languageDir) {
|
|
852
|
+
const phrases = loadPhrases(language, languageDir);
|
|
853
|
+
if (phrases.whatsapp) {
|
|
854
|
+
return phrases.whatsapp[key];
|
|
855
|
+
}
|
|
856
|
+
return phrases.sms[key];
|
|
857
|
+
}
|
|
616
858
|
|
|
617
859
|
// src/core/voice.ts
|
|
618
860
|
var DEFAULT_VOICES = {
|
|
@@ -1074,40 +1316,116 @@ async function callOpenAI(deps, systemPrompt, userMessage, context) {
|
|
|
1074
1316
|
}
|
|
1075
1317
|
|
|
1076
1318
|
// src/core/processing/prompts.ts
|
|
1077
|
-
var
|
|
1078
|
-
var
|
|
1319
|
+
var import_node_fs4 = require("node:fs");
|
|
1320
|
+
var import_node_path3 = require("node:path");
|
|
1079
1321
|
var incomingPrompt = null;
|
|
1080
1322
|
var outgoingPrompt = null;
|
|
1081
1323
|
function loadPromptFile(filename, customPath) {
|
|
1082
|
-
if (customPath && (0,
|
|
1083
|
-
return (0,
|
|
1084
|
-
}
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1324
|
+
if (customPath && (0, import_node_fs4.existsSync)(customPath)) {
|
|
1325
|
+
return (0, import_node_fs4.readFileSync)(customPath, "utf-8");
|
|
1326
|
+
}
|
|
1327
|
+
const candidates = [
|
|
1328
|
+
(0, import_node_path3.join)(__dirname, "../../../prompts", filename),
|
|
1329
|
+
// source: src/core/processing/ -> prompts/
|
|
1330
|
+
(0, import_node_path3.join)(__dirname, "../../prompts", filename),
|
|
1331
|
+
// dist: dist/ -> prompts/
|
|
1332
|
+
(0, import_node_path3.join)(__dirname, "../prompts", filename)
|
|
1333
|
+
// flat dist
|
|
1334
|
+
];
|
|
1335
|
+
for (const path of candidates) {
|
|
1336
|
+
if ((0, import_node_fs4.existsSync)(path)) {
|
|
1337
|
+
return (0, import_node_fs4.readFileSync)(path, "utf-8");
|
|
1338
|
+
}
|
|
1088
1339
|
}
|
|
1089
|
-
|
|
1340
|
+
return void 0;
|
|
1090
1341
|
}
|
|
1342
|
+
var DEFAULT_INCOMING_PROMPT = `# Incoming Message Processor
|
|
1343
|
+
|
|
1344
|
+
You are a pre-processor for a voice assistant. Your job is to analyze incoming caller speech and determine three things:
|
|
1345
|
+
|
|
1346
|
+
1. Should the caller be transferred to a human directly?
|
|
1347
|
+
2. What language is the caller speaking?
|
|
1348
|
+
3. What is the cleaned-up version of their message to send to the knowledge base?
|
|
1349
|
+
|
|
1350
|
+
## IMPORTANT: Conversation Context
|
|
1351
|
+
|
|
1352
|
+
You may receive conversation history along with the current message. **You MUST consider the context** when making decisions.
|
|
1353
|
+
|
|
1354
|
+
## Language Detection
|
|
1355
|
+
|
|
1356
|
+
Detect the language the caller is speaking. Supported: "en", "fr", "nl", "de", "es", "pt". Default: "en".
|
|
1357
|
+
|
|
1358
|
+
## Transfer Detection
|
|
1359
|
+
|
|
1360
|
+
Transfer if the caller expresses: "speak to someone", "talk to a person", "connect me", "real person", "human", frustration signals, or complex/personal matters.
|
|
1361
|
+
|
|
1362
|
+
## End Call Detection
|
|
1363
|
+
|
|
1364
|
+
End call if: "no thanks", "that's all", "goodbye", "bye", "I'm done", "nothing else". Only if clearly ending the conversation.
|
|
1365
|
+
|
|
1366
|
+
## Response Format
|
|
1367
|
+
|
|
1368
|
+
Respond with valid JSON:
|
|
1369
|
+
\`\`\`json
|
|
1370
|
+
{
|
|
1371
|
+
"shouldTransfer": true or false,
|
|
1372
|
+
"shouldEndCall": true or false,
|
|
1373
|
+
"detectedLanguage": "en" or "fr" or "nl" or "de" or "es" or "pt",
|
|
1374
|
+
"processedMessage": "the cleaned up message"
|
|
1375
|
+
}
|
|
1376
|
+
\`\`\`
|
|
1377
|
+
|
|
1378
|
+
## Message Cleaning Rules
|
|
1379
|
+
|
|
1380
|
+
- Fix obvious speech-to-text errors
|
|
1381
|
+
- Remove filler words (um, uh, like, you know)
|
|
1382
|
+
- Keep the core intent and original language intact`;
|
|
1383
|
+
var DEFAULT_OUTGOING_PROMPT = `# Outgoing Response Processor
|
|
1384
|
+
|
|
1385
|
+
You are a post-processor for a voice assistant. Transform knowledge base responses into channel-appropriate messages.
|
|
1386
|
+
|
|
1387
|
+
## Channel Type
|
|
1388
|
+
|
|
1389
|
+
You will be told the channel: "call" (phone), "sms" (text message), or "whatsapp".
|
|
1390
|
+
|
|
1391
|
+
**For CALL:** Spoken aloud by TTS. Convert numbers to words. Remove URLs. Max 2 sentences.
|
|
1392
|
+
**For SMS:** Read on phone screen. Keep digits. Max 160 chars. No markdown.
|
|
1393
|
+
**For WHATSAPP:** Read in chat. Keep digits and URLs. Can use *bold*, _italic_. Up to 500 chars.
|
|
1394
|
+
|
|
1395
|
+
## Language Requirement
|
|
1396
|
+
|
|
1397
|
+
You MUST respond in the specified language.
|
|
1398
|
+
|
|
1399
|
+
## Rules
|
|
1400
|
+
|
|
1401
|
+
1. Be concise - answer directly
|
|
1402
|
+
2. End with a follow-up question
|
|
1403
|
+
3. Always use third person
|
|
1404
|
+
4. Remove markdown, lists, technical jargon
|
|
1405
|
+
|
|
1406
|
+
Return ONLY the transformed text. No JSON, no explanations.`;
|
|
1091
1407
|
function getIncomingPrompt(deps) {
|
|
1092
1408
|
if (!incomingPrompt) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
incomingPrompt =
|
|
1409
|
+
const loaded = loadPromptFile("incoming.md", deps.config.processing?.incomingPromptPath);
|
|
1410
|
+
if (loaded) {
|
|
1411
|
+
incomingPrompt = loaded;
|
|
1412
|
+
logger.info("incoming prompt loaded from file");
|
|
1413
|
+
} else {
|
|
1414
|
+
incomingPrompt = DEFAULT_INCOMING_PROMPT;
|
|
1415
|
+
logger.info("incoming prompt loaded (built-in default)");
|
|
1099
1416
|
}
|
|
1100
1417
|
}
|
|
1101
1418
|
return incomingPrompt;
|
|
1102
1419
|
}
|
|
1103
1420
|
function getOutgoingPrompt(deps) {
|
|
1104
1421
|
if (!outgoingPrompt) {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
outgoingPrompt =
|
|
1422
|
+
const loaded = loadPromptFile("outgoing.md", deps.config.processing?.outgoingPromptPath);
|
|
1423
|
+
if (loaded) {
|
|
1424
|
+
outgoingPrompt = loaded;
|
|
1425
|
+
logger.info("outgoing prompt loaded from file");
|
|
1426
|
+
} else {
|
|
1427
|
+
outgoingPrompt = DEFAULT_OUTGOING_PROMPT;
|
|
1428
|
+
logger.info("outgoing prompt loaded (built-in default)");
|
|
1111
1429
|
}
|
|
1112
1430
|
}
|
|
1113
1431
|
return outgoingPrompt;
|
|
@@ -1201,10 +1519,10 @@ Respond in: ${language}`;
|
|
|
1201
1519
|
}
|
|
1202
1520
|
|
|
1203
1521
|
// src/flows/params.ts
|
|
1204
|
-
var
|
|
1522
|
+
var import_node_fs5 = require("node:fs");
|
|
1205
1523
|
var OPENAI_API_URL3 = "https://api.openai.com/v1/chat/completions";
|
|
1206
1524
|
async function extractParameters(deps, flow, phoneNumber, userMessage, existingParams) {
|
|
1207
|
-
const instructions = (0,
|
|
1525
|
+
const instructions = (0, import_node_fs5.readFileSync)(flow.instructionsPath, "utf-8");
|
|
1208
1526
|
const schema = flow.definition.schema;
|
|
1209
1527
|
const properties = schema.properties || {};
|
|
1210
1528
|
const required = schema.required || [];
|
|
@@ -1488,9 +1806,10 @@ async function processCall(deps, registry, phoneNumber, speechResult) {
|
|
|
1488
1806
|
deps.config.voices
|
|
1489
1807
|
);
|
|
1490
1808
|
const transferNumber = deps.config.transferNumber || "";
|
|
1809
|
+
const escapedFlowResponse = escapeXml(flowResult.response);
|
|
1491
1810
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1492
1811
|
<Response>
|
|
1493
|
-
<Say voice="${voice}" language="${lang}">${
|
|
1812
|
+
<Say voice="${voice}" language="${lang}">${escapedFlowResponse}</Say>
|
|
1494
1813
|
<Dial>${transferNumber}</Dial>
|
|
1495
1814
|
</Response>`;
|
|
1496
1815
|
}
|
|
@@ -1583,6 +1902,12 @@ async function handleStatus(c) {
|
|
|
1583
1902
|
// src/routes/call/index.ts
|
|
1584
1903
|
function callRoutes(deps, registry) {
|
|
1585
1904
|
const app = new import_hono.Hono();
|
|
1905
|
+
app.use("/call/*", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1906
|
+
app.use("/call/*", rateLimitMiddleware(deps.config.rateLimit));
|
|
1907
|
+
app.use("/call/*", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1908
|
+
app.post("/call", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1909
|
+
app.post("/call", rateLimitMiddleware(deps.config.rateLimit));
|
|
1910
|
+
app.post("/call", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1586
1911
|
app.post("/call", (c) => handleInitialCall(c, deps.config));
|
|
1587
1912
|
app.post("/call/respond", (c) => handleRespond(c, deps, registry));
|
|
1588
1913
|
app.post("/call/answer", (c) => handleAnswer(c, deps.config));
|
|
@@ -1653,11 +1978,171 @@ async function handleIncomingSMS(c, deps, registry) {
|
|
|
1653
1978
|
// src/routes/sms/index.ts
|
|
1654
1979
|
function smsRoutes(deps, registry) {
|
|
1655
1980
|
const app = new import_hono2.Hono();
|
|
1981
|
+
app.post("/sms", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1982
|
+
app.post("/sms", rateLimitMiddleware(deps.config.rateLimit));
|
|
1983
|
+
app.post("/sms", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1656
1984
|
app.post("/sms", (c) => handleIncomingSMS(c, deps, registry));
|
|
1657
1985
|
app.get("/sms", (c) => c.text("SMS endpoint active"));
|
|
1658
1986
|
return app;
|
|
1659
1987
|
}
|
|
1660
1988
|
|
|
1989
|
+
// src/routes/whatsapp/index.ts
|
|
1990
|
+
var import_hono3 = require("hono");
|
|
1991
|
+
|
|
1992
|
+
// src/adapters/twilio.ts
|
|
1993
|
+
function stripWhatsAppPrefix(phoneNumber) {
|
|
1994
|
+
return phoneNumber.replace(/^whatsapp:/, "");
|
|
1995
|
+
}
|
|
1996
|
+
async function sendSMS(config, to, message) {
|
|
1997
|
+
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
1998
|
+
logger.warn("Twilio credentials not configured, skipping SMS send", { to });
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
try {
|
|
2002
|
+
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
2003
|
+
const response = await fetch(
|
|
2004
|
+
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
2005
|
+
{
|
|
2006
|
+
method: "POST",
|
|
2007
|
+
headers: {
|
|
2008
|
+
Authorization: `Basic ${auth}`,
|
|
2009
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
2010
|
+
},
|
|
2011
|
+
body: new URLSearchParams({
|
|
2012
|
+
From: config.phoneNumber,
|
|
2013
|
+
To: to,
|
|
2014
|
+
Body: message
|
|
2015
|
+
})
|
|
2016
|
+
}
|
|
2017
|
+
);
|
|
2018
|
+
if (!response.ok) {
|
|
2019
|
+
const error = await response.text();
|
|
2020
|
+
logger.error("SMS send failed", { to, status: response.status, error });
|
|
2021
|
+
return false;
|
|
2022
|
+
}
|
|
2023
|
+
const data = await response.json();
|
|
2024
|
+
logger.info("SMS sent successfully", { to, messageSid: data.sid });
|
|
2025
|
+
return true;
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
logger.error("SMS send error", {
|
|
2028
|
+
to,
|
|
2029
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
2030
|
+
});
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
async function sendWhatsApp(config, to, message) {
|
|
2035
|
+
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
2036
|
+
logger.warn("Twilio credentials not configured, skipping WhatsApp send", { to });
|
|
2037
|
+
return false;
|
|
2038
|
+
}
|
|
2039
|
+
try {
|
|
2040
|
+
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
2041
|
+
const whatsappTo = to.startsWith("whatsapp:") ? to : `whatsapp:${to}`;
|
|
2042
|
+
const whatsappFrom = config.phoneNumber.startsWith("whatsapp:") ? config.phoneNumber : `whatsapp:${config.phoneNumber}`;
|
|
2043
|
+
const response = await fetch(
|
|
2044
|
+
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
2045
|
+
{
|
|
2046
|
+
method: "POST",
|
|
2047
|
+
headers: {
|
|
2048
|
+
Authorization: `Basic ${auth}`,
|
|
2049
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
2050
|
+
},
|
|
2051
|
+
body: new URLSearchParams({
|
|
2052
|
+
From: whatsappFrom,
|
|
2053
|
+
To: whatsappTo,
|
|
2054
|
+
Body: message
|
|
2055
|
+
})
|
|
2056
|
+
}
|
|
2057
|
+
);
|
|
2058
|
+
if (!response.ok) {
|
|
2059
|
+
const error = await response.text();
|
|
2060
|
+
logger.error("WhatsApp send failed", { to, status: response.status, error });
|
|
2061
|
+
return false;
|
|
2062
|
+
}
|
|
2063
|
+
const data = await response.json();
|
|
2064
|
+
logger.info("WhatsApp message sent successfully", { to, messageSid: data.sid });
|
|
2065
|
+
return true;
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
logger.error("WhatsApp send error", {
|
|
2068
|
+
to,
|
|
2069
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
2070
|
+
});
|
|
2071
|
+
return false;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/routes/whatsapp/processor.ts
|
|
2076
|
+
async function processWhatsApp(deps, registry, phoneNumber, messageBody) {
|
|
2077
|
+
const incoming = await processIncoming(deps, phoneNumber, messageBody, "whatsapp");
|
|
2078
|
+
if (incoming.shouldTransfer) {
|
|
2079
|
+
const message = getWhatsAppPhrase(
|
|
2080
|
+
incoming.detectedLanguage,
|
|
2081
|
+
"callForHelp",
|
|
2082
|
+
deps.config.languageDir
|
|
2083
|
+
);
|
|
2084
|
+
return messageTwiml(message);
|
|
2085
|
+
}
|
|
2086
|
+
const flowResult = await processFlow(
|
|
2087
|
+
deps,
|
|
2088
|
+
registry,
|
|
2089
|
+
phoneNumber,
|
|
2090
|
+
incoming.processedMessage,
|
|
2091
|
+
"whatsapp"
|
|
2092
|
+
);
|
|
2093
|
+
if (flowResult.isFlowActive || flowResult.flowCompleted) {
|
|
2094
|
+
if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
|
|
2095
|
+
const message = getWhatsAppPhrase(
|
|
2096
|
+
incoming.detectedLanguage,
|
|
2097
|
+
"processingError",
|
|
2098
|
+
deps.config.languageDir
|
|
2099
|
+
);
|
|
2100
|
+
return messageTwiml(message);
|
|
2101
|
+
}
|
|
2102
|
+
return messageTwiml(flowResult.response);
|
|
2103
|
+
}
|
|
2104
|
+
const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
|
|
2105
|
+
const whatsappResponse = await processOutgoing(deps, phoneNumber, botResponse, "whatsapp");
|
|
2106
|
+
return messageTwiml(whatsappResponse);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// src/routes/whatsapp/handle-incoming.ts
|
|
2110
|
+
async function handleIncomingWhatsApp(c, deps, registry) {
|
|
2111
|
+
const body = await c.req.parseBody();
|
|
2112
|
+
const rawFrom = (body.From || "unknown").trim();
|
|
2113
|
+
const phoneNumber = stripWhatsAppPrefix(rawFrom);
|
|
2114
|
+
const messageBody = body.Body || "";
|
|
2115
|
+
logger.info("whatsapp message received", { phoneNumber, messageBody });
|
|
2116
|
+
if (!messageBody.trim()) {
|
|
2117
|
+
return c.text(messageTwiml(getWhatsAppPhrase("en", "greeting", deps.config.languageDir)), 200, {
|
|
2118
|
+
"Content-Type": "text/xml"
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
try {
|
|
2122
|
+
const twiml = await processWhatsApp(deps, registry, phoneNumber, messageBody);
|
|
2123
|
+
persistSession(phoneNumber, "whatsapp");
|
|
2124
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
2125
|
+
} catch (error) {
|
|
2126
|
+
logger.error("whatsapp processing error", {
|
|
2127
|
+
phoneNumber,
|
|
2128
|
+
error: getErrorMessage(error)
|
|
2129
|
+
});
|
|
2130
|
+
return c.text(
|
|
2131
|
+
messageTwiml(getWhatsAppPhrase("en", "genericError", deps.config.languageDir)),
|
|
2132
|
+
200,
|
|
2133
|
+
{ "Content-Type": "text/xml" }
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/routes/whatsapp/index.ts
|
|
2139
|
+
function whatsappRoutes(deps, registry) {
|
|
2140
|
+
const app = new import_hono3.Hono();
|
|
2141
|
+
app.post("/whatsapp", (c) => handleIncomingWhatsApp(c, deps, registry));
|
|
2142
|
+
app.get("/whatsapp", (c) => c.text("WhatsApp endpoint active"));
|
|
2143
|
+
return app;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
1661
2146
|
// src/plugin.ts
|
|
1662
2147
|
var DEFAULT_CONTEXT_TTL_MS = 30 * 60 * 1e3;
|
|
1663
2148
|
var DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
@@ -1691,6 +2176,7 @@ async function createTelephonyRoutes(app, chatterDeps, config) {
|
|
|
1691
2176
|
const prefix = config.routePrefix || "";
|
|
1692
2177
|
app.route(prefix, callRoutes(deps, registry));
|
|
1693
2178
|
app.route(prefix, smsRoutes(deps, registry));
|
|
2179
|
+
app.route(prefix, whatsappRoutes(deps, registry));
|
|
1694
2180
|
logger.info("telephony routes mounted", {
|
|
1695
2181
|
prefix: prefix || "/",
|
|
1696
2182
|
hasFlows: !!config.flowsDir,
|
|
@@ -1700,7 +2186,7 @@ async function createTelephonyRoutes(app, chatterDeps, config) {
|
|
|
1700
2186
|
}
|
|
1701
2187
|
|
|
1702
2188
|
// src/standalone.ts
|
|
1703
|
-
var
|
|
2189
|
+
var import_hono4 = require("hono");
|
|
1704
2190
|
var DEFAULT_CONTEXT_TTL_MS2 = 30 * 60 * 1e3;
|
|
1705
2191
|
var DEFAULT_CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
|
|
1706
2192
|
var DEFAULT_MODEL2 = "gpt-4o-mini";
|
|
@@ -1730,11 +2216,12 @@ async function createStandaloneServer(config) {
|
|
|
1730
2216
|
config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS2,
|
|
1731
2217
|
config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS2
|
|
1732
2218
|
);
|
|
1733
|
-
const app = new
|
|
2219
|
+
const app = new import_hono4.Hono();
|
|
1734
2220
|
app.get("/healthz", (c) => c.text("ok"));
|
|
1735
2221
|
const prefix = config.routePrefix || "";
|
|
1736
2222
|
app.route(prefix, callRoutes(deps, registry));
|
|
1737
2223
|
app.route(prefix, smsRoutes(deps, registry));
|
|
2224
|
+
app.route(prefix, whatsappRoutes(deps, registry));
|
|
1738
2225
|
logger.info("standalone talker server ready", {
|
|
1739
2226
|
prefix: prefix || "/",
|
|
1740
2227
|
hasFlows: !!config.flowsDir,
|
|
@@ -1744,46 +2231,6 @@ async function createStandaloneServer(config) {
|
|
|
1744
2231
|
});
|
|
1745
2232
|
return app;
|
|
1746
2233
|
}
|
|
1747
|
-
|
|
1748
|
-
// src/adapters/twilio.ts
|
|
1749
|
-
async function sendSMS(config, to, message) {
|
|
1750
|
-
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
1751
|
-
logger.warn("Twilio credentials not configured, skipping SMS send", { to });
|
|
1752
|
-
return false;
|
|
1753
|
-
}
|
|
1754
|
-
try {
|
|
1755
|
-
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
1756
|
-
const response = await fetch(
|
|
1757
|
-
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
1758
|
-
{
|
|
1759
|
-
method: "POST",
|
|
1760
|
-
headers: {
|
|
1761
|
-
Authorization: `Basic ${auth}`,
|
|
1762
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
1763
|
-
},
|
|
1764
|
-
body: new URLSearchParams({
|
|
1765
|
-
From: config.phoneNumber,
|
|
1766
|
-
To: to,
|
|
1767
|
-
Body: message
|
|
1768
|
-
})
|
|
1769
|
-
}
|
|
1770
|
-
);
|
|
1771
|
-
if (!response.ok) {
|
|
1772
|
-
const error = await response.text();
|
|
1773
|
-
logger.error("SMS send failed", { to, status: response.status, error });
|
|
1774
|
-
return false;
|
|
1775
|
-
}
|
|
1776
|
-
const data = await response.json();
|
|
1777
|
-
logger.info("SMS sent successfully", { to, messageSid: data.sid });
|
|
1778
|
-
return true;
|
|
1779
|
-
} catch (error) {
|
|
1780
|
-
logger.error("SMS send error", {
|
|
1781
|
-
to,
|
|
1782
|
-
error: error instanceof Error ? error.message : "Unknown"
|
|
1783
|
-
});
|
|
1784
|
-
return false;
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
2234
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1788
2235
|
0 && (module.exports = {
|
|
1789
2236
|
FlowRegistry,
|
|
@@ -1813,8 +2260,10 @@ async function sendSMS(config, to, message) {
|
|
|
1813
2260
|
getPhrase,
|
|
1814
2261
|
getSmsPhrase,
|
|
1815
2262
|
getVoiceConfig,
|
|
2263
|
+
getWhatsAppPhrase,
|
|
1816
2264
|
incrementNoSpeechRetries,
|
|
1817
2265
|
initDbClient,
|
|
2266
|
+
inputSanitizeMiddleware,
|
|
1818
2267
|
loadFlowsFromDirectory,
|
|
1819
2268
|
loadPhrases,
|
|
1820
2269
|
logger,
|
|
@@ -1824,10 +2273,13 @@ async function sendSMS(config, to, message) {
|
|
|
1824
2273
|
processFlow,
|
|
1825
2274
|
processIncoming,
|
|
1826
2275
|
processOutgoing,
|
|
2276
|
+
rateLimitMiddleware,
|
|
2277
|
+
redactPhone,
|
|
1827
2278
|
resetNoSpeechRetries,
|
|
1828
2279
|
runMigrations,
|
|
1829
2280
|
sayTwiml,
|
|
1830
2281
|
sendSMS,
|
|
2282
|
+
sendWhatsApp,
|
|
1831
2283
|
setActiveFlow,
|
|
1832
2284
|
setDetectedLanguage,
|
|
1833
2285
|
setLastPrompt,
|
|
@@ -1835,6 +2287,10 @@ async function sendSMS(config, to, message) {
|
|
|
1835
2287
|
smsRoutes,
|
|
1836
2288
|
startCleanup,
|
|
1837
2289
|
stopCleanup,
|
|
2290
|
+
stripWhatsAppPrefix,
|
|
1838
2291
|
transferTwiml,
|
|
1839
|
-
|
|
2292
|
+
twilioSignatureMiddleware,
|
|
2293
|
+
updateFlowParams,
|
|
2294
|
+
validateTwilioSignature,
|
|
2295
|
+
whatsappRoutes
|
|
1840
2296
|
});
|