niahere 0.3.0 → 0.3.1
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/README.md +2 -0
- package/package.json +1 -1
- package/skills/nia-phone/SKILL.md +139 -61
- package/src/channels/index.ts +12 -0
- package/src/channels/phone/index.ts +109 -157
- package/src/channels/sms.ts +189 -0
- package/src/channels/twilio/dedup.ts +36 -0
- package/src/channels/twilio/media-cache.ts +107 -0
- package/src/channels/twilio/media.ts +79 -0
- package/src/channels/twilio/rate-limit.ts +33 -0
- package/src/channels/twilio/rest.ts +133 -0
- package/src/channels/twilio/server.ts +255 -0
- package/src/channels/twilio/signature.ts +36 -0
- package/src/channels/twilio/transcribe.ts +58 -0
- package/src/channels/whatsapp.ts +408 -0
- package/src/cli/phone.ts +21 -15
- package/src/types/config.ts +41 -11
- package/src/types/enums.ts +1 -1
- package/src/types/index.ts +10 -1
- package/src/utils/config.ts +92 -42
- package/src/channels/phone/twilio.ts +0 -125
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phone channel — voice calling via Twilio + OpenAI Realtime.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Registers HTTP + WebSocket routes on the shared TwilioWebhookServer
|
|
5
|
+
* (which other Twilio channels — sms, whatsapp — also use). The server
|
|
6
|
+
* handles signature validation, dedup, and rate-limit middleware so this
|
|
7
|
+
* file only owns voice-specific logic:
|
|
6
8
|
*
|
|
7
9
|
* - Inbound: caller dials our Twilio number; we return TwiML that opens
|
|
8
10
|
* a Media Stream back to us; the stream is bridged to OpenAI Realtime.
|
|
@@ -11,33 +13,30 @@
|
|
|
11
13
|
* with a per-call goal seeded into the realtime session.
|
|
12
14
|
*
|
|
13
15
|
* Submodules:
|
|
14
|
-
* - twilio.ts
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
16
|
+
* - ../twilio/server.ts — shared HTTP+WS server
|
|
17
|
+
* - ../twilio/rest.ts — Twilio REST API helpers (placeCall, etc.)
|
|
18
|
+
* - twiml.ts — TwiML XML builders
|
|
19
|
+
* - relay.ts — Twilio Media Stream <-> OpenAI Realtime bridge
|
|
20
|
+
* - instructions.ts — system-prompt builders for inbound/outbound
|
|
21
|
+
* - tools.ts — function-calling tools exposed to the realtime model
|
|
22
|
+
* - consult.ts — escape hatch to Claude for memory-aware reasoning
|
|
20
23
|
*/
|
|
21
|
-
import type {
|
|
22
|
-
import type { Channel, PhoneConfig } from "../../types";
|
|
24
|
+
import type { ServerWebSocket } from "bun";
|
|
25
|
+
import type { Channel, PhoneConfig, TwilioConfig } from "../../types";
|
|
23
26
|
import { getConfig } from "../../utils/config";
|
|
24
27
|
import { log } from "../../utils/log";
|
|
25
28
|
import { getChannel } from "../registry";
|
|
26
29
|
import { Session, Message } from "../../db/models";
|
|
27
30
|
import { runMigrations } from "../../db/migrate";
|
|
28
31
|
|
|
29
|
-
import {
|
|
32
|
+
import { getTwilioServer, type WsConnectionData } from "../twilio/server";
|
|
33
|
+
import { placeCall as twilioPlaceCall } from "../twilio/rest";
|
|
30
34
|
import { streamTwiML, sayAndHangupTwiML, rejectTwiML } from "./twiml";
|
|
31
35
|
import { createRelay, type CallContext, type RelayHandle, type RelayResult } from "./relay";
|
|
32
36
|
import { buildInboundInstructions, buildOutboundInstructions } from "./instructions";
|
|
33
37
|
import { buildPhoneTools } from "./tools";
|
|
34
38
|
|
|
35
|
-
interface WsData {
|
|
36
|
-
streamSid: string | null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
39
|
interface PendingCall {
|
|
40
|
-
/** Context written when the call is initiated; consumed by the WS upgrade. */
|
|
41
40
|
context: Omit<CallContext, "streamSid" | "tools">;
|
|
42
41
|
startedAt: number;
|
|
43
42
|
}
|
|
@@ -64,97 +63,68 @@ function defer<T>(): Deferred<T> {
|
|
|
64
63
|
const DEFAULT_MAX_MINUTES = 10;
|
|
65
64
|
const HARD_MAX_MINUTES = 30;
|
|
66
65
|
|
|
66
|
+
const WS_PATH = "/twilio/voice/stream";
|
|
67
|
+
|
|
67
68
|
class PhoneChannel implements Channel {
|
|
68
69
|
name = "phone";
|
|
69
|
-
private
|
|
70
|
-
private readonly
|
|
71
|
-
/** Calls placed/answered but whose Media Stream hasn't connected yet. */
|
|
70
|
+
private readonly phone: PhoneConfig;
|
|
71
|
+
private readonly twilio: TwilioConfig;
|
|
72
72
|
private readonly pending = new Map<string, PendingCall>();
|
|
73
|
-
/** Active relays
|
|
73
|
+
/** Active relays keyed by streamSid. */
|
|
74
74
|
private readonly active = new Map<string, ActiveCall>();
|
|
75
|
-
/** Per-callSid completion deferreds
|
|
75
|
+
/** Per-callSid completion deferreds; resolved after persistCall. */
|
|
76
76
|
private readonly completions = new Map<string, Deferred<RelayResult>>();
|
|
77
77
|
|
|
78
|
-
constructor(
|
|
79
|
-
this.
|
|
78
|
+
constructor(phone: PhoneConfig, twilio: TwilioConfig) {
|
|
79
|
+
this.phone = phone;
|
|
80
|
+
this.twilio = twilio;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
// --- Channel lifecycle ---
|
|
83
|
-
|
|
84
83
|
async start(): Promise<void> {
|
|
85
84
|
await runMigrations();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
port: this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (path === "/healthz") return new Response("ok", { status: 200 });
|
|
94
|
-
|
|
95
|
-
if (path === "/twilio/voice/stream") {
|
|
96
|
-
const ok = server.upgrade(req, { data: { streamSid: null } });
|
|
97
|
-
return ok ? undefined : new Response("expected websocket", { status: 400 });
|
|
98
|
-
}
|
|
99
|
-
if (path === "/twilio/voice/incoming" && req.method === "POST") {
|
|
100
|
-
return await self.handleIncoming(req);
|
|
101
|
-
}
|
|
102
|
-
if (path === "/twilio/voice/outbound" && req.method === "POST") {
|
|
103
|
-
return await self.handleOutboundTwiml(req);
|
|
104
|
-
}
|
|
105
|
-
if (path === "/twilio/voice/status" && req.method === "POST") {
|
|
106
|
-
return await self.handleStatus(req);
|
|
107
|
-
}
|
|
108
|
-
return new Response("not found", { status: 404 });
|
|
109
|
-
},
|
|
110
|
-
websocket: {
|
|
111
|
-
message(ws, message) {
|
|
112
|
-
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
113
|
-
const sid = ws.data.streamSid;
|
|
114
|
-
if (sid) {
|
|
115
|
-
const active = self.active.get(sid);
|
|
116
|
-
if (active) {
|
|
117
|
-
active.handle.onTwilioMessage(data);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
self.bootstrapWsMessage(ws, data).catch((err) => {
|
|
122
|
-
log.error({ err }, "phone: ws bootstrap failed");
|
|
123
|
-
try {
|
|
124
|
-
ws.close();
|
|
125
|
-
} catch {}
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
close(ws) {
|
|
129
|
-
const sid = ws.data.streamSid;
|
|
130
|
-
if (!sid) return;
|
|
131
|
-
const active = self.active.get(sid);
|
|
132
|
-
if (active) active.handle.onTwilioClose();
|
|
133
|
-
},
|
|
134
|
-
},
|
|
85
|
+
|
|
86
|
+
const server = getTwilioServer();
|
|
87
|
+
server.configure({
|
|
88
|
+
port: this.twilio.port,
|
|
89
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
90
|
+
signingToken: this.twilio.auth_token || this.twilio.secret,
|
|
135
91
|
});
|
|
136
92
|
|
|
93
|
+
server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
|
|
94
|
+
server.registerHttp("/twilio/voice/outbound", (req, ctx) => this.handleOutboundTwiml(req, ctx.params));
|
|
95
|
+
server.registerHttp("/twilio/voice/status", (req, ctx) => this.handleStatus(ctx.params), {
|
|
96
|
+
dedupOn: "CallSid",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
server.registerWs(WS_PATH, {
|
|
100
|
+
onMessage: (ws, data) => this.onWsMessage(ws, data),
|
|
101
|
+
onClose: (ws) => this.onWsClose(ws),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Owner number must never be rate-limited (e.g. urgent rapid retries).
|
|
105
|
+
if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
|
|
106
|
+
|
|
107
|
+
await server.start();
|
|
108
|
+
|
|
137
109
|
log.info(
|
|
138
110
|
{
|
|
139
|
-
port: this.
|
|
140
|
-
publicBaseUrl: this.
|
|
141
|
-
from: this.
|
|
142
|
-
owner: this.
|
|
143
|
-
realtimeModel: this.
|
|
144
|
-
voice: this.
|
|
111
|
+
port: this.twilio.port,
|
|
112
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
113
|
+
from: this.phone.from_number,
|
|
114
|
+
owner: this.twilio.owner_number,
|
|
115
|
+
realtimeModel: this.phone.realtime_model,
|
|
116
|
+
voice: this.phone.voice,
|
|
145
117
|
},
|
|
146
118
|
"phone channel started",
|
|
147
119
|
);
|
|
148
120
|
}
|
|
149
121
|
|
|
150
122
|
async stop(): Promise<void> {
|
|
151
|
-
if (this.server) {
|
|
152
|
-
this.server.stop(true);
|
|
153
|
-
this.server = null;
|
|
154
|
-
}
|
|
155
123
|
for (const active of this.active.values()) active.handle.onTwilioClose();
|
|
156
124
|
this.active.clear();
|
|
157
125
|
this.pending.clear();
|
|
126
|
+
// The shared server is stopped by the daemon's channel teardown — leaving
|
|
127
|
+
// it running here would block SMS/WhatsApp if they're also bound to it.
|
|
158
128
|
}
|
|
159
129
|
|
|
160
130
|
// --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
|
|
@@ -174,10 +144,6 @@ class PhoneChannel implements Channel {
|
|
|
174
144
|
opts.maxMinutes && opts.maxMinutes > 0 ? Math.min(opts.maxMinutes, HARD_MAX_MINUTES) : DEFAULT_MAX_MINUTES;
|
|
175
145
|
const instructions = buildOutboundInstructions(opts.goal, opts.context);
|
|
176
146
|
|
|
177
|
-
// Twilio always includes `CallSid` in the form body of the TwiML
|
|
178
|
-
// webhook, so we don't need to encode it in the URL path — one static
|
|
179
|
-
// endpoint handles every outbound call, and we look up context by the
|
|
180
|
-
// CallSid Twilio sends us back.
|
|
181
147
|
const result = await twilioPlaceCall({
|
|
182
148
|
...creds,
|
|
183
149
|
to: opts.number,
|
|
@@ -205,21 +171,14 @@ class PhoneChannel implements Channel {
|
|
|
205
171
|
return result;
|
|
206
172
|
}
|
|
207
173
|
|
|
208
|
-
/**
|
|
209
|
-
* Wait for an in-flight call to finish. Resolves with the final transcript
|
|
210
|
-
* once the call has been persisted; returns null if the callSid is unknown.
|
|
211
|
-
*/
|
|
212
174
|
async awaitCallCompletion(callSid: string): Promise<RelayResult | null> {
|
|
213
175
|
const deferred = this.completions.get(callSid);
|
|
214
176
|
return deferred ? deferred.promise : null;
|
|
215
177
|
}
|
|
216
178
|
|
|
217
|
-
// --- HTTP handlers ---
|
|
218
|
-
|
|
219
|
-
private async handleIncoming(req: Request): Promise<Response> {
|
|
220
|
-
const { params } = await readForm(req);
|
|
221
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
179
|
+
// --- HTTP handlers (signature already validated by the shared server) ---
|
|
222
180
|
|
|
181
|
+
private async handleIncoming(_req: Request, params: Record<string, string>): Promise<Response> {
|
|
223
182
|
const callSid = params.CallSid || "";
|
|
224
183
|
const from = params.From || "";
|
|
225
184
|
const { label, allowed } = this.classifyCaller(from);
|
|
@@ -254,10 +213,7 @@ class PhoneChannel implements Channel {
|
|
|
254
213
|
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "inbound" }));
|
|
255
214
|
}
|
|
256
215
|
|
|
257
|
-
private async handleOutboundTwiml(
|
|
258
|
-
const { params } = await readForm(req);
|
|
259
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
260
|
-
|
|
216
|
+
private async handleOutboundTwiml(_req: Request, params: Record<string, string>): Promise<Response> {
|
|
261
217
|
const callSid = params.CallSid || "";
|
|
262
218
|
const pending = this.pending.get(callSid);
|
|
263
219
|
if (!pending) {
|
|
@@ -270,9 +226,7 @@ class PhoneChannel implements Channel {
|
|
|
270
226
|
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "outbound" }));
|
|
271
227
|
}
|
|
272
228
|
|
|
273
|
-
private
|
|
274
|
-
const { params } = await readForm(req);
|
|
275
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
229
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
276
230
|
log.info(
|
|
277
231
|
{
|
|
278
232
|
callSid: params.CallSid,
|
|
@@ -285,16 +239,40 @@ class PhoneChannel implements Channel {
|
|
|
285
239
|
return new Response("", { status: 204 });
|
|
286
240
|
}
|
|
287
241
|
|
|
288
|
-
// --- WebSocket
|
|
242
|
+
// --- WebSocket plumbing (delegated by shared server to this channel) ---
|
|
243
|
+
|
|
244
|
+
private onWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): void {
|
|
245
|
+
const sid = (ws.data.channel as { streamSid?: string }).streamSid;
|
|
246
|
+
if (sid) {
|
|
247
|
+
const active = this.active.get(sid);
|
|
248
|
+
if (active) {
|
|
249
|
+
active.handle.onTwilioMessage(data);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
this.bootstrapWsMessage(ws, data).catch((err) => {
|
|
254
|
+
log.error({ err }, "phone: ws bootstrap failed");
|
|
255
|
+
try {
|
|
256
|
+
ws.close();
|
|
257
|
+
} catch {}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
289
260
|
|
|
290
|
-
private
|
|
261
|
+
private onWsClose(ws: ServerWebSocket<WsConnectionData>): void {
|
|
262
|
+
const sid = (ws.data.channel as { streamSid?: string }).streamSid;
|
|
263
|
+
if (!sid) return;
|
|
264
|
+
const active = this.active.get(sid);
|
|
265
|
+
if (active) active.handle.onTwilioClose();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async bootstrapWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): Promise<void> {
|
|
291
269
|
let msg: any;
|
|
292
270
|
try {
|
|
293
271
|
msg = JSON.parse(data);
|
|
294
272
|
} catch {
|
|
295
273
|
return;
|
|
296
274
|
}
|
|
297
|
-
if (msg.event === "connected") return;
|
|
275
|
+
if (msg.event === "connected") return;
|
|
298
276
|
|
|
299
277
|
if (msg.event !== "start") {
|
|
300
278
|
log.warn({ event: msg.event }, "phone: unexpected first ws event");
|
|
@@ -321,16 +299,16 @@ class PhoneChannel implements Channel {
|
|
|
321
299
|
|
|
322
300
|
const handle = createRelay({
|
|
323
301
|
twilioWs: ws,
|
|
324
|
-
openAiKey: this.
|
|
325
|
-
model: this.
|
|
326
|
-
voice: this.
|
|
302
|
+
openAiKey: this.phone.openai_api_key!,
|
|
303
|
+
model: this.phone.realtime_model,
|
|
304
|
+
voice: this.phone.voice,
|
|
327
305
|
context,
|
|
328
306
|
});
|
|
329
307
|
|
|
330
|
-
ws.data.streamSid = streamSid;
|
|
308
|
+
(ws.data.channel as { streamSid?: string }).streamSid = streamSid;
|
|
331
309
|
this.active.set(streamSid, { handle, context, startedAt: pending.startedAt });
|
|
332
310
|
|
|
333
|
-
handle.onTwilioMessage(data);
|
|
311
|
+
handle.onTwilioMessage(data);
|
|
334
312
|
|
|
335
313
|
handle.completion
|
|
336
314
|
.then((result) => this.persistCall(context, pending.startedAt, result))
|
|
@@ -341,50 +319,36 @@ class PhoneChannel implements Channel {
|
|
|
341
319
|
// --- Helpers ---
|
|
342
320
|
|
|
343
321
|
private canStartRealtime(): boolean {
|
|
344
|
-
return Boolean(this.
|
|
322
|
+
return Boolean(this.phone.openai_api_key && this.twilio.public_base_url);
|
|
345
323
|
}
|
|
346
324
|
|
|
347
325
|
private buildWssUrl(): string {
|
|
348
|
-
|
|
349
|
-
return `${base}/twilio/voice/stream`;
|
|
326
|
+
return getTwilioServer().buildWssUrl(WS_PATH);
|
|
350
327
|
}
|
|
351
328
|
|
|
352
329
|
private classifyCaller(from: string): { label: string; allowed: boolean } {
|
|
353
|
-
if (from && from === this.
|
|
354
|
-
if (from && this.
|
|
330
|
+
if (from && from === this.twilio.owner_number) return { label: "Aman", allowed: true };
|
|
331
|
+
if (from && this.twilio.allowlist.includes(from)) return { label: from, allowed: true };
|
|
355
332
|
return { label: from || "unknown", allowed: false };
|
|
356
333
|
}
|
|
357
334
|
|
|
358
|
-
private
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const signingToken = this.cfg.twilio_auth_token || this.cfg.twilio_secret;
|
|
364
|
-
if (!signingToken) return false;
|
|
365
|
-
const signature = req.headers.get("X-Twilio-Signature") || "";
|
|
366
|
-
const fullUrl = this.cfg.public_base_url ? `${this.cfg.public_base_url}${new URL(req.url).pathname}` : req.url;
|
|
367
|
-
return validateTwilioSignature({ authToken: signingToken, fullUrl, params, signature });
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private requireCreds(): { accountSid: string; authToken: string } {
|
|
371
|
-
const sid = this.cfg.twilio_sid;
|
|
372
|
-
const secret = this.cfg.twilio_secret;
|
|
373
|
-
if (!sid || !secret)
|
|
374
|
-
throw new Error("phone: channels.phone.twilio_sid and twilio_secret not set (config.yaml or env)");
|
|
375
|
-
return { accountSid: sid, authToken: secret };
|
|
335
|
+
private requireCreds(): { accountSid: string; authSid: string; authSecret: string } {
|
|
336
|
+
const sid = this.twilio.sid;
|
|
337
|
+
const secret = this.twilio.secret;
|
|
338
|
+
if (!sid || !secret) throw new Error("twilio: sid and secret not set (channels.twilio.* in config.yaml or env)");
|
|
339
|
+
return { accountSid: sid, authSid: sid, authSecret: secret };
|
|
376
340
|
}
|
|
377
341
|
|
|
378
342
|
private requirePublicBaseUrl(): string {
|
|
379
|
-
if (!this.
|
|
380
|
-
throw new Error("
|
|
381
|
-
return this.
|
|
343
|
+
if (!this.twilio.public_base_url)
|
|
344
|
+
throw new Error("twilio: public_base_url not set (channels.twilio.public_base_url or PUBLIC_BASE_URL env)");
|
|
345
|
+
return this.twilio.public_base_url;
|
|
382
346
|
}
|
|
383
347
|
|
|
384
348
|
private requireFromNumber(): string {
|
|
385
|
-
if (!this.
|
|
386
|
-
throw new Error("phone:
|
|
387
|
-
return this.
|
|
349
|
+
if (!this.phone.from_number)
|
|
350
|
+
throw new Error("phone: from_number not set (channels.phone.from_number or PHONE_FROM_NUMBER env)");
|
|
351
|
+
return this.phone.from_number;
|
|
388
352
|
}
|
|
389
353
|
|
|
390
354
|
private async persistCall(context: CallContext, startedAt: number, result: RelayResult): Promise<void> {
|
|
@@ -432,34 +396,22 @@ class PhoneChannel implements Channel {
|
|
|
432
396
|
|
|
433
397
|
// --- Pure helpers ---
|
|
434
398
|
|
|
435
|
-
async function readForm(req: Request): Promise<{ params: Record<string, string> }> {
|
|
436
|
-
const body = await req.text();
|
|
437
|
-
const params: Record<string, string> = {};
|
|
438
|
-
const usp = new URLSearchParams(body);
|
|
439
|
-
for (const [k, v] of usp) params[k] = v;
|
|
440
|
-
return { params };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
399
|
function twimlResponse(xml: string): Response {
|
|
444
400
|
return new Response(xml, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
445
401
|
}
|
|
446
402
|
|
|
447
|
-
function forbidden(): Response {
|
|
448
|
-
return new Response("invalid signature", { status: 403 });
|
|
449
|
-
}
|
|
450
|
-
|
|
451
403
|
// --- Factory ---
|
|
452
404
|
|
|
453
405
|
let _instance: PhoneChannel | null = null;
|
|
454
406
|
|
|
455
407
|
export function createPhoneChannel(): PhoneChannel | null {
|
|
456
|
-
const
|
|
457
|
-
if (!
|
|
458
|
-
|
|
408
|
+
const { twilio, phone } = getConfig().channels;
|
|
409
|
+
if (!phone.enabled) return null;
|
|
410
|
+
if (!twilio.sid || !twilio.secret || !phone.from_number) return null;
|
|
411
|
+
_instance = new PhoneChannel(phone, twilio);
|
|
459
412
|
return _instance;
|
|
460
413
|
}
|
|
461
414
|
|
|
462
|
-
/** Used by the place_call MCP tool and CLI test entrypoint. Null if not running. */
|
|
463
415
|
export function getPhoneChannel(): PhoneChannel | null {
|
|
464
416
|
return _instance;
|
|
465
417
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS channel via Twilio.
|
|
3
|
+
*
|
|
4
|
+
* Same Twilio number as voice (channels.phone.from_number by default,
|
|
5
|
+
* overridable via channels.sms.from_number). Inbound webhook →
|
|
6
|
+
* chat engine → REST reply. Reuses the shared TwilioWebhookServer for
|
|
7
|
+
* routing, signature validation, dedup, and rate-limiting.
|
|
8
|
+
*
|
|
9
|
+
* Use case: cellular-only-no-data reachability — Aman can text Nia from
|
|
10
|
+
* patchy zones (Ladakh highways, basements, etc.) where Telegram /
|
|
11
|
+
* WhatsApp / voice over data won't work but SMS over SS7 still does.
|
|
12
|
+
*
|
|
13
|
+
* Note: outbound from US Twilio long codes to Indian mobile numbers has
|
|
14
|
+
* variable deliverability under TRAI scrubbing rules. Test empirically;
|
|
15
|
+
* if outbound fails, the inbound leg (Aman → Nia) is more reliable.
|
|
16
|
+
*/
|
|
17
|
+
import { createChatEngine } from "../chat/engine";
|
|
18
|
+
import { getMcpServers } from "../mcp";
|
|
19
|
+
import { Session } from "../db/models";
|
|
20
|
+
import { runMigrations } from "../db/migrate";
|
|
21
|
+
import type { Channel, ChatState, PhoneConfig, SmsConfig, TwilioConfig } from "../types";
|
|
22
|
+
import { getConfig } from "../utils/config";
|
|
23
|
+
import { log } from "../utils/log";
|
|
24
|
+
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
25
|
+
import { getTwilioServer } from "./twilio/server";
|
|
26
|
+
|
|
27
|
+
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
28
|
+
|
|
29
|
+
class SmsChannel implements Channel {
|
|
30
|
+
name = "sms";
|
|
31
|
+
private readonly twilio: TwilioConfig;
|
|
32
|
+
private readonly sms: SmsConfig;
|
|
33
|
+
/** Cached resolved "from" number: sms.from_number || phone.from_number */
|
|
34
|
+
private readonly fromNumber: string;
|
|
35
|
+
private readonly chats = new Map<string, ChatState>();
|
|
36
|
+
|
|
37
|
+
constructor(twilio: TwilioConfig, sms: SmsConfig, fromNumber: string) {
|
|
38
|
+
this.twilio = twilio;
|
|
39
|
+
this.sms = sms;
|
|
40
|
+
this.fromNumber = fromNumber;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async start(): Promise<void> {
|
|
44
|
+
await runMigrations();
|
|
45
|
+
|
|
46
|
+
const server = getTwilioServer();
|
|
47
|
+
server.configure({
|
|
48
|
+
port: this.twilio.port,
|
|
49
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
50
|
+
signingToken: this.twilio.auth_token || this.twilio.secret,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
server.registerHttp("/twilio/sms/incoming", (_req, ctx) => this.handleInbound(ctx.params), {
|
|
54
|
+
dedupOn: "MessageSid",
|
|
55
|
+
rateLimitOn: "From",
|
|
56
|
+
});
|
|
57
|
+
server.registerHttp("/twilio/sms/status", (_req, ctx) => this.handleStatus(ctx.params), {
|
|
58
|
+
dedupOn: "MessageSid",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
|
|
62
|
+
|
|
63
|
+
await server.start();
|
|
64
|
+
|
|
65
|
+
log.info(
|
|
66
|
+
{
|
|
67
|
+
from: this.fromNumber,
|
|
68
|
+
owner: this.twilio.owner_number,
|
|
69
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
70
|
+
},
|
|
71
|
+
"sms channel started",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async stop(): Promise<void> {
|
|
76
|
+
for (const state of this.chats.values()) state.engine.close();
|
|
77
|
+
this.chats.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Outbound to the owner — used by send_message MCP tool. */
|
|
81
|
+
async sendMessage(text: string): Promise<void> {
|
|
82
|
+
if (!this.twilio.owner_number) throw new Error("sms: owner_number not set");
|
|
83
|
+
await this.sendTo(this.twilio.owner_number, text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Inbound webhook ---
|
|
87
|
+
|
|
88
|
+
private async handleInbound(params: Record<string, string>): Promise<Response> {
|
|
89
|
+
const from = params.From || "";
|
|
90
|
+
const body = params.Body || "";
|
|
91
|
+
|
|
92
|
+
if (!this.isAllowed(from)) {
|
|
93
|
+
log.warn({ from }, "sms: rejecting non-allowlisted sender");
|
|
94
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const state = await this.getState(from);
|
|
98
|
+
// Ack the webhook immediately; reply via REST asynchronously to avoid
|
|
99
|
+
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
100
|
+
state.lock = state.lock.then(
|
|
101
|
+
async () => {
|
|
102
|
+
try {
|
|
103
|
+
const { result } = await state.engine.send(body);
|
|
104
|
+
const reply = result.trim() || "(no response)";
|
|
105
|
+
await this.sendTo(from, reply);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.error({ err, from }, "sms: engine error");
|
|
108
|
+
await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
(err) => log.error({ err, from }, "sms: lock chain error"),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
118
|
+
log.info(
|
|
119
|
+
{
|
|
120
|
+
messageSid: params.MessageSid,
|
|
121
|
+
status: params.MessageStatus,
|
|
122
|
+
errorCode: params.ErrorCode,
|
|
123
|
+
to: params.To,
|
|
124
|
+
},
|
|
125
|
+
"sms: delivery status",
|
|
126
|
+
);
|
|
127
|
+
return new Response("", { status: 204 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Outbound ---
|
|
131
|
+
|
|
132
|
+
private async sendTo(remoteE164: string, body: string): Promise<void> {
|
|
133
|
+
if (!this.twilio.sid || !this.twilio.secret) {
|
|
134
|
+
log.warn("sms: twilio sid/secret missing, cannot send");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const res = await twilioSendMessage({
|
|
139
|
+
accountSid: this.twilio.sid,
|
|
140
|
+
authSid: this.twilio.sid,
|
|
141
|
+
authSecret: this.twilio.secret,
|
|
142
|
+
to: remoteE164,
|
|
143
|
+
from: this.fromNumber,
|
|
144
|
+
body,
|
|
145
|
+
statusCallbackUrl: this.twilio.public_base_url ? `${this.twilio.public_base_url}/twilio/sms/status` : undefined,
|
|
146
|
+
});
|
|
147
|
+
log.info({ to: remoteE164, sid: res.messageSid, status: res.status }, "sms: sent");
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.error({ err, to: remoteE164 }, "sms: send failed");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Helpers ---
|
|
154
|
+
|
|
155
|
+
private isAllowed(remoteE164: string): boolean {
|
|
156
|
+
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
157
|
+
return this.twilio.allowlist.includes(remoteE164);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async getState(remoteE164: string): Promise<ChatState> {
|
|
161
|
+
let state = this.chats.get(remoteE164);
|
|
162
|
+
if (state) return state;
|
|
163
|
+
const prefix = `sms-${remoteE164}`;
|
|
164
|
+
const idx = await Session.getLatestRoomIndex(prefix);
|
|
165
|
+
const room = `${prefix}-${idx}`;
|
|
166
|
+
log.info({ remoteE164, room }, "sms: creating chat engine");
|
|
167
|
+
const engine = await createChatEngine({
|
|
168
|
+
room,
|
|
169
|
+
channel: "sms",
|
|
170
|
+
resume: true,
|
|
171
|
+
mcpServers: getMcpServers(),
|
|
172
|
+
});
|
|
173
|
+
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
174
|
+
this.chats.set(remoteE164, state);
|
|
175
|
+
return state;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createSmsChannel(): SmsChannel | null {
|
|
180
|
+
const { twilio, sms, phone } = getConfig().channels;
|
|
181
|
+
if (!sms.enabled) return null;
|
|
182
|
+
if (!twilio.sid || !twilio.secret) return null;
|
|
183
|
+
// sms.from_number falls back to phone.from_number (same number for voice + SMS).
|
|
184
|
+
const fromNumber = sms.from_number ?? phone.from_number;
|
|
185
|
+
if (!fromNumber) return null;
|
|
186
|
+
return new SmsChannel(twilio, sms, fromNumber);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type { SmsChannel };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-bounded set for deduplicating webhook deliveries.
|
|
3
|
+
*
|
|
4
|
+
* Twilio retries webhooks on 5xx/timeouts, so the same MessageSid /
|
|
5
|
+
* CallSid can arrive multiple times. We track recently-seen IDs and
|
|
6
|
+
* drop duplicates, expiring entries after `ttlMs`.
|
|
7
|
+
*/
|
|
8
|
+
export class Dedup {
|
|
9
|
+
private readonly seen = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly ttlMs: number = 10 * 60 * 1000,
|
|
13
|
+
private readonly maxEntries: number = 5000,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** Returns true if this id was already seen recently; false (and records it) otherwise. */
|
|
17
|
+
check(id: string): boolean {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const cutoff = now - this.ttlMs;
|
|
20
|
+
const seenAt = this.seen.get(id);
|
|
21
|
+
if (seenAt !== undefined && seenAt > cutoff) return true;
|
|
22
|
+
this.seen.set(id, now);
|
|
23
|
+
if (this.seen.size > this.maxEntries) this.prune(cutoff);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private prune(cutoff: number): void {
|
|
28
|
+
for (const [k, v] of this.seen) {
|
|
29
|
+
if (v <= cutoff) this.seen.delete(k);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
size(): number {
|
|
34
|
+
return this.seen.size;
|
|
35
|
+
}
|
|
36
|
+
}
|