@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.mjs
CHANGED
|
@@ -48,13 +48,31 @@ var isTestEnv = process.env.NODE_ENV === "test" || typeof Bun !== "undefined" &&
|
|
|
48
48
|
var isDebug = process.env.DEBUG === "true";
|
|
49
49
|
var isSilent = isTestEnv && !isDebug;
|
|
50
50
|
var timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
51
|
+
function redactPhone(phone) {
|
|
52
|
+
if (!phone || phone === "unknown") return phone;
|
|
53
|
+
const digits = phone.replace(/\D/g, "");
|
|
54
|
+
if (digits.length <= 4) return "***";
|
|
55
|
+
return `***${digits.slice(-4)}`;
|
|
56
|
+
}
|
|
57
|
+
function redactData(data) {
|
|
58
|
+
if (!data) return data;
|
|
59
|
+
const redacted = {};
|
|
60
|
+
for (const [key, value] of Object.entries(data)) {
|
|
61
|
+
if ((key === "phoneNumber" || key === "phone") && typeof value === "string") {
|
|
62
|
+
redacted[key] = redactPhone(value);
|
|
63
|
+
} else {
|
|
64
|
+
redacted[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return redacted;
|
|
68
|
+
}
|
|
51
69
|
var log = (level, message, data) => {
|
|
52
70
|
if (isSilent) return;
|
|
53
71
|
const entry = {
|
|
54
72
|
timestamp: timestamp(),
|
|
55
73
|
level,
|
|
56
74
|
message,
|
|
57
|
-
...data
|
|
75
|
+
...redactData(data)
|
|
58
76
|
};
|
|
59
77
|
console.log(JSON.stringify(entry));
|
|
60
78
|
};
|
|
@@ -215,8 +233,35 @@ async function closeDbClient() {
|
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
// src/db/migrate.ts
|
|
218
|
-
|
|
219
|
-
|
|
236
|
+
var SCHEMA_STATEMENTS = [
|
|
237
|
+
`CREATE TABLE IF NOT EXISTS talker_sessions (
|
|
238
|
+
id TEXT PRIMARY KEY,
|
|
239
|
+
phone_number TEXT NOT NULL,
|
|
240
|
+
channel TEXT NOT NULL CHECK(channel IN ('call', 'sms')),
|
|
241
|
+
reason TEXT NOT NULL CHECK(reason IN ('ended', 'redirected')),
|
|
242
|
+
language TEXT NOT NULL,
|
|
243
|
+
started_at INTEGER NOT NULL,
|
|
244
|
+
ended_at INTEGER NOT NULL,
|
|
245
|
+
duration_ms INTEGER NOT NULL,
|
|
246
|
+
transfer_reason TEXT,
|
|
247
|
+
conversation_id TEXT,
|
|
248
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
249
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
250
|
+
)`,
|
|
251
|
+
`CREATE TABLE IF NOT EXISTS talker_messages (
|
|
252
|
+
id TEXT PRIMARY KEY,
|
|
253
|
+
session_id TEXT NOT NULL,
|
|
254
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
|
255
|
+
content TEXT NOT NULL,
|
|
256
|
+
timestamp INTEGER NOT NULL,
|
|
257
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
258
|
+
FOREIGN KEY (session_id) REFERENCES talker_sessions(id) ON DELETE CASCADE
|
|
259
|
+
)`,
|
|
260
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_sessions_phone ON talker_sessions(phone_number)",
|
|
261
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_sessions_created ON talker_sessions(created_at DESC)",
|
|
262
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_messages_session ON talker_messages(session_id)",
|
|
263
|
+
"CREATE INDEX IF NOT EXISTS idx_talker_messages_ts ON talker_messages(timestamp)"
|
|
264
|
+
];
|
|
220
265
|
async function runMigrations() {
|
|
221
266
|
const client2 = getDbClient();
|
|
222
267
|
if (!client2) {
|
|
@@ -224,11 +269,8 @@ async function runMigrations() {
|
|
|
224
269
|
return;
|
|
225
270
|
}
|
|
226
271
|
try {
|
|
227
|
-
|
|
228
|
-
const
|
|
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) {
|
|
272
|
+
logger.info("running database migrations", { statementCount: SCHEMA_STATEMENTS.length });
|
|
273
|
+
for (const statement of SCHEMA_STATEMENTS) {
|
|
232
274
|
await client2.execute(statement);
|
|
233
275
|
}
|
|
234
276
|
logger.info("database migrations completed");
|
|
@@ -241,7 +283,7 @@ async function runMigrations() {
|
|
|
241
283
|
}
|
|
242
284
|
|
|
243
285
|
// src/flows/registry.ts
|
|
244
|
-
import { readFileSync as
|
|
286
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
245
287
|
|
|
246
288
|
// src/flows/intent.ts
|
|
247
289
|
var OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
@@ -330,8 +372,8 @@ Rules:
|
|
|
330
372
|
}
|
|
331
373
|
|
|
332
374
|
// src/flows/loader.ts
|
|
333
|
-
import { existsSync, readFileSync
|
|
334
|
-
import { join
|
|
375
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
376
|
+
import { join } from "node:path";
|
|
335
377
|
async function loadFlowsFromDirectory(flowsDir) {
|
|
336
378
|
const flows = /* @__PURE__ */ new Map();
|
|
337
379
|
if (!existsSync(flowsDir)) {
|
|
@@ -356,10 +398,10 @@ async function loadFlowsFromDirectory(flowsDir) {
|
|
|
356
398
|
return flows;
|
|
357
399
|
}
|
|
358
400
|
async function loadFlow(flowsDir, flowName) {
|
|
359
|
-
const flowPath =
|
|
360
|
-
const definitionPath =
|
|
361
|
-
const instructionsPath =
|
|
362
|
-
const handlerPath =
|
|
401
|
+
const flowPath = join(flowsDir, flowName);
|
|
402
|
+
const definitionPath = join(flowPath, "flow.json");
|
|
403
|
+
const instructionsPath = join(flowPath, "instructions.md");
|
|
404
|
+
const handlerPath = join(flowPath, "handler.ts");
|
|
363
405
|
if (!existsSync(definitionPath)) {
|
|
364
406
|
throw new Error(`flow.json not found for ${flowName}`);
|
|
365
407
|
}
|
|
@@ -369,7 +411,7 @@ async function loadFlow(flowsDir, flowName) {
|
|
|
369
411
|
if (!existsSync(handlerPath)) {
|
|
370
412
|
throw new Error(`handler.ts not found for ${flowName}`);
|
|
371
413
|
}
|
|
372
|
-
const definitionContent =
|
|
414
|
+
const definitionContent = readFileSync(definitionPath, "utf-8");
|
|
373
415
|
const definition = JSON.parse(definitionContent);
|
|
374
416
|
if (definition.id !== flowName) {
|
|
375
417
|
throw new Error(`Flow id mismatch: ${definition.id} !== ${flowName}`);
|
|
@@ -385,7 +427,7 @@ async function loadFlow(flowsDir, flowName) {
|
|
|
385
427
|
if (typeof handler !== "function") {
|
|
386
428
|
throw new Error(`Flow ${flowName} handler.ts must export an 'execute' function`);
|
|
387
429
|
}
|
|
388
|
-
const prefillPath =
|
|
430
|
+
const prefillPath = join(flowPath, "prefill.ts");
|
|
389
431
|
let prefill;
|
|
390
432
|
if (existsSync(prefillPath)) {
|
|
391
433
|
const prefillModule = await import(prefillPath);
|
|
@@ -471,7 +513,7 @@ var FlowRegistry = class {
|
|
|
471
513
|
if (!flow) {
|
|
472
514
|
throw new Error(`Flow ${flowName} not found`);
|
|
473
515
|
}
|
|
474
|
-
return
|
|
516
|
+
return readFileSync2(flow.instructionsPath, "utf-8");
|
|
475
517
|
}
|
|
476
518
|
};
|
|
477
519
|
function testModeDetectIntent(message) {
|
|
@@ -485,6 +527,140 @@ function testModeDetectIntent(message) {
|
|
|
485
527
|
// src/routes/call/index.ts
|
|
486
528
|
import { Hono } from "hono";
|
|
487
529
|
|
|
530
|
+
// src/middleware/input-sanitize.ts
|
|
531
|
+
var DEFAULT_MAX_INPUT_LENGTH = 1e3;
|
|
532
|
+
function truncateInput(input, maxLength) {
|
|
533
|
+
if (input.length <= maxLength) return input;
|
|
534
|
+
return input.substring(0, maxLength);
|
|
535
|
+
}
|
|
536
|
+
function inputSanitizeMiddleware(maxInputLength) {
|
|
537
|
+
const maxLen = maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
|
|
538
|
+
return async (c, next) => {
|
|
539
|
+
const body = await c.req.parseBody();
|
|
540
|
+
let truncated = false;
|
|
541
|
+
if (typeof body.SpeechResult === "string" && body.SpeechResult.length > maxLen) {
|
|
542
|
+
logger.warn("input truncated: SpeechResult", {
|
|
543
|
+
original: body.SpeechResult.length,
|
|
544
|
+
max: maxLen
|
|
545
|
+
});
|
|
546
|
+
body.SpeechResult = truncateInput(body.SpeechResult, maxLen);
|
|
547
|
+
truncated = true;
|
|
548
|
+
}
|
|
549
|
+
if (typeof body.Body === "string" && body.Body.length > maxLen) {
|
|
550
|
+
logger.warn("input truncated: Body", {
|
|
551
|
+
original: body.Body.length,
|
|
552
|
+
max: maxLen
|
|
553
|
+
});
|
|
554
|
+
body.Body = truncateInput(body.Body, maxLen);
|
|
555
|
+
truncated = true;
|
|
556
|
+
}
|
|
557
|
+
if (truncated) {
|
|
558
|
+
c.set("sanitizedBody", body);
|
|
559
|
+
}
|
|
560
|
+
return next();
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/middleware/rate-limit.ts
|
|
565
|
+
var DEFAULT_MAX_REQUESTS = 30;
|
|
566
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
567
|
+
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
568
|
+
var cleanupTimer2 = null;
|
|
569
|
+
function ensureCleanup(windowMs) {
|
|
570
|
+
if (cleanupTimer2) return;
|
|
571
|
+
cleanupTimer2 = setInterval(() => {
|
|
572
|
+
const cutoff = Date.now() - windowMs * 2;
|
|
573
|
+
for (const [key, entry] of rateLimitStore) {
|
|
574
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
575
|
+
if (entry.timestamps.length === 0) {
|
|
576
|
+
rateLimitStore.delete(key);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}, windowMs);
|
|
580
|
+
}
|
|
581
|
+
function checkRateLimit(phoneNumber, maxRequests, windowMs) {
|
|
582
|
+
const now = Date.now();
|
|
583
|
+
const cutoff = now - windowMs;
|
|
584
|
+
let entry = rateLimitStore.get(phoneNumber);
|
|
585
|
+
if (!entry) {
|
|
586
|
+
entry = { timestamps: [] };
|
|
587
|
+
rateLimitStore.set(phoneNumber, entry);
|
|
588
|
+
}
|
|
589
|
+
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
|
|
590
|
+
if (entry.timestamps.length >= maxRequests) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
entry.timestamps.push(now);
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
function rateLimitMiddleware(config) {
|
|
597
|
+
const maxRequests = config?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
598
|
+
const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
|
|
599
|
+
ensureCleanup(windowMs);
|
|
600
|
+
return async (c, next) => {
|
|
601
|
+
const body = await c.req.parseBody();
|
|
602
|
+
const phoneNumber = (body.From || "unknown").trim();
|
|
603
|
+
if (!checkRateLimit(phoneNumber, maxRequests, windowMs)) {
|
|
604
|
+
logger.warn("rate limit exceeded", { phoneNumber, path: c.req.path });
|
|
605
|
+
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
606
|
+
<Response>
|
|
607
|
+
<Say>Please try again in a moment.</Say>
|
|
608
|
+
</Response>`;
|
|
609
|
+
return c.text(twiml, 429, { "Content-Type": "text/xml" });
|
|
610
|
+
}
|
|
611
|
+
return next();
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/middleware/twilio-signature.ts
|
|
616
|
+
import { createHmac } from "node:crypto";
|
|
617
|
+
function computeTwilioSignature(authToken, url, params) {
|
|
618
|
+
const sortedKeys = Object.keys(params).sort();
|
|
619
|
+
let data = url;
|
|
620
|
+
for (const key of sortedKeys) {
|
|
621
|
+
data += key + params[key];
|
|
622
|
+
}
|
|
623
|
+
return createHmac("sha1", authToken).update(data).digest("base64");
|
|
624
|
+
}
|
|
625
|
+
function validateTwilioSignature(authToken, signature, url, params) {
|
|
626
|
+
const expected = computeTwilioSignature(authToken, url, params);
|
|
627
|
+
if (expected.length !== signature.length) return false;
|
|
628
|
+
let mismatch = 0;
|
|
629
|
+
for (let i = 0; i < expected.length; i++) {
|
|
630
|
+
mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
|
|
631
|
+
}
|
|
632
|
+
return mismatch === 0;
|
|
633
|
+
}
|
|
634
|
+
function twilioSignatureMiddleware(authToken, baseUrl) {
|
|
635
|
+
return async (c, next) => {
|
|
636
|
+
if (!authToken) {
|
|
637
|
+
return next();
|
|
638
|
+
}
|
|
639
|
+
const signature = c.req.header("x-twilio-signature");
|
|
640
|
+
if (!signature) {
|
|
641
|
+
logger.warn("rejected request: missing X-Twilio-Signature header", {
|
|
642
|
+
path: c.req.path
|
|
643
|
+
});
|
|
644
|
+
return c.text("", 403);
|
|
645
|
+
}
|
|
646
|
+
const requestUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}${c.req.path}` : c.req.url;
|
|
647
|
+
const body = await c.req.parseBody();
|
|
648
|
+
const params = {};
|
|
649
|
+
for (const [key, value] of Object.entries(body)) {
|
|
650
|
+
if (typeof value === "string") {
|
|
651
|
+
params[key] = value;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!validateTwilioSignature(authToken, signature, requestUrl, params)) {
|
|
655
|
+
logger.warn("rejected request: invalid Twilio signature", {
|
|
656
|
+
path: c.req.path
|
|
657
|
+
});
|
|
658
|
+
return c.text("", 403);
|
|
659
|
+
}
|
|
660
|
+
return next();
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
488
664
|
// src/core/errors.ts
|
|
489
665
|
function getErrorMessage(error) {
|
|
490
666
|
if (error instanceof Error) {
|
|
@@ -497,20 +673,69 @@ function getErrorMessage(error) {
|
|
|
497
673
|
}
|
|
498
674
|
|
|
499
675
|
// src/core/phrases.ts
|
|
500
|
-
import { existsSync as existsSync2, readFileSync as
|
|
501
|
-
import { join as
|
|
676
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
677
|
+
import { join as join2 } from "node:path";
|
|
502
678
|
var phrasesCache = {};
|
|
679
|
+
var ENGLISH_FALLBACK = {
|
|
680
|
+
greeting: "Hello! I'm your voice assistant. How can I help you today?",
|
|
681
|
+
didNotCatch: "I didn't catch that. Could you please repeat?",
|
|
682
|
+
didNotHear: "I didn't hear anything. Goodbye.",
|
|
683
|
+
didNotHearRetry: "Sorry, I didn't catch that. Could you try again?",
|
|
684
|
+
didNotHearFinal: "I'm really sorry but I cannot hear what you're saying. Please call again. Bye for now.",
|
|
685
|
+
transfer: "Let me connect you with someone directly.",
|
|
686
|
+
acknowledgment: "One moment please...",
|
|
687
|
+
farewell: {
|
|
688
|
+
morning: "You're welcome! Have a wonderful day. Goodbye!",
|
|
689
|
+
afternoon: "You're welcome! Have a lovely afternoon. Goodbye!",
|
|
690
|
+
evening: "You're welcome! Have a good evening. Goodbye!"
|
|
691
|
+
},
|
|
692
|
+
error: "Sorry, I encountered an error. Please try again later. Goodbye.",
|
|
693
|
+
timeout: "Sorry, I took too long to respond. Please try again. Goodbye.",
|
|
694
|
+
lostQuestion: "I'm sorry, I lost track of your question. Could you please repeat?",
|
|
695
|
+
flow: {
|
|
696
|
+
cancelled: "No problem! I've cancelled that. What else would you like to know?"
|
|
697
|
+
},
|
|
698
|
+
sms: {
|
|
699
|
+
greeting: "Hi! I'm your voice assistant. Ask me anything!",
|
|
700
|
+
greetingShort: "Hi! Ask me anything!",
|
|
701
|
+
callForHelp: "For more complex questions, feel free to call back or reach us directly.",
|
|
702
|
+
processingError: "I'm having trouble processing that. Please try texting again or call back.",
|
|
703
|
+
genericError: "Sorry, something went wrong. Please try again."
|
|
704
|
+
},
|
|
705
|
+
whatsapp: {
|
|
706
|
+
greeting: "Hi! I'm your assistant. Send me a message and I'll help you out!",
|
|
707
|
+
greetingShort: "Hi! How can I help?",
|
|
708
|
+
callForHelp: "For more complex questions, feel free to call us directly or reply here with more details.",
|
|
709
|
+
processingError: "I'm having trouble processing that. Please try sending your message again.",
|
|
710
|
+
genericError: "Sorry, something went wrong. Please try again."
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
function resolveBuiltinLanguageDir() {
|
|
714
|
+
const candidates = [
|
|
715
|
+
join2(__dirname, "../../language"),
|
|
716
|
+
// source: src/core/ -> language/
|
|
717
|
+
join2(__dirname, "../language")
|
|
718
|
+
// dist: dist/ -> language/
|
|
719
|
+
];
|
|
720
|
+
for (const dir of candidates) {
|
|
721
|
+
if (existsSync2(dir)) {
|
|
722
|
+
return dir;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return void 0;
|
|
726
|
+
}
|
|
503
727
|
function loadPhrases(language, languageDir) {
|
|
504
728
|
const cacheKey = `${languageDir || "default"}:${language}`;
|
|
505
729
|
if (phrasesCache[cacheKey]) {
|
|
506
730
|
return phrasesCache[cacheKey];
|
|
507
731
|
}
|
|
508
|
-
const
|
|
732
|
+
const builtinDir = resolveBuiltinLanguageDir();
|
|
733
|
+
const dirs = [languageDir, builtinDir].filter(Boolean);
|
|
509
734
|
for (const dir of dirs) {
|
|
510
|
-
const filePath =
|
|
735
|
+
const filePath = join2(dir, `${language}.json`);
|
|
511
736
|
if (existsSync2(filePath)) {
|
|
512
737
|
try {
|
|
513
|
-
const content =
|
|
738
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
514
739
|
phrasesCache[cacheKey] = JSON.parse(content);
|
|
515
740
|
return phrasesCache[cacheKey];
|
|
516
741
|
} catch {
|
|
@@ -520,7 +745,8 @@ function loadPhrases(language, languageDir) {
|
|
|
520
745
|
if (language !== "en") {
|
|
521
746
|
return loadPhrases("en", languageDir);
|
|
522
747
|
}
|
|
523
|
-
|
|
748
|
+
phrasesCache[cacheKey] = ENGLISH_FALLBACK;
|
|
749
|
+
return ENGLISH_FALLBACK;
|
|
524
750
|
}
|
|
525
751
|
function getPhrase(language, key, languageDir) {
|
|
526
752
|
const phrases = loadPhrases(language, languageDir);
|
|
@@ -545,6 +771,13 @@ function getSmsPhrase(language, key, languageDir) {
|
|
|
545
771
|
const phrases = loadPhrases(language, languageDir);
|
|
546
772
|
return phrases.sms[key];
|
|
547
773
|
}
|
|
774
|
+
function getWhatsAppPhrase(language, key, languageDir) {
|
|
775
|
+
const phrases = loadPhrases(language, languageDir);
|
|
776
|
+
if (phrases.whatsapp) {
|
|
777
|
+
return phrases.whatsapp[key];
|
|
778
|
+
}
|
|
779
|
+
return phrases.sms[key];
|
|
780
|
+
}
|
|
548
781
|
|
|
549
782
|
// src/core/voice.ts
|
|
550
783
|
var DEFAULT_VOICES = {
|
|
@@ -1006,40 +1239,116 @@ async function callOpenAI(deps, systemPrompt, userMessage, context) {
|
|
|
1006
1239
|
}
|
|
1007
1240
|
|
|
1008
1241
|
// src/core/processing/prompts.ts
|
|
1009
|
-
import { existsSync as existsSync3, readFileSync as
|
|
1010
|
-
import { join as
|
|
1242
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
|
|
1243
|
+
import { join as join3 } from "node:path";
|
|
1011
1244
|
var incomingPrompt = null;
|
|
1012
1245
|
var outgoingPrompt = null;
|
|
1013
1246
|
function loadPromptFile(filename, customPath) {
|
|
1014
1247
|
if (customPath && existsSync3(customPath)) {
|
|
1015
|
-
return
|
|
1016
|
-
}
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1248
|
+
return readFileSync4(customPath, "utf-8");
|
|
1249
|
+
}
|
|
1250
|
+
const candidates = [
|
|
1251
|
+
join3(__dirname, "../../../prompts", filename),
|
|
1252
|
+
// source: src/core/processing/ -> prompts/
|
|
1253
|
+
join3(__dirname, "../../prompts", filename),
|
|
1254
|
+
// dist: dist/ -> prompts/
|
|
1255
|
+
join3(__dirname, "../prompts", filename)
|
|
1256
|
+
// flat dist
|
|
1257
|
+
];
|
|
1258
|
+
for (const path of candidates) {
|
|
1259
|
+
if (existsSync3(path)) {
|
|
1260
|
+
return readFileSync4(path, "utf-8");
|
|
1261
|
+
}
|
|
1020
1262
|
}
|
|
1021
|
-
|
|
1263
|
+
return void 0;
|
|
1022
1264
|
}
|
|
1265
|
+
var DEFAULT_INCOMING_PROMPT = `# Incoming Message Processor
|
|
1266
|
+
|
|
1267
|
+
You are a pre-processor for a voice assistant. Your job is to analyze incoming caller speech and determine three things:
|
|
1268
|
+
|
|
1269
|
+
1. Should the caller be transferred to a human directly?
|
|
1270
|
+
2. What language is the caller speaking?
|
|
1271
|
+
3. What is the cleaned-up version of their message to send to the knowledge base?
|
|
1272
|
+
|
|
1273
|
+
## IMPORTANT: Conversation Context
|
|
1274
|
+
|
|
1275
|
+
You may receive conversation history along with the current message. **You MUST consider the context** when making decisions.
|
|
1276
|
+
|
|
1277
|
+
## Language Detection
|
|
1278
|
+
|
|
1279
|
+
Detect the language the caller is speaking. Supported: "en", "fr", "nl", "de", "es", "pt". Default: "en".
|
|
1280
|
+
|
|
1281
|
+
## Transfer Detection
|
|
1282
|
+
|
|
1283
|
+
Transfer if the caller expresses: "speak to someone", "talk to a person", "connect me", "real person", "human", frustration signals, or complex/personal matters.
|
|
1284
|
+
|
|
1285
|
+
## End Call Detection
|
|
1286
|
+
|
|
1287
|
+
End call if: "no thanks", "that's all", "goodbye", "bye", "I'm done", "nothing else". Only if clearly ending the conversation.
|
|
1288
|
+
|
|
1289
|
+
## Response Format
|
|
1290
|
+
|
|
1291
|
+
Respond with valid JSON:
|
|
1292
|
+
\`\`\`json
|
|
1293
|
+
{
|
|
1294
|
+
"shouldTransfer": true or false,
|
|
1295
|
+
"shouldEndCall": true or false,
|
|
1296
|
+
"detectedLanguage": "en" or "fr" or "nl" or "de" or "es" or "pt",
|
|
1297
|
+
"processedMessage": "the cleaned up message"
|
|
1298
|
+
}
|
|
1299
|
+
\`\`\`
|
|
1300
|
+
|
|
1301
|
+
## Message Cleaning Rules
|
|
1302
|
+
|
|
1303
|
+
- Fix obvious speech-to-text errors
|
|
1304
|
+
- Remove filler words (um, uh, like, you know)
|
|
1305
|
+
- Keep the core intent and original language intact`;
|
|
1306
|
+
var DEFAULT_OUTGOING_PROMPT = `# Outgoing Response Processor
|
|
1307
|
+
|
|
1308
|
+
You are a post-processor for a voice assistant. Transform knowledge base responses into channel-appropriate messages.
|
|
1309
|
+
|
|
1310
|
+
## Channel Type
|
|
1311
|
+
|
|
1312
|
+
You will be told the channel: "call" (phone), "sms" (text message), or "whatsapp".
|
|
1313
|
+
|
|
1314
|
+
**For CALL:** Spoken aloud by TTS. Convert numbers to words. Remove URLs. Max 2 sentences.
|
|
1315
|
+
**For SMS:** Read on phone screen. Keep digits. Max 160 chars. No markdown.
|
|
1316
|
+
**For WHATSAPP:** Read in chat. Keep digits and URLs. Can use *bold*, _italic_. Up to 500 chars.
|
|
1317
|
+
|
|
1318
|
+
## Language Requirement
|
|
1319
|
+
|
|
1320
|
+
You MUST respond in the specified language.
|
|
1321
|
+
|
|
1322
|
+
## Rules
|
|
1323
|
+
|
|
1324
|
+
1. Be concise - answer directly
|
|
1325
|
+
2. End with a follow-up question
|
|
1326
|
+
3. Always use third person
|
|
1327
|
+
4. Remove markdown, lists, technical jargon
|
|
1328
|
+
|
|
1329
|
+
Return ONLY the transformed text. No JSON, no explanations.`;
|
|
1023
1330
|
function getIncomingPrompt(deps) {
|
|
1024
1331
|
if (!incomingPrompt) {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
incomingPrompt =
|
|
1332
|
+
const loaded = loadPromptFile("incoming.md", deps.config.processing?.incomingPromptPath);
|
|
1333
|
+
if (loaded) {
|
|
1334
|
+
incomingPrompt = loaded;
|
|
1335
|
+
logger.info("incoming prompt loaded from file");
|
|
1336
|
+
} else {
|
|
1337
|
+
incomingPrompt = DEFAULT_INCOMING_PROMPT;
|
|
1338
|
+
logger.info("incoming prompt loaded (built-in default)");
|
|
1031
1339
|
}
|
|
1032
1340
|
}
|
|
1033
1341
|
return incomingPrompt;
|
|
1034
1342
|
}
|
|
1035
1343
|
function getOutgoingPrompt(deps) {
|
|
1036
1344
|
if (!outgoingPrompt) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
outgoingPrompt =
|
|
1345
|
+
const loaded = loadPromptFile("outgoing.md", deps.config.processing?.outgoingPromptPath);
|
|
1346
|
+
if (loaded) {
|
|
1347
|
+
outgoingPrompt = loaded;
|
|
1348
|
+
logger.info("outgoing prompt loaded from file");
|
|
1349
|
+
} else {
|
|
1350
|
+
outgoingPrompt = DEFAULT_OUTGOING_PROMPT;
|
|
1351
|
+
logger.info("outgoing prompt loaded (built-in default)");
|
|
1043
1352
|
}
|
|
1044
1353
|
}
|
|
1045
1354
|
return outgoingPrompt;
|
|
@@ -1133,10 +1442,10 @@ Respond in: ${language}`;
|
|
|
1133
1442
|
}
|
|
1134
1443
|
|
|
1135
1444
|
// src/flows/params.ts
|
|
1136
|
-
import { readFileSync as
|
|
1445
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
1137
1446
|
var OPENAI_API_URL3 = "https://api.openai.com/v1/chat/completions";
|
|
1138
1447
|
async function extractParameters(deps, flow, phoneNumber, userMessage, existingParams) {
|
|
1139
|
-
const instructions =
|
|
1448
|
+
const instructions = readFileSync5(flow.instructionsPath, "utf-8");
|
|
1140
1449
|
const schema = flow.definition.schema;
|
|
1141
1450
|
const properties = schema.properties || {};
|
|
1142
1451
|
const required = schema.required || [];
|
|
@@ -1420,9 +1729,10 @@ async function processCall(deps, registry, phoneNumber, speechResult) {
|
|
|
1420
1729
|
deps.config.voices
|
|
1421
1730
|
);
|
|
1422
1731
|
const transferNumber = deps.config.transferNumber || "";
|
|
1732
|
+
const escapedFlowResponse = escapeXml(flowResult.response);
|
|
1423
1733
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1424
1734
|
<Response>
|
|
1425
|
-
<Say voice="${voice}" language="${lang}">${
|
|
1735
|
+
<Say voice="${voice}" language="${lang}">${escapedFlowResponse}</Say>
|
|
1426
1736
|
<Dial>${transferNumber}</Dial>
|
|
1427
1737
|
</Response>`;
|
|
1428
1738
|
}
|
|
@@ -1515,6 +1825,12 @@ async function handleStatus(c) {
|
|
|
1515
1825
|
// src/routes/call/index.ts
|
|
1516
1826
|
function callRoutes(deps, registry) {
|
|
1517
1827
|
const app = new Hono();
|
|
1828
|
+
app.use("/call/*", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1829
|
+
app.use("/call/*", rateLimitMiddleware(deps.config.rateLimit));
|
|
1830
|
+
app.use("/call/*", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1831
|
+
app.post("/call", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1832
|
+
app.post("/call", rateLimitMiddleware(deps.config.rateLimit));
|
|
1833
|
+
app.post("/call", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1518
1834
|
app.post("/call", (c) => handleInitialCall(c, deps.config));
|
|
1519
1835
|
app.post("/call/respond", (c) => handleRespond(c, deps, registry));
|
|
1520
1836
|
app.post("/call/answer", (c) => handleAnswer(c, deps.config));
|
|
@@ -1585,11 +1901,171 @@ async function handleIncomingSMS(c, deps, registry) {
|
|
|
1585
1901
|
// src/routes/sms/index.ts
|
|
1586
1902
|
function smsRoutes(deps, registry) {
|
|
1587
1903
|
const app = new Hono2();
|
|
1904
|
+
app.post("/sms", twilioSignatureMiddleware(deps.config.twilio?.authToken));
|
|
1905
|
+
app.post("/sms", rateLimitMiddleware(deps.config.rateLimit));
|
|
1906
|
+
app.post("/sms", inputSanitizeMiddleware(deps.config.maxInputLength));
|
|
1588
1907
|
app.post("/sms", (c) => handleIncomingSMS(c, deps, registry));
|
|
1589
1908
|
app.get("/sms", (c) => c.text("SMS endpoint active"));
|
|
1590
1909
|
return app;
|
|
1591
1910
|
}
|
|
1592
1911
|
|
|
1912
|
+
// src/routes/whatsapp/index.ts
|
|
1913
|
+
import { Hono as Hono3 } from "hono";
|
|
1914
|
+
|
|
1915
|
+
// src/adapters/twilio.ts
|
|
1916
|
+
function stripWhatsAppPrefix(phoneNumber) {
|
|
1917
|
+
return phoneNumber.replace(/^whatsapp:/, "");
|
|
1918
|
+
}
|
|
1919
|
+
async function sendSMS(config, to, message) {
|
|
1920
|
+
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
1921
|
+
logger.warn("Twilio credentials not configured, skipping SMS send", { to });
|
|
1922
|
+
return false;
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
1926
|
+
const response = await fetch(
|
|
1927
|
+
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
1928
|
+
{
|
|
1929
|
+
method: "POST",
|
|
1930
|
+
headers: {
|
|
1931
|
+
Authorization: `Basic ${auth}`,
|
|
1932
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1933
|
+
},
|
|
1934
|
+
body: new URLSearchParams({
|
|
1935
|
+
From: config.phoneNumber,
|
|
1936
|
+
To: to,
|
|
1937
|
+
Body: message
|
|
1938
|
+
})
|
|
1939
|
+
}
|
|
1940
|
+
);
|
|
1941
|
+
if (!response.ok) {
|
|
1942
|
+
const error = await response.text();
|
|
1943
|
+
logger.error("SMS send failed", { to, status: response.status, error });
|
|
1944
|
+
return false;
|
|
1945
|
+
}
|
|
1946
|
+
const data = await response.json();
|
|
1947
|
+
logger.info("SMS sent successfully", { to, messageSid: data.sid });
|
|
1948
|
+
return true;
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
logger.error("SMS send error", {
|
|
1951
|
+
to,
|
|
1952
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
1953
|
+
});
|
|
1954
|
+
return false;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
async function sendWhatsApp(config, to, message) {
|
|
1958
|
+
if (!config.accountSid || !config.authToken || !config.phoneNumber) {
|
|
1959
|
+
logger.warn("Twilio credentials not configured, skipping WhatsApp send", { to });
|
|
1960
|
+
return false;
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
|
|
1964
|
+
const whatsappTo = to.startsWith("whatsapp:") ? to : `whatsapp:${to}`;
|
|
1965
|
+
const whatsappFrom = config.phoneNumber.startsWith("whatsapp:") ? config.phoneNumber : `whatsapp:${config.phoneNumber}`;
|
|
1966
|
+
const response = await fetch(
|
|
1967
|
+
`https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
|
1968
|
+
{
|
|
1969
|
+
method: "POST",
|
|
1970
|
+
headers: {
|
|
1971
|
+
Authorization: `Basic ${auth}`,
|
|
1972
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1973
|
+
},
|
|
1974
|
+
body: new URLSearchParams({
|
|
1975
|
+
From: whatsappFrom,
|
|
1976
|
+
To: whatsappTo,
|
|
1977
|
+
Body: message
|
|
1978
|
+
})
|
|
1979
|
+
}
|
|
1980
|
+
);
|
|
1981
|
+
if (!response.ok) {
|
|
1982
|
+
const error = await response.text();
|
|
1983
|
+
logger.error("WhatsApp send failed", { to, status: response.status, error });
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
const data = await response.json();
|
|
1987
|
+
logger.info("WhatsApp message sent successfully", { to, messageSid: data.sid });
|
|
1988
|
+
return true;
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
logger.error("WhatsApp send error", {
|
|
1991
|
+
to,
|
|
1992
|
+
error: error instanceof Error ? error.message : "Unknown"
|
|
1993
|
+
});
|
|
1994
|
+
return false;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/routes/whatsapp/processor.ts
|
|
1999
|
+
async function processWhatsApp(deps, registry, phoneNumber, messageBody) {
|
|
2000
|
+
const incoming = await processIncoming(deps, phoneNumber, messageBody, "whatsapp");
|
|
2001
|
+
if (incoming.shouldTransfer) {
|
|
2002
|
+
const message = getWhatsAppPhrase(
|
|
2003
|
+
incoming.detectedLanguage,
|
|
2004
|
+
"callForHelp",
|
|
2005
|
+
deps.config.languageDir
|
|
2006
|
+
);
|
|
2007
|
+
return messageTwiml(message);
|
|
2008
|
+
}
|
|
2009
|
+
const flowResult = await processFlow(
|
|
2010
|
+
deps,
|
|
2011
|
+
registry,
|
|
2012
|
+
phoneNumber,
|
|
2013
|
+
incoming.processedMessage,
|
|
2014
|
+
"whatsapp"
|
|
2015
|
+
);
|
|
2016
|
+
if (flowResult.isFlowActive || flowResult.flowCompleted) {
|
|
2017
|
+
if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
|
|
2018
|
+
const message = getWhatsAppPhrase(
|
|
2019
|
+
incoming.detectedLanguage,
|
|
2020
|
+
"processingError",
|
|
2021
|
+
deps.config.languageDir
|
|
2022
|
+
);
|
|
2023
|
+
return messageTwiml(message);
|
|
2024
|
+
}
|
|
2025
|
+
return messageTwiml(flowResult.response);
|
|
2026
|
+
}
|
|
2027
|
+
const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
|
|
2028
|
+
const whatsappResponse = await processOutgoing(deps, phoneNumber, botResponse, "whatsapp");
|
|
2029
|
+
return messageTwiml(whatsappResponse);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// src/routes/whatsapp/handle-incoming.ts
|
|
2033
|
+
async function handleIncomingWhatsApp(c, deps, registry) {
|
|
2034
|
+
const body = await c.req.parseBody();
|
|
2035
|
+
const rawFrom = (body.From || "unknown").trim();
|
|
2036
|
+
const phoneNumber = stripWhatsAppPrefix(rawFrom);
|
|
2037
|
+
const messageBody = body.Body || "";
|
|
2038
|
+
logger.info("whatsapp message received", { phoneNumber, messageBody });
|
|
2039
|
+
if (!messageBody.trim()) {
|
|
2040
|
+
return c.text(messageTwiml(getWhatsAppPhrase("en", "greeting", deps.config.languageDir)), 200, {
|
|
2041
|
+
"Content-Type": "text/xml"
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
const twiml = await processWhatsApp(deps, registry, phoneNumber, messageBody);
|
|
2046
|
+
persistSession(phoneNumber, "whatsapp");
|
|
2047
|
+
return c.text(twiml, 200, { "Content-Type": "text/xml" });
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
logger.error("whatsapp processing error", {
|
|
2050
|
+
phoneNumber,
|
|
2051
|
+
error: getErrorMessage(error)
|
|
2052
|
+
});
|
|
2053
|
+
return c.text(
|
|
2054
|
+
messageTwiml(getWhatsAppPhrase("en", "genericError", deps.config.languageDir)),
|
|
2055
|
+
200,
|
|
2056
|
+
{ "Content-Type": "text/xml" }
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// src/routes/whatsapp/index.ts
|
|
2062
|
+
function whatsappRoutes(deps, registry) {
|
|
2063
|
+
const app = new Hono3();
|
|
2064
|
+
app.post("/whatsapp", (c) => handleIncomingWhatsApp(c, deps, registry));
|
|
2065
|
+
app.get("/whatsapp", (c) => c.text("WhatsApp endpoint active"));
|
|
2066
|
+
return app;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
1593
2069
|
// src/plugin.ts
|
|
1594
2070
|
var DEFAULT_CONTEXT_TTL_MS = 30 * 60 * 1e3;
|
|
1595
2071
|
var DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
|
|
@@ -1623,6 +2099,7 @@ async function createTelephonyRoutes(app, chatterDeps, config) {
|
|
|
1623
2099
|
const prefix = config.routePrefix || "";
|
|
1624
2100
|
app.route(prefix, callRoutes(deps, registry));
|
|
1625
2101
|
app.route(prefix, smsRoutes(deps, registry));
|
|
2102
|
+
app.route(prefix, whatsappRoutes(deps, registry));
|
|
1626
2103
|
logger.info("telephony routes mounted", {
|
|
1627
2104
|
prefix: prefix || "/",
|
|
1628
2105
|
hasFlows: !!config.flowsDir,
|
|
@@ -1632,7 +2109,7 @@ async function createTelephonyRoutes(app, chatterDeps, config) {
|
|
|
1632
2109
|
}
|
|
1633
2110
|
|
|
1634
2111
|
// src/standalone.ts
|
|
1635
|
-
import { Hono as
|
|
2112
|
+
import { Hono as Hono4 } from "hono";
|
|
1636
2113
|
var DEFAULT_CONTEXT_TTL_MS2 = 30 * 60 * 1e3;
|
|
1637
2114
|
var DEFAULT_CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
|
|
1638
2115
|
var DEFAULT_MODEL2 = "gpt-4o-mini";
|
|
@@ -1662,11 +2139,12 @@ async function createStandaloneServer(config) {
|
|
|
1662
2139
|
config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS2,
|
|
1663
2140
|
config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS2
|
|
1664
2141
|
);
|
|
1665
|
-
const app = new
|
|
2142
|
+
const app = new Hono4();
|
|
1666
2143
|
app.get("/healthz", (c) => c.text("ok"));
|
|
1667
2144
|
const prefix = config.routePrefix || "";
|
|
1668
2145
|
app.route(prefix, callRoutes(deps, registry));
|
|
1669
2146
|
app.route(prefix, smsRoutes(deps, registry));
|
|
2147
|
+
app.route(prefix, whatsappRoutes(deps, registry));
|
|
1670
2148
|
logger.info("standalone talker server ready", {
|
|
1671
2149
|
prefix: prefix || "/",
|
|
1672
2150
|
hasFlows: !!config.flowsDir,
|
|
@@ -1676,46 +2154,6 @@ async function createStandaloneServer(config) {
|
|
|
1676
2154
|
});
|
|
1677
2155
|
return app;
|
|
1678
2156
|
}
|
|
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
2157
|
export {
|
|
1720
2158
|
FlowRegistry,
|
|
1721
2159
|
acknowledgmentTwiml,
|
|
@@ -1744,8 +2182,10 @@ export {
|
|
|
1744
2182
|
getPhrase,
|
|
1745
2183
|
getSmsPhrase,
|
|
1746
2184
|
getVoiceConfig,
|
|
2185
|
+
getWhatsAppPhrase,
|
|
1747
2186
|
incrementNoSpeechRetries,
|
|
1748
2187
|
initDbClient,
|
|
2188
|
+
inputSanitizeMiddleware,
|
|
1749
2189
|
loadFlowsFromDirectory,
|
|
1750
2190
|
loadPhrases,
|
|
1751
2191
|
logger,
|
|
@@ -1755,10 +2195,13 @@ export {
|
|
|
1755
2195
|
processFlow,
|
|
1756
2196
|
processIncoming,
|
|
1757
2197
|
processOutgoing,
|
|
2198
|
+
rateLimitMiddleware,
|
|
2199
|
+
redactPhone,
|
|
1758
2200
|
resetNoSpeechRetries,
|
|
1759
2201
|
runMigrations,
|
|
1760
2202
|
sayTwiml,
|
|
1761
2203
|
sendSMS,
|
|
2204
|
+
sendWhatsApp,
|
|
1762
2205
|
setActiveFlow,
|
|
1763
2206
|
setDetectedLanguage,
|
|
1764
2207
|
setLastPrompt,
|
|
@@ -1766,6 +2209,10 @@ export {
|
|
|
1766
2209
|
smsRoutes,
|
|
1767
2210
|
startCleanup,
|
|
1768
2211
|
stopCleanup,
|
|
2212
|
+
stripWhatsAppPrefix,
|
|
1769
2213
|
transferTwiml,
|
|
1770
|
-
|
|
2214
|
+
twilioSignatureMiddleware,
|
|
2215
|
+
updateFlowParams,
|
|
2216
|
+
validateTwilioSignature,
|
|
2217
|
+
whatsappRoutes
|
|
1771
2218
|
};
|