niahere 0.2.91 → 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.
@@ -28,23 +28,36 @@ export interface SlackConfig {
28
28
  watch: Record<string, SlackWatchChannel> | null;
29
29
  }
30
30
 
31
- export interface PhoneConfig {
32
- twilio_sid: string | null;
33
- twilio_secret: string | null;
34
- /** Account-level Auth Token used to verify X-Twilio-Signature on inbound webhooks.
35
- * Falls back to twilio_secret if not set (works when twilio_sid is the Account SID
36
- * and twilio_secret is the Auth Token). */
37
- twilio_auth_token: string | null;
38
- /** Twilio number Nia dials from (E.164, e.g. +13025480697) */
39
- from_number: string | null;
40
- /** Owner's phone number (E.164). Highest-trust caller. */
31
+ /**
32
+ * Shared config for all Twilio-based channels (phone/sms/whatsapp).
33
+ * Credentials, owner identity, public URL, and the local webhook port
34
+ * live here so individual channels don't reach into each other's configs.
35
+ */
36
+ export interface TwilioConfig {
37
+ /** SID used for both URL paths and Basic auth.
38
+ * Usually the Account SID (AC…). Can be an API Key SID (SK…) — Twilio resolves it. */
39
+ sid: string | null;
40
+ /** Basic auth password. Account Auth Token if sid is AC…, API Key Secret if SK…. */
41
+ secret: string | null;
42
+ /** Account-level Auth Token. Used to verify X-Twilio-Signature on inbound webhooks.
43
+ * If sid is an API Key SID (SK…), this MUST be set separately. Falls back to `secret`
44
+ * when sid is an Account SID and `secret` is the Auth Token. */
45
+ auth_token: string | null;
46
+ /** Owner's phone number (E.164). Highest-trust caller / messenger. */
41
47
  owner_number: string | null;
42
48
  /** Extra allowlisted E.164 numbers (family, close contacts). */
43
49
  allowlist: string[];
44
50
  /** Public base URL Twilio hits (e.g. https://nia.example.com). No trailing slash. */
45
51
  public_base_url: string | null;
46
- /** Local HTTP port for the Twilio webhook server. */
52
+ /** Local HTTP port the shared Twilio webhook server binds to. */
47
53
  port: number;
54
+ }
55
+
56
+ /** Voice (Twilio Programmable Voice + OpenAI Realtime). */
57
+ export interface PhoneConfig {
58
+ enabled: boolean;
59
+ /** Twilio number Nia dials from / inbound voice number (E.164). */
60
+ from_number: string | null;
48
61
  /** OpenAI API key for the Realtime voice loop. */
49
62
  openai_api_key: string | null;
50
63
  /** OpenAI Realtime model id. */
@@ -53,12 +66,29 @@ export interface PhoneConfig {
53
66
  voice: string;
54
67
  }
55
68
 
69
+ /** SMS via Twilio (uses the shared TwilioConfig credentials). */
70
+ export interface SmsConfig {
71
+ enabled: boolean;
72
+ /** E.164 number SMS is sent from. Defaults to phone.from_number. */
73
+ from_number: string | null;
74
+ }
75
+
76
+ /** WhatsApp via Twilio (sandbox by default; uses shared TwilioConfig). */
77
+ export interface WhatsappConfig {
78
+ enabled: boolean;
79
+ /** WhatsApp sender E.164. Defaults to Twilio Sandbox shared number +14155238886. */
80
+ from_number: string | null;
81
+ }
82
+
56
83
  export interface ChannelsConfig {
57
84
  enabled: boolean;
58
85
  default: string;
59
86
  telegram: TelegramConfig;
60
87
  slack: SlackConfig;
88
+ twilio: TwilioConfig;
61
89
  phone: PhoneConfig;
90
+ sms: SmsConfig;
91
+ whatsapp: WhatsappConfig;
62
92
  }
63
93
 
64
94
  export interface SessionFinalizationConfig {
@@ -17,4 +17,4 @@ export type Mode = "chat" | "job";
17
17
  export type AttachmentType = "image" | "document" | "file";
18
18
 
19
19
  /** Channel names. */
20
- export type ChannelName = "telegram" | "slack";
20
+ export type ChannelName = "telegram" | "slack" | "phone" | "sms" | "whatsapp";
@@ -5,7 +5,16 @@ export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatE
5
5
  export type { AuditEntry, JobState, CronState } from "./audit";
6
6
  export type { Channel, ChannelFactory } from "./channel";
7
7
  export type { ChatState } from "./chat-state";
8
- export type { Config, ChannelsConfig, TelegramConfig, SlackConfig, PhoneConfig } from "./config";
8
+ export type {
9
+ Config,
10
+ ChannelsConfig,
11
+ TelegramConfig,
12
+ SlackConfig,
13
+ TwilioConfig,
14
+ PhoneConfig,
15
+ SmsConfig,
16
+ WhatsappConfig,
17
+ } from "./config";
9
18
  export type { Paths } from "./paths";
10
19
  export type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "./message";
11
20
  export type { AgentInfo } from "./agent";
@@ -36,19 +36,30 @@ const DEFAULTS: Config = {
36
36
  workspace_url: null,
37
37
  watch: null,
38
38
  },
39
- phone: {
40
- twilio_sid: null,
41
- twilio_secret: null,
42
- twilio_auth_token: null,
43
- from_number: null,
39
+ twilio: {
40
+ sid: null,
41
+ secret: null,
42
+ auth_token: null,
44
43
  owner_number: null,
45
44
  allowlist: [],
46
45
  public_base_url: null,
47
46
  port: 7079,
47
+ },
48
+ phone: {
49
+ enabled: true,
50
+ from_number: null,
48
51
  openai_api_key: null,
49
52
  realtime_model: "gpt-realtime",
50
53
  voice: "marin",
51
54
  },
55
+ sms: {
56
+ enabled: true,
57
+ from_number: null,
58
+ },
59
+ whatsapp: {
60
+ enabled: true,
61
+ from_number: "+14155238886",
62
+ },
52
63
  },
53
64
  };
54
65
 
@@ -131,6 +142,19 @@ export function loadConfig(): Config {
131
142
  const chTg = (ch.telegram || {}) as Record<string, unknown>;
132
143
  const chSl = (ch.slack || {}) as Record<string, unknown>;
133
144
  const chPh = (ch.phone || {}) as Record<string, unknown>;
145
+ const chTw = (ch.twilio || {}) as Record<string, unknown>;
146
+ const chSms = (ch.sms || {}) as Record<string, unknown>;
147
+ const chWa = (ch.whatsapp || {}) as Record<string, unknown>;
148
+
149
+ // Helper: read a value from `channels.twilio.<newKey>`, falling back to
150
+ // legacy `channels.phone.<oldKey>` (the pre-refactor location), then env.
151
+ const twilioOrPhone = (newKey: string, oldKey: string, envKey: string | null): string | null => {
152
+ const envVal = envKey ? process.env[envKey] : undefined;
153
+ if (envVal) return envVal;
154
+ if (typeof chTw[newKey] === "string") return chTw[newKey] as string;
155
+ if (typeof chPh[oldKey] === "string") return chPh[oldKey] as string;
156
+ return null;
157
+ };
134
158
 
135
159
  const channelsEnabled = ch.enabled !== false;
136
160
 
@@ -162,29 +186,42 @@ export function loadConfig(): Config {
162
186
  const slWorkspaceId = typeof chSl.workspace_id === "string" ? chSl.workspace_id : null;
163
187
  const slWorkspaceUrl = typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
164
188
 
165
- // Phone env vars override config; secrets are env-only by convention
166
- const phTwilioSid = process.env.TWILIO_SID || (typeof chPh.twilio_sid === "string" ? chPh.twilio_sid : null);
167
- const phTwilioSecret =
168
- process.env.TWILIO_SECRET || (typeof chPh.twilio_secret === "string" ? chPh.twilio_secret : null);
169
- const phTwilioAuthToken =
170
- process.env.TWILIO_AUTH_TOKEN || (typeof chPh.twilio_auth_token === "string" ? chPh.twilio_auth_token : null);
189
+ // --- Twilio shared config (used by phone, sms, whatsapp) ---
190
+ // New shape: channels.twilio.{sid,secret,auth_token,owner_number,allowlist,public_base_url,port}.
191
+ // Legacy shape kept for one release: channels.phone.{twilio_sid,twilio_secret,twilio_auth_token,
192
+ // owner_number,allowlist,public_base_url,port}. Env vars take precedence over both.
193
+ const twSid = twilioOrPhone("sid", "twilio_sid", "TWILIO_SID");
194
+ const twSecret = twilioOrPhone("secret", "twilio_secret", "TWILIO_SECRET");
195
+ const twAuthToken = twilioOrPhone("auth_token", "twilio_auth_token", "TWILIO_AUTH_TOKEN");
196
+ const twOwnerNumber = twilioOrPhone("owner_number", "owner_number", "PRIMARY_PHONE_USER");
197
+ const twPublicBaseUrl =
198
+ (twilioOrPhone("public_base_url", "public_base_url", "PUBLIC_BASE_URL") || "").replace(/\/$/, "") || null;
199
+
200
+ const twPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
201
+ const twPort =
202
+ twPortRaw && Number.isFinite(twPortRaw)
203
+ ? twPortRaw
204
+ : typeof chTw.port === "number"
205
+ ? chTw.port
206
+ : typeof chPh.port === "number"
207
+ ? chPh.port
208
+ : DEFAULTS.channels.twilio.port;
209
+
210
+ const twAllowlistRaw =
211
+ process.env.PHONE_ALLOWLIST ||
212
+ (Array.isArray(chTw.allowlist)
213
+ ? (chTw.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
214
+ : Array.isArray(chPh.allowlist)
215
+ ? (chPh.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
216
+ : "");
217
+ const twAllowlist = twAllowlistRaw
218
+ .split(",")
219
+ .map((s) => s.trim())
220
+ .filter((s) => s.length > 0);
221
+
222
+ // --- Phone (voice) — env vars and new keys override legacy ---
171
223
  const phFromNumber =
172
224
  process.env.PHONE_FROM_NUMBER || (typeof chPh.from_number === "string" ? chPh.from_number : null);
173
- const phOwnerNumber =
174
- process.env.PRIMARY_PHONE_USER || (typeof chPh.owner_number === "string" ? chPh.owner_number : null);
175
- const phPublicBaseUrl =
176
- (
177
- process.env.PUBLIC_BASE_URL ||
178
- (typeof chPh.public_base_url === "string" ? chPh.public_base_url : null) ||
179
- ""
180
- ).replace(/\/$/, "") || null;
181
- const phPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
182
- const phPort =
183
- phPortRaw && Number.isFinite(phPortRaw)
184
- ? phPortRaw
185
- : typeof chPh.port === "number"
186
- ? chPh.port
187
- : DEFAULTS.channels.phone.port;
188
225
  const phOpenAiKey =
189
226
  process.env.OPENAI_API_KEY || (typeof chPh.openai_api_key === "string" ? chPh.openai_api_key : null);
190
227
  const phRealtimeModel =
@@ -192,16 +229,18 @@ export function loadConfig(): Config {
192
229
  (typeof chPh.realtime_model === "string" ? chPh.realtime_model : DEFAULTS.channels.phone.realtime_model);
193
230
  const phVoice =
194
231
  process.env.PHONE_VOICE || (typeof chPh.voice === "string" ? chPh.voice : DEFAULTS.channels.phone.voice);
232
+ const phEnabled = chPh.enabled !== false;
195
233
 
196
- const phAllowlistRaw =
197
- process.env.PHONE_ALLOWLIST ||
198
- (Array.isArray(chPh.allowlist)
199
- ? (chPh.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
200
- : "");
201
- const phAllowlist = phAllowlistRaw
202
- .split(",")
203
- .map((s) => s.trim())
204
- .filter((s) => s.length > 0);
234
+ // --- SMS ---
235
+ const smsFromNumber =
236
+ process.env.SMS_FROM_NUMBER || (typeof chSms.from_number === "string" ? chSms.from_number : null);
237
+ const smsEnabled = chSms.enabled !== false;
238
+
239
+ // --- WhatsApp ---
240
+ const waFromNumber =
241
+ process.env.WHATSAPP_FROM_NUMBER ||
242
+ (typeof chWa.from_number === "string" ? chWa.from_number : DEFAULTS.channels.whatsapp.from_number);
243
+ const waEnabled = chWa.enabled !== false;
205
244
 
206
245
  // Slack watch channels — behavior is optional (defaults to key name lookup)
207
246
  const rawWatch = chSl.watch as Record<string, unknown> | undefined;
@@ -242,19 +281,30 @@ export function loadConfig(): Config {
242
281
  workspace_url: slWorkspaceUrl,
243
282
  watch: slWatch,
244
283
  },
284
+ twilio: {
285
+ sid: twSid,
286
+ secret: twSecret,
287
+ auth_token: twAuthToken,
288
+ owner_number: twOwnerNumber,
289
+ allowlist: twAllowlist,
290
+ public_base_url: twPublicBaseUrl,
291
+ port: twPort,
292
+ },
245
293
  phone: {
246
- twilio_sid: phTwilioSid,
247
- twilio_secret: phTwilioSecret,
248
- twilio_auth_token: phTwilioAuthToken,
294
+ enabled: phEnabled,
249
295
  from_number: phFromNumber,
250
- owner_number: phOwnerNumber,
251
- allowlist: phAllowlist,
252
- public_base_url: phPublicBaseUrl,
253
- port: phPort,
254
296
  openai_api_key: phOpenAiKey,
255
297
  realtime_model: phRealtimeModel,
256
298
  voice: phVoice,
257
299
  },
300
+ sms: {
301
+ enabled: smsEnabled,
302
+ from_number: smsFromNumber,
303
+ },
304
+ whatsapp: {
305
+ enabled: waEnabled,
306
+ from_number: waFromNumber,
307
+ },
258
308
  },
259
309
  };
260
310
  }
@@ -1,125 +0,0 @@
1
- /**
2
- * Minimal Twilio REST + webhook-signature helpers.
3
- * Skips the official SDK so the daemon's dependency surface stays small —
4
- * the surface we use (place call, hang up, swap URL, validate signature) is
5
- * a handful of well-documented endpoints.
6
- */
7
- import { createHmac, timingSafeEqual } from "crypto";
8
-
9
- const TWILIO_BASE = "https://api.twilio.com/2010-04-01";
10
-
11
- interface TwilioCreds {
12
- accountSid: string;
13
- authToken: string;
14
- }
15
-
16
- function basicAuth({ accountSid, authToken }: TwilioCreds): string {
17
- return `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`;
18
- }
19
-
20
- function accountUrl({ accountSid }: TwilioCreds, suffix: string): string {
21
- return `${TWILIO_BASE}/Accounts/${encodeURIComponent(accountSid)}${suffix}`;
22
- }
23
-
24
- /**
25
- * Validate a Twilio webhook signature.
26
- * Algorithm (per Twilio's webhook security docs):
27
- * 1. Take the full URL Twilio sent the request to (including query string).
28
- * 2. For application/x-www-form-urlencoded bodies, sort POST keys and
29
- * append each "key" + "value" to the URL string.
30
- * 3. HMAC-SHA1 with the account AuthToken, base64-encode.
31
- * 4. Timing-safe compare with the X-Twilio-Signature header.
32
- */
33
- export function validateTwilioSignature(opts: {
34
- authToken: string;
35
- fullUrl: string;
36
- params: Record<string, string>;
37
- signature: string;
38
- }): boolean {
39
- const { authToken, fullUrl, params, signature } = opts;
40
- if (!signature) return false;
41
-
42
- const sortedKeys = Object.keys(params).sort();
43
- let data = fullUrl;
44
- for (const key of sortedKeys) {
45
- data += key + params[key];
46
- }
47
-
48
- const computed = createHmac("sha1", authToken).update(data, "utf8").digest("base64");
49
- const a = Buffer.from(computed);
50
- const b = Buffer.from(signature);
51
- if (a.length !== b.length) return false;
52
- return timingSafeEqual(a, b);
53
- }
54
-
55
- export interface PlaceCallOpts extends TwilioCreds {
56
- to: string;
57
- from: string;
58
- twimlUrl: string;
59
- statusCallbackUrl?: string;
60
- /** Hard cap on call duration (seconds). Maps to Twilio's TimeLimit param. */
61
- maxDurationSec?: number;
62
- }
63
-
64
- export interface PlaceCallResult {
65
- callSid: string;
66
- status: string;
67
- }
68
-
69
- export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
70
- const body = new URLSearchParams({
71
- To: opts.to,
72
- From: opts.from,
73
- Url: opts.twimlUrl,
74
- Method: "POST",
75
- });
76
- if (opts.statusCallbackUrl) {
77
- body.set("StatusCallback", opts.statusCallbackUrl);
78
- body.set("StatusCallbackMethod", "POST");
79
- body.append("StatusCallbackEvent", "initiated");
80
- body.append("StatusCallbackEvent", "answered");
81
- body.append("StatusCallbackEvent", "completed");
82
- }
83
- if (opts.maxDurationSec && opts.maxDurationSec > 0) {
84
- body.set("TimeLimit", String(opts.maxDurationSec));
85
- }
86
-
87
- const resp = await fetch(accountUrl(opts, "/Calls.json"), {
88
- method: "POST",
89
- headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
90
- body: body.toString(),
91
- });
92
- if (!resp.ok) {
93
- const text = await resp.text().catch(() => "");
94
- throw new Error(`Twilio placeCall failed: ${resp.status} ${text}`);
95
- }
96
- const data = (await resp.json()) as { sid: string; status: string };
97
- return { callSid: data.sid, status: data.status };
98
- }
99
-
100
- /** Swap the TwiML URL on an in-flight call (used to inject the real callSid into the path). */
101
- export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
102
- const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
103
- const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
104
- method: "POST",
105
- headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
106
- body: body.toString(),
107
- });
108
- if (!resp.ok) {
109
- const text = await resp.text().catch(() => "");
110
- throw new Error(`Twilio updateCallUrl failed: ${resp.status} ${text}`);
111
- }
112
- }
113
-
114
- export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
115
- const body = new URLSearchParams({ Status: "completed" });
116
- const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
117
- method: "POST",
118
- headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
119
- body: body.toString(),
120
- });
121
- if (!resp.ok) {
122
- const text = await resp.text().catch(() => "");
123
- throw new Error(`Twilio hangupCall failed: ${resp.status} ${text}`);
124
- }
125
- }