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/README.md +2 -0
- package/package.json +1 -1
- package/skills/nia-phone/SKILL.md +139 -61
- package/src/channels/common/chat-session.ts +56 -0
- package/src/channels/index.ts +12 -0
- package/src/channels/phone/index.ts +120 -159
- package/src/channels/phone/tools.ts +2 -2
- package/src/channels/slack.ts +62 -82
- package/src/channels/sms.ts +179 -0
- package/src/channels/telegram.ts +46 -54
- 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 +376 -0
- package/src/chat/identity.ts +1 -2
- package/src/cli/phone.ts +30 -21
- package/src/commands/init.ts +17 -14
- package/src/commands/service.ts +26 -7
- package/src/mcp/tools.ts +17 -33
- package/src/types/channel.ts +35 -6
- package/src/types/config.ts +41 -11
- package/src/types/enums.ts +1 -1
- package/src/types/index.ts +11 -2
- package/src/utils/attachment.ts +8 -2
- package/src/utils/config.ts +82 -47
- package/src/channels/phone/twilio.ts +0 -125
package/src/mcp/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync,
|
|
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
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
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({
|
package/src/types/channel.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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;
|
package/src/types/config.ts
CHANGED
|
@@ -28,23 +28,36 @@ export interface SlackConfig {
|
|
|
28
28
|
watch: Record<string, SlackWatchChannel> | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/**
|
|
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
|
|
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 {
|
package/src/types/enums.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/utils/attachment.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AttachmentType } from "../types";
|
|
2
|
-
import {
|
|
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
|
|
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
|
}
|
package/src/utils/config.ts
CHANGED
|
@@ -36,19 +36,30 @@ const DEFAULTS: Config = {
|
|
|
36
36
|
workspace_url: null,
|
|
37
37
|
watch: null,
|
|
38
38
|
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
.
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
}
|