niahere 0.3.0 → 0.3.2

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/src/mcp/tools.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, writeFileSync, appendFileSync, existsSync } from "fs";
2
- import type { ScheduleType } from "../types";
1
+ import { readFileSync, appendFileSync, existsSync } from "fs";
2
+ import type { Recipient, ScheduleType } from "../types";
3
3
  import { basename, join } from "path";
4
4
  import { randomUUID } from "crypto";
5
5
  import { Job, Message, Session } from "../db/models";
@@ -8,7 +8,6 @@ import { getConfig, readRawConfig, updateRawConfig, writeRawConfig } from "../ut
8
8
  import { getPaths } from "../utils/paths";
9
9
  import { getChannel } from "../channels/registry";
10
10
  import { log } from "../utils/log";
11
- import { classifyMime } from "../utils/attachment";
12
11
  import { scanAgents } from "../core/agents";
13
12
  import { listEmployeesForMcp } from "../core/employees";
14
13
  import { resolveJobPrompt } from "../core/job-prompt";
@@ -312,42 +311,27 @@ export async function sendMessage(
312
311
  }
313
312
 
314
313
  try {
315
- // Handle media attachment if provided
314
+ let media: { data: Uint8Array; mimeType: string; filename: string } | undefined;
316
315
  if (mediaPath) {
317
316
  if (!existsSync(mediaPath)) {
318
317
  if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
319
318
  return `Failed to send: file not found: ${mediaPath}`;
320
319
  }
321
- const data = readFileSync(mediaPath);
322
- const mimeType = guessMime(mediaPath);
323
- const filename = basename(mediaPath);
324
-
325
- if (useThread && channel?.sendMediaToThread) {
326
- await channel.sendMediaToThread(sourceCtx!.slackChannelId!, data, mimeType, filename, sourceCtx!.slackThreadTs);
327
- } else if (channel?.sendMedia) {
328
- await channel.sendMedia(data, mimeType, filename);
329
- } else {
330
- await sendMediaDirect(channelTarget, data, mimeType, filename);
331
- }
320
+ const buf = readFileSync(mediaPath);
321
+ media = { data: new Uint8Array(buf), mimeType: guessMime(mediaPath), filename: basename(mediaPath) };
322
+ }
332
323
 
333
- // Also send text if provided (as a separate message)
334
- if (text) {
335
- if (useThread && channel?.sendToThread) {
336
- await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
337
- } else if (channel?.sendMessage) {
338
- await channel.sendMessage(text);
339
- } else {
340
- await sendDirect(channelTarget, text);
341
- }
342
- }
324
+ const recipient: Recipient = useThread
325
+ ? { kind: "thread", channelId: sourceCtx!.slackChannelId!, threadTs: sourceCtx!.slackThreadTs }
326
+ : { kind: "owner" };
327
+
328
+ if (channel) {
329
+ await channel.deliver({ text: text || undefined, media, to: recipient });
343
330
  } else {
344
- if (useThread && channel?.sendToThread) {
345
- await channel.sendToThread(sourceCtx!.slackChannelId!, text, sourceCtx!.slackThreadTs);
346
- } else if (channel?.sendMessage) {
347
- await channel.sendMessage(text);
348
- } else {
349
- await sendDirect(channelTarget, text);
350
- }
331
+ // No started channel in this process (e.g. CLI `nia send` outside the daemon).
332
+ // Fall back to API-direct send — text-only, no thread fan-out.
333
+ if (media) await sendMediaDirect(channelTarget, Buffer.from(media.data), media.mimeType, media.filename);
334
+ if (text) await sendDirect(channelTarget, text);
351
335
  }
352
336
 
353
337
  // Mark as sent
@@ -480,7 +464,7 @@ export async function placeCall(args: {
480
464
  const { getPhoneChannel } = await import("../channels/phone");
481
465
  const phone = getPhoneChannel();
482
466
  if (!phone) {
483
- return "Phone channel is not configured. Add a channels.phone block to ~/.niahere/config.yaml with twilio_sid, twilio_secret, from_number, public_base_url, openai_api_key (or set the matching env vars in .env), then restart the daemon.";
467
+ return "Phone channel is not configured. Add channels.twilio.{sid, secret, public_base_url} and channels.phone.{from_number, openai_api_key} to ~/.niahere/config.yaml (or set the matching env vars in .env), then restart the daemon.";
484
468
  }
485
469
  try {
486
470
  const result = await phone.placeCall({
@@ -1,13 +1,42 @@
1
+ /**
2
+ * Where an outbound payload is delivered.
3
+ *
4
+ * - `owner` → the channel's configured default recipient (DM user, owner
5
+ * phone number, etc.). Always supported.
6
+ * - `thread` → reply in a specific Slack thread. Channels that don't
7
+ * support threads fall back to `owner`.
8
+ */
9
+ export type Recipient = { kind: "owner" } | { kind: "thread"; channelId: string; threadTs?: string };
10
+
11
+ export interface OutboundMedia {
12
+ data: Uint8Array;
13
+ mimeType: string;
14
+ filename?: string;
15
+ }
16
+
17
+ /**
18
+ * Structured payload for agent-initiated outbound messages (`send_message`
19
+ * MCP tool, cross-channel notifications). Replaces the old optional
20
+ * sendMessage / sendMedia / sendToThread / sendMediaToThread surface.
21
+ */
22
+ export interface Outbound {
23
+ text?: string;
24
+ media?: OutboundMedia;
25
+ /** Defaults to `{ kind: "owner" }`. */
26
+ to?: Recipient;
27
+ }
28
+
1
29
  export interface Channel {
30
+ /** Channel identifier. Built-in channels use the `ChannelName` literals; test fixtures may use other strings. */
2
31
  name: string;
3
32
  start(): Promise<void>;
4
33
  stop(): Promise<void>;
5
- sendMessage?(text: string): Promise<void>;
6
- sendMedia?(data: Buffer, mimeType: string, filename?: string): Promise<void>;
7
- /** Send media to a specific channel/thread when the channel supports it. */
8
- sendMediaToThread?(channelId: string, data: Buffer, mimeType: string, filename?: string, threadTs?: string): Promise<void>;
9
- /** Send a message to a specific channel/thread (e.g. reply back to a Slack thread). */
10
- sendToThread?(channelId: string, text: string, threadTs?: string): Promise<void>;
34
+ /**
35
+ * Deliver an outbound payload. Channels are expected to handle either
36
+ * a text-only, media-only, or text+media payload; format details (chunking,
37
+ * markdown, attachment shape) are channel-specific.
38
+ */
39
+ deliver(out: Outbound): Promise<void>;
11
40
  }
12
41
 
13
42
  export type ChannelFactory = () => Channel | null;
@@ -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";
@@ -3,9 +3,18 @@ export type { JobStatus, JobStateStatus, JobLifecycle, ScheduleType, Mode, Attac
3
3
  export type { JobInput, JobPromptSource, JobResult, ResolvedJobPrompt } from "./job";
4
4
  export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "./engine";
5
5
  export type { AuditEntry, JobState, CronState } from "./audit";
6
- export type { Channel, ChannelFactory } from "./channel";
6
+ export type { Channel, ChannelFactory, Outbound, OutboundMedia, Recipient } 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";
@@ -1,5 +1,11 @@
1
1
  import type { AttachmentType } from "../types";
2
- import { IMAGE_MIMES, DOCUMENT_MIMES, MAX_ATTACHMENT_SIZE, MAX_IMAGE_DIMENSION, JPEG_QUALITY } from "../constants/attachment";
2
+ import {
3
+ IMAGE_MIMES,
4
+ DOCUMENT_MIMES,
5
+ MAX_ATTACHMENT_SIZE,
6
+ MAX_IMAGE_DIMENSION,
7
+ JPEG_QUALITY,
8
+ } from "../constants/attachment";
3
9
 
4
10
  export function classifyMime(mimeType: string): AttachmentType | null {
5
11
  if (IMAGE_MIMES.has(mimeType)) return "image";
@@ -8,7 +14,7 @@ export function classifyMime(mimeType: string): AttachmentType | null {
8
14
  return "file";
9
15
  }
10
16
 
11
- export function validateAttachment(data: Buffer, mimeType: string): string | null {
17
+ export function validateAttachment(data: Buffer): string | null {
12
18
  if (data.length > MAX_ATTACHMENT_SIZE) {
13
19
  return `File too large (${(data.length / 1024 / 1024).toFixed(1)}MB, max ${MAX_ATTACHMENT_SIZE / 1024 / 1024}MB)`;
14
20
  }
@@ -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,15 @@ 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
+ const twilioField = (key: string, envKey: string | null): string | null => {
150
+ const envVal = envKey ? process.env[envKey] : undefined;
151
+ if (envVal) return envVal;
152
+ return typeof chTw[key] === "string" ? (chTw[key] as string) : null;
153
+ };
134
154
 
135
155
  const channelsEnabled = ch.enabled !== false;
136
156
 
@@ -150,11 +170,7 @@ export function loadConfig(): Config {
150
170
 
151
171
  const slAppToken = process.env.SLACK_APP_TOKEN || (typeof chSl.app_token === "string" ? chSl.app_token : null);
152
172
 
153
- // Legacy: channel_id was removed in favor of dm_user_id. Fall back to channel_id if dm_user_id is not set.
154
- const legacyChannelId =
155
- process.env.SLACK_CHANNEL_ID || (typeof chSl.channel_id === "string" ? chSl.channel_id : null);
156
- const slDmUserId =
157
- process.env.SLACK_DM_USER_ID || (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null) || legacyChannelId;
173
+ const slDmUserId = process.env.SLACK_DM_USER_ID || (typeof chSl.dm_user_id === "string" ? chSl.dm_user_id : null);
158
174
 
159
175
  const slBotUserId = typeof chSl.bot_user_id === "string" ? chSl.bot_user_id : null;
160
176
  const slBotName = typeof chSl.bot_name === "string" ? chSl.bot_name : null;
@@ -162,29 +178,35 @@ export function loadConfig(): Config {
162
178
  const slWorkspaceId = typeof chSl.workspace_id === "string" ? chSl.workspace_id : null;
163
179
  const slWorkspaceUrl = typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
164
180
 
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);
181
+ // --- Twilio shared config (used by phone, sms, whatsapp) ---
182
+ // Env vars take precedence over channels.twilio.* values.
183
+ const twSid = twilioField("sid", "TWILIO_SID");
184
+ const twSecret = twilioField("secret", "TWILIO_SECRET");
185
+ const twAuthToken = twilioField("auth_token", "TWILIO_AUTH_TOKEN");
186
+ const twOwnerNumber = twilioField("owner_number", "PRIMARY_PHONE_USER");
187
+ const twPublicBaseUrl = (twilioField("public_base_url", "PUBLIC_BASE_URL") || "").replace(/\/$/, "") || null;
188
+
189
+ const twPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
190
+ const twPort =
191
+ twPortRaw && Number.isFinite(twPortRaw)
192
+ ? twPortRaw
193
+ : typeof chTw.port === "number"
194
+ ? chTw.port
195
+ : DEFAULTS.channels.twilio.port;
196
+
197
+ const twAllowlistRaw =
198
+ process.env.PHONE_ALLOWLIST ||
199
+ (Array.isArray(chTw.allowlist)
200
+ ? (chTw.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
201
+ : "");
202
+ const twAllowlist = twAllowlistRaw
203
+ .split(",")
204
+ .map((s) => s.trim())
205
+ .filter((s) => s.length > 0);
206
+
207
+ // --- Phone (voice) — env vars override config ---
171
208
  const phFromNumber =
172
209
  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
210
  const phOpenAiKey =
189
211
  process.env.OPENAI_API_KEY || (typeof chPh.openai_api_key === "string" ? chPh.openai_api_key : null);
190
212
  const phRealtimeModel =
@@ -192,16 +214,18 @@ export function loadConfig(): Config {
192
214
  (typeof chPh.realtime_model === "string" ? chPh.realtime_model : DEFAULTS.channels.phone.realtime_model);
193
215
  const phVoice =
194
216
  process.env.PHONE_VOICE || (typeof chPh.voice === "string" ? chPh.voice : DEFAULTS.channels.phone.voice);
217
+ const phEnabled = chPh.enabled !== false;
195
218
 
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);
219
+ // --- SMS ---
220
+ const smsFromNumber =
221
+ process.env.SMS_FROM_NUMBER || (typeof chSms.from_number === "string" ? chSms.from_number : null);
222
+ const smsEnabled = chSms.enabled !== false;
223
+
224
+ // --- WhatsApp ---
225
+ const waFromNumber =
226
+ process.env.WHATSAPP_FROM_NUMBER ||
227
+ (typeof chWa.from_number === "string" ? chWa.from_number : DEFAULTS.channels.whatsapp.from_number);
228
+ const waEnabled = chWa.enabled !== false;
205
229
 
206
230
  // Slack watch channels — behavior is optional (defaults to key name lookup)
207
231
  const rawWatch = chSl.watch as Record<string, unknown> | undefined;
@@ -242,19 +266,30 @@ export function loadConfig(): Config {
242
266
  workspace_url: slWorkspaceUrl,
243
267
  watch: slWatch,
244
268
  },
269
+ twilio: {
270
+ sid: twSid,
271
+ secret: twSecret,
272
+ auth_token: twAuthToken,
273
+ owner_number: twOwnerNumber,
274
+ allowlist: twAllowlist,
275
+ public_base_url: twPublicBaseUrl,
276
+ port: twPort,
277
+ },
245
278
  phone: {
246
- twilio_sid: phTwilioSid,
247
- twilio_secret: phTwilioSecret,
248
- twilio_auth_token: phTwilioAuthToken,
279
+ enabled: phEnabled,
249
280
  from_number: phFromNumber,
250
- owner_number: phOwnerNumber,
251
- allowlist: phAllowlist,
252
- public_base_url: phPublicBaseUrl,
253
- port: phPort,
254
281
  openai_api_key: phOpenAiKey,
255
282
  realtime_model: phRealtimeModel,
256
283
  voice: phVoice,
257
284
  },
285
+ sms: {
286
+ enabled: smsEnabled,
287
+ from_number: smsFromNumber,
288
+ },
289
+ whatsapp: {
290
+ enabled: waEnabled,
291
+ from_number: waFromNumber,
292
+ },
258
293
  },
259
294
  };
260
295
  }
@@ -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
- }