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.
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Phone channel — voice calling via Twilio + OpenAI Realtime.
3
3
  *
4
- * Boots an HTTP+WebSocket server inside the daemon. Twilio reaches it
5
- * through a public tunnel (cloudflared in our setup). Two surfaces:
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 REST + webhook signature helpers
15
- * - twiml.ts TwiML XML builders
16
- * - relay.ts Twilio Media Stream <-> OpenAI Realtime bridge
17
- * - instructions.ts system-prompt builders for inbound/outbound
18
- * - tools.ts function-calling tools exposed to the realtime model
19
- * - consult.ts escape hatch to Claude for memory-aware reasoning
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 { Server, ServerWebSocket } from "bun";
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 { placeCall as twilioPlaceCall, validateTwilioSignature } from "./twilio";
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 server: Server<WsData> | null = null;
70
- private readonly cfg: PhoneConfig;
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, keyed by streamSid (assigned on Twilio "start" event). */
73
+ /** Active relays keyed by streamSid. */
74
74
  private readonly active = new Map<string, ActiveCall>();
75
- /** Per-callSid completion deferreds. Resolved when the call's transcript is persisted. */
75
+ /** Per-callSid completion deferreds; resolved after persistCall. */
76
76
  private readonly completions = new Map<string, Deferred<RelayResult>>();
77
77
 
78
- constructor(cfg: PhoneConfig) {
79
- this.cfg = cfg;
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
- const self = this;
87
-
88
- this.server = Bun.serve<WsData, never>({
89
- port: this.cfg.port,
90
- async fetch(req, server) {
91
- const path = new URL(req.url).pathname;
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.cfg.port,
140
- publicBaseUrl: this.cfg.public_base_url,
141
- from: this.cfg.from_number,
142
- owner: this.cfg.owner_number,
143
- realtimeModel: this.cfg.realtime_model,
144
- voice: this.cfg.voice,
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(req: Request): Promise<Response> {
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 async handleStatus(req: Request): Promise<Response> {
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 bootstrap ---
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 async bootstrapWsMessage(ws: ServerWebSocket<WsData>, data: string): Promise<void> {
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; // first frame, no streamSid yet
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.cfg.openai_api_key!,
325
- model: this.cfg.realtime_model,
326
- voice: this.cfg.voice,
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); // forward the "start" event itself
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.cfg.openai_api_key && this.cfg.public_base_url);
322
+ return Boolean(this.phone.openai_api_key && this.twilio.public_base_url);
345
323
  }
346
324
 
347
325
  private buildWssUrl(): string {
348
- const base = this.cfg.public_base_url!.replace(/^http/, "ws");
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.cfg.owner_number) return { label: "Aman", allowed: true };
354
- if (from && this.cfg.allowlist.includes(from)) return { label: from, allowed: true };
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 verifySignature(req: Request, params: Record<string, string>): boolean {
359
- // X-Twilio-Signature is HMAC-SHA1 with the account's Auth Token. When an
360
- // API Key (SK…) + Secret is used for REST auth, the Auth Token is a
361
- // separate value set as TWILIO_AUTH_TOKEN. We fall back to TWILIO_SECRET
362
- // so the simple "Account SID + Auth Token" pairing also works.
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.cfg.public_base_url)
380
- throw new Error("phone: channels.phone.public_base_url not set (config.yaml or PUBLIC_BASE_URL env)");
381
- return this.cfg.public_base_url;
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.cfg.from_number)
386
- throw new Error("phone: channels.phone.from_number not set (config.yaml or PHONE_FROM_NUMBER env)");
387
- return this.cfg.from_number;
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 cfg = getConfig().channels.phone;
457
- if (!cfg.twilio_sid || !cfg.twilio_secret || !cfg.from_number) return null;
458
- _instance = new PhoneChannel(cfg);
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
+ }