niahere 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/skills/nia-phone/SKILL.md +139 -61
- package/src/channels/index.ts +12 -0
- package/src/channels/phone/index.ts +109 -157
- package/src/channels/sms.ts +189 -0
- package/src/channels/twilio/dedup.ts +36 -0
- package/src/channels/twilio/media-cache.ts +107 -0
- package/src/channels/twilio/media.ts +79 -0
- package/src/channels/twilio/rate-limit.ts +33 -0
- package/src/channels/twilio/rest.ts +133 -0
- package/src/channels/twilio/server.ts +255 -0
- package/src/channels/twilio/signature.ts +36 -0
- package/src/channels/twilio/transcribe.ts +58 -0
- package/src/channels/whatsapp.ts +408 -0
- package/src/cli/phone.ts +21 -15
- package/src/types/config.ts +41 -11
- package/src/types/enums.ts +1 -1
- package/src/types/index.ts +10 -1
- package/src/utils/config.ts +92 -42
- package/src/channels/phone/twilio.ts +0 -125
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcribe a short audio clip via OpenAI's gpt-4o-mini-transcribe.
|
|
3
|
+
*
|
|
4
|
+
* Used by the WhatsApp channel for voice notes. We accept the raw bytes
|
|
5
|
+
* + MIME (Twilio's WhatsApp voice notes are typically audio/ogg with
|
|
6
|
+
* opus codec — the endpoint handles ogg natively).
|
|
7
|
+
*/
|
|
8
|
+
import { log } from "../../utils/log";
|
|
9
|
+
|
|
10
|
+
const ENDPOINT = "https://api.openai.com/v1/audio/transcriptions";
|
|
11
|
+
const MODEL = "gpt-4o-mini-transcribe";
|
|
12
|
+
const TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
const MIME_TO_FILENAME: Record<string, string> = {
|
|
15
|
+
"audio/ogg": "audio.ogg",
|
|
16
|
+
"audio/mpeg": "audio.mp3",
|
|
17
|
+
"audio/mp4": "audio.m4a",
|
|
18
|
+
"audio/wav": "audio.wav",
|
|
19
|
+
"audio/webm": "audio.webm",
|
|
20
|
+
"audio/flac": "audio.flac",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface TranscribeOpts {
|
|
24
|
+
apiKey: string;
|
|
25
|
+
data: Buffer;
|
|
26
|
+
mime: string;
|
|
27
|
+
language?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function transcribeAudio(opts: TranscribeOpts): Promise<string> {
|
|
31
|
+
const filename = MIME_TO_FILENAME[opts.mime] ?? "audio.ogg";
|
|
32
|
+
const form = new FormData();
|
|
33
|
+
form.set("file", new Blob([new Uint8Array(opts.data)], { type: opts.mime }), filename);
|
|
34
|
+
form.set("model", MODEL);
|
|
35
|
+
if (opts.language) form.set("language", opts.language);
|
|
36
|
+
form.set("response_format", "json");
|
|
37
|
+
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
40
|
+
try {
|
|
41
|
+
const resp = await fetch(ENDPOINT, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` },
|
|
44
|
+
body: form,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
const text = await resp.text().catch(() => "");
|
|
49
|
+
throw new Error(`OpenAI transcribe failed: ${resp.status} ${text}`);
|
|
50
|
+
}
|
|
51
|
+
const json = (await resp.json()) as { text?: string };
|
|
52
|
+
const text = (json.text || "").trim();
|
|
53
|
+
log.info({ chars: text.length, mime: opts.mime }, "twilio: voice note transcribed");
|
|
54
|
+
return text;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp channel via Twilio (Sandbox by default).
|
|
3
|
+
*
|
|
4
|
+
* Reuses the shared TwilioWebhookServer. Inbound webhook hits
|
|
5
|
+
* /twilio/whatsapp/incoming; we ack immediately (Twilio's 15s budget)
|
|
6
|
+
* and reply via REST under a per-sender lock.
|
|
7
|
+
*
|
|
8
|
+
* Parity targets with the Telegram channel: text + images + documents +
|
|
9
|
+
* voice notes (transcribed), /reset to start a new room, WhatsApp-flavored
|
|
10
|
+
* markdown for outbound, 4096-char chunking, [error] reporting, delivery
|
|
11
|
+
* status tracking. Outbound media is served from
|
|
12
|
+
* channels/twilio/media-cache via GET /twilio/media/<sha>.<ext>.
|
|
13
|
+
*
|
|
14
|
+
* Sandbox: in Twilio Console → Messaging → Try it out → WhatsApp, point
|
|
15
|
+
* the inbound webhook at `${PUBLIC_BASE_URL}/twilio/whatsapp/incoming`.
|
|
16
|
+
* Users opt in by sending `join <two-words>` to `+1 415 523 8886`. Opt-in
|
|
17
|
+
* expires after 72h of inactivity; the join code stays valid. Outbound
|
|
18
|
+
* is further gated by Meta's 24-hour customer-service window.
|
|
19
|
+
*/
|
|
20
|
+
import { createChatEngine } from "../chat/engine";
|
|
21
|
+
import { getMcpServers } from "../mcp";
|
|
22
|
+
import { Session, Message } from "../db/models";
|
|
23
|
+
import { runMigrations } from "../db/migrate";
|
|
24
|
+
import type { Attachment, Channel, ChatState, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
|
|
25
|
+
import { getConfig } from "../utils/config";
|
|
26
|
+
import { log } from "../utils/log";
|
|
27
|
+
import { classifyMime, prepareImage, validateAttachment } from "../utils/attachment";
|
|
28
|
+
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
29
|
+
import { getTwilioServer } from "./twilio/server";
|
|
30
|
+
import { downloadInboundMedia, extractMedia } from "./twilio/media";
|
|
31
|
+
import { transcribeAudio } from "./twilio/transcribe";
|
|
32
|
+
|
|
33
|
+
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const WA_PREFIX = "whatsapp:";
|
|
35
|
+
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
36
|
+
const CHUNK_LIMIT = 4096;
|
|
37
|
+
const RESET_RE = /^\s*\/(reset|new)\s*$/i;
|
|
38
|
+
const VOICE_MIME_PREFIX = "audio/";
|
|
39
|
+
|
|
40
|
+
class WhatsAppChannel implements Channel {
|
|
41
|
+
name = "whatsapp";
|
|
42
|
+
private readonly twilio: TwilioConfig;
|
|
43
|
+
private readonly whatsapp: WhatsappConfig;
|
|
44
|
+
private readonly phone: PhoneConfig;
|
|
45
|
+
private readonly chats = new Map<string, ChatState>();
|
|
46
|
+
private readonly lastInboundAt = new Map<string, number>();
|
|
47
|
+
|
|
48
|
+
constructor(twilio: TwilioConfig, whatsapp: WhatsappConfig, phone: PhoneConfig) {
|
|
49
|
+
this.twilio = twilio;
|
|
50
|
+
this.whatsapp = whatsapp;
|
|
51
|
+
this.phone = phone;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async start(): Promise<void> {
|
|
55
|
+
await runMigrations();
|
|
56
|
+
|
|
57
|
+
const server = getTwilioServer();
|
|
58
|
+
server.configure({
|
|
59
|
+
port: this.twilio.port,
|
|
60
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
61
|
+
signingToken: this.twilio.auth_token || this.twilio.secret,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server.registerHttp("/twilio/whatsapp/incoming", (_req, ctx) => this.handleInbound(ctx.params), {
|
|
65
|
+
dedupOn: "MessageSid",
|
|
66
|
+
rateLimitOn: "From",
|
|
67
|
+
});
|
|
68
|
+
server.registerHttp("/twilio/whatsapp/status", (_req, ctx) => this.handleStatus(ctx.params), {
|
|
69
|
+
dedupOn: "MessageSid",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (this.twilio.owner_number) {
|
|
73
|
+
server.exemptFromRateLimit(`${WA_PREFIX}${this.twilio.owner_number}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await server.start();
|
|
77
|
+
|
|
78
|
+
log.info(
|
|
79
|
+
{
|
|
80
|
+
from: this.whatsapp.from_number,
|
|
81
|
+
owner: this.twilio.owner_number,
|
|
82
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
83
|
+
},
|
|
84
|
+
"whatsapp channel started",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async stop(): Promise<void> {
|
|
89
|
+
for (const state of this.chats.values()) state.engine.close();
|
|
90
|
+
this.chats.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Outbound text to the owner — used by send_message MCP tool. */
|
|
94
|
+
async sendMessage(text: string): Promise<void> {
|
|
95
|
+
if (!this.twilio.owner_number) throw new Error("whatsapp: owner_number not set");
|
|
96
|
+
await this.sendTextTo(this.twilio.owner_number, text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Outbound media to the owner — used by send_message MCP tool with attachments. */
|
|
100
|
+
async sendMedia(data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
101
|
+
if (!this.twilio.owner_number) throw new Error("whatsapp: owner_number not set");
|
|
102
|
+
await this.sendMediaTo(this.twilio.owner_number, data, mimeType, filename);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Inbound webhook ---
|
|
106
|
+
|
|
107
|
+
private async handleInbound(params: Record<string, string>): Promise<Response> {
|
|
108
|
+
const from = (params.From || "").replace(/^whatsapp:/, "");
|
|
109
|
+
const body = (params.Body || "").trim();
|
|
110
|
+
|
|
111
|
+
if (!this.isAllowed(from)) {
|
|
112
|
+
log.warn({ from }, "whatsapp: rejecting non-allowlisted sender");
|
|
113
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.lastInboundAt.set(from, Date.now());
|
|
117
|
+
|
|
118
|
+
if (RESET_RE.test(body)) {
|
|
119
|
+
// Serialize through the same lock so a /reset chasing an in-flight
|
|
120
|
+
// engine.send() waits its turn instead of yanking the engine away.
|
|
121
|
+
const state = await this.getState(from);
|
|
122
|
+
state.lock = state.lock.then(
|
|
123
|
+
async () => {
|
|
124
|
+
const newState = await this.restartChat(from);
|
|
125
|
+
await this.sendTextTo(
|
|
126
|
+
from,
|
|
127
|
+
`New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`,
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
(err) => log.error({ err, from }, "whatsapp: /reset lock chain error"),
|
|
131
|
+
);
|
|
132
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const descriptors = extractMedia(params);
|
|
136
|
+
|
|
137
|
+
const state = await this.getState(from);
|
|
138
|
+
state.lock = state.lock.then(
|
|
139
|
+
async () => {
|
|
140
|
+
let userText = body;
|
|
141
|
+
let attachments: Attachment[] | undefined;
|
|
142
|
+
|
|
143
|
+
if (descriptors.length > 0) {
|
|
144
|
+
const downloaded = await downloadInboundMedia(descriptors, {
|
|
145
|
+
accountSid: this.twilio.sid!,
|
|
146
|
+
authSid: this.twilio.sid!,
|
|
147
|
+
authSecret: this.twilio.secret!,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const voiceParts: string[] = [];
|
|
151
|
+
const built: Attachment[] = [];
|
|
152
|
+
|
|
153
|
+
for (const item of downloaded) {
|
|
154
|
+
if (item.mime.startsWith(VOICE_MIME_PREFIX)) {
|
|
155
|
+
if (!this.phone.openai_api_key) {
|
|
156
|
+
voiceParts.push("[voice note: transcription unavailable — channels.phone.openai_api_key not set]");
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const transcript = await transcribeAudio({
|
|
161
|
+
apiKey: this.phone.openai_api_key,
|
|
162
|
+
data: item.data,
|
|
163
|
+
mime: item.mime,
|
|
164
|
+
});
|
|
165
|
+
voiceParts.push(transcript || "[empty voice note]");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log.error({ err, from }, "whatsapp: voice transcription failed");
|
|
168
|
+
voiceParts.push(
|
|
169
|
+
`[voice note: transcription failed — ${err instanceof Error ? err.message : String(err)}]`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const error = validateAttachment(item.data, item.mime);
|
|
176
|
+
if (error) {
|
|
177
|
+
log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
|
|
178
|
+
await this.sendTextTo(from, `[error] ${error}`).catch(() => {});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const attType = classifyMime(item.mime) || "file";
|
|
183
|
+
let data = item.data;
|
|
184
|
+
let mime = item.mime;
|
|
185
|
+
if (attType === "image") {
|
|
186
|
+
const prepared = await prepareImage(data, mime);
|
|
187
|
+
data = prepared.data;
|
|
188
|
+
mime = prepared.mimeType;
|
|
189
|
+
}
|
|
190
|
+
built.push({ type: attType, data, mimeType: mime });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (voiceParts.length > 0) {
|
|
194
|
+
const joined = voiceParts.join("\n\n");
|
|
195
|
+
userText = userText ? `${userText}\n\n${joined}` : joined;
|
|
196
|
+
}
|
|
197
|
+
if (built.length > 0) attachments = built;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!userText && !attachments) {
|
|
201
|
+
log.debug({ from }, "whatsapp: empty inbound (no body, no usable media)");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
|
|
207
|
+
const reply = result.trim() || "(no response)";
|
|
208
|
+
try {
|
|
209
|
+
await this.sendTextTo(from, reply);
|
|
210
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
211
|
+
} catch (sendErr) {
|
|
212
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
213
|
+
throw sendErr;
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
log.error({ err, from }, "whatsapp: engine error");
|
|
217
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
218
|
+
await this.sendTextTo(from, `[error] ${errText}`).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
(err) => log.error({ err, from }, "whatsapp: lock chain error"),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
228
|
+
log.info(
|
|
229
|
+
{
|
|
230
|
+
messageSid: params.MessageSid,
|
|
231
|
+
status: params.MessageStatus,
|
|
232
|
+
errorCode: params.ErrorCode,
|
|
233
|
+
to: params.To,
|
|
234
|
+
},
|
|
235
|
+
"whatsapp: delivery status",
|
|
236
|
+
);
|
|
237
|
+
return new Response("", { status: 204 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Outbound ---
|
|
241
|
+
|
|
242
|
+
private async sendTextTo(remoteE164: string, body: string): Promise<void> {
|
|
243
|
+
if (!this.canSend(remoteE164)) return;
|
|
244
|
+
const converted = toWhatsAppMarkdown(body);
|
|
245
|
+
const chunks = chunkText(converted, CHUNK_LIMIT);
|
|
246
|
+
for (const chunk of chunks) {
|
|
247
|
+
await this.postMessage(remoteE164, chunk, undefined);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async sendMediaTo(remoteE164: string, data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
252
|
+
if (!this.canSend(remoteE164)) return;
|
|
253
|
+
const ext = filename ? extOf(filename) : undefined;
|
|
254
|
+
let mediaUrl: string;
|
|
255
|
+
try {
|
|
256
|
+
mediaUrl = await getTwilioServer().serveMedia(new Uint8Array(data), mimeType, ext);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log.error({ err }, "whatsapp: serveMedia failed");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
await this.postMessage(remoteE164, "", [mediaUrl]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async postMessage(remoteE164: string, body: string, mediaUrl: string[] | undefined): Promise<void> {
|
|
265
|
+
try {
|
|
266
|
+
const res = await twilioSendMessage({
|
|
267
|
+
accountSid: this.twilio.sid!,
|
|
268
|
+
authSid: this.twilio.sid!,
|
|
269
|
+
authSecret: this.twilio.secret!,
|
|
270
|
+
to: `${WA_PREFIX}${remoteE164}`,
|
|
271
|
+
from: `${WA_PREFIX}${this.whatsapp.from_number}`,
|
|
272
|
+
body,
|
|
273
|
+
mediaUrl,
|
|
274
|
+
statusCallbackUrl: this.twilio.public_base_url
|
|
275
|
+
? `${this.twilio.public_base_url}/twilio/whatsapp/status`
|
|
276
|
+
: undefined,
|
|
277
|
+
});
|
|
278
|
+
log.info({ to: remoteE164, sid: res.messageSid, status: res.status, hasMedia: !!mediaUrl }, "whatsapp: sent");
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.error({ err, to: remoteE164 }, "whatsapp: send failed");
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Returns true if we have credentials AND we're inside the 24h window. */
|
|
286
|
+
private canSend(remoteE164: string): boolean {
|
|
287
|
+
if (!this.twilio.sid || !this.twilio.secret) {
|
|
288
|
+
log.warn("whatsapp: twilio sid/secret missing, cannot send");
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (!this.whatsapp.from_number) {
|
|
292
|
+
log.warn("whatsapp: from_number not configured");
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const lastIn = this.lastInboundAt.get(remoteE164);
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
if (!lastIn || now - lastIn > TWENTY_FOUR_HOURS_MS) {
|
|
298
|
+
log.warn(
|
|
299
|
+
{
|
|
300
|
+
remoteE164,
|
|
301
|
+
lastInboundAt: lastIn ? new Date(lastIn).toISOString() : null,
|
|
302
|
+
},
|
|
303
|
+
"whatsapp: outside 24h customer-service window — drop (Twilio rejects free-form; approved template needed)",
|
|
304
|
+
);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Helpers ---
|
|
311
|
+
|
|
312
|
+
private isAllowed(remoteE164: string): boolean {
|
|
313
|
+
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
314
|
+
return this.twilio.allowlist.includes(remoteE164);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private roomPrefix(remoteE164: string): string {
|
|
318
|
+
return `wa-${remoteE164}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async getState(remoteE164: string): Promise<ChatState> {
|
|
322
|
+
let state = this.chats.get(remoteE164);
|
|
323
|
+
if (state) return state;
|
|
324
|
+
const prefix = this.roomPrefix(remoteE164);
|
|
325
|
+
const idx = await Session.getLatestRoomIndex(prefix);
|
|
326
|
+
const room = `${prefix}-${idx}`;
|
|
327
|
+
log.info({ remoteE164, room }, "whatsapp: creating chat engine");
|
|
328
|
+
const engine = await createChatEngine({
|
|
329
|
+
room,
|
|
330
|
+
channel: "whatsapp",
|
|
331
|
+
resume: true,
|
|
332
|
+
mcpServers: getMcpServers(),
|
|
333
|
+
});
|
|
334
|
+
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
335
|
+
this.chats.set(remoteE164, state);
|
|
336
|
+
return state;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async restartChat(remoteE164: string): Promise<ChatState> {
|
|
340
|
+
const old = this.chats.get(remoteE164);
|
|
341
|
+
if (old) old.engine.close();
|
|
342
|
+
|
|
343
|
+
const prefix = this.roomPrefix(remoteE164);
|
|
344
|
+
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
345
|
+
const newIdx = prevIdx + 1;
|
|
346
|
+
const room = `${prefix}-${newIdx}`;
|
|
347
|
+
|
|
348
|
+
// Persist a placeholder session so the room index survives daemon
|
|
349
|
+
// restarts (otherwise getState falls back to the old room).
|
|
350
|
+
await Session.create(`placeholder-${room}`, room);
|
|
351
|
+
|
|
352
|
+
const engine = await createChatEngine({
|
|
353
|
+
room,
|
|
354
|
+
channel: "whatsapp",
|
|
355
|
+
resume: false,
|
|
356
|
+
mcpServers: getMcpServers(),
|
|
357
|
+
});
|
|
358
|
+
const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
|
|
359
|
+
this.chats.set(remoteE164, state);
|
|
360
|
+
log.info({ remoteE164, room }, "whatsapp: new conversation started");
|
|
361
|
+
return state;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Translate the slice of Markdown the agent uses to WhatsApp's flavor.
|
|
367
|
+
* WhatsApp's renderer accepts `*bold*`, `_italic_`, `~strike~`, and
|
|
368
|
+
* triple-backtick code blocks. We only rewrite forms that would render
|
|
369
|
+
* as literal punctuation otherwise (`**bold**`, `~~strike~~`); single
|
|
370
|
+
* `*italic*` is left alone since detecting it without false positives
|
|
371
|
+
* around bold is more trouble than it's worth.
|
|
372
|
+
*/
|
|
373
|
+
export function toWhatsAppMarkdown(text: string): string {
|
|
374
|
+
return text.replace(/\*\*(.+?)\*\*/gs, "*$1*").replace(/~~(.+?)~~/gs, "~$1~");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Split text into chunks bounded by `limit` chars, preferring paragraph then line breaks. */
|
|
378
|
+
export function chunkText(text: string, limit: number): string[] {
|
|
379
|
+
if (text.length <= limit) return [text];
|
|
380
|
+
const chunks: string[] = [];
|
|
381
|
+
let remaining = text;
|
|
382
|
+
while (remaining.length > limit) {
|
|
383
|
+
let cut = remaining.lastIndexOf("\n\n", limit);
|
|
384
|
+
if (cut < limit / 2) cut = remaining.lastIndexOf("\n", limit);
|
|
385
|
+
if (cut < limit / 2) cut = remaining.lastIndexOf(" ", limit);
|
|
386
|
+
if (cut <= 0) cut = limit;
|
|
387
|
+
chunks.push(remaining.slice(0, cut).trimEnd());
|
|
388
|
+
remaining = remaining.slice(cut).trimStart();
|
|
389
|
+
}
|
|
390
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
391
|
+
return chunks;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function extOf(filename: string): string | undefined {
|
|
395
|
+
const dot = filename.lastIndexOf(".");
|
|
396
|
+
if (dot < 0 || dot === filename.length - 1) return undefined;
|
|
397
|
+
return filename.slice(dot + 1).toLowerCase();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function createWhatsAppChannel(): WhatsAppChannel | null {
|
|
401
|
+
const { twilio, whatsapp, phone } = getConfig().channels;
|
|
402
|
+
if (!whatsapp.enabled) return null;
|
|
403
|
+
if (!twilio.sid || !twilio.secret) return null;
|
|
404
|
+
if (!whatsapp.from_number) return null;
|
|
405
|
+
return new WhatsAppChannel(twilio, whatsapp, phone);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export type { WhatsAppChannel };
|
package/src/cli/phone.ts
CHANGED
|
@@ -50,15 +50,15 @@ async function phoneCallCommand(): Promise<void> {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
await channel!.start();
|
|
53
|
-
const
|
|
54
|
-
console.log(`${ICON_PASS} phone server up on :${
|
|
55
|
-
if (!
|
|
53
|
+
const { twilio, phone } = getConfig().channels;
|
|
54
|
+
console.log(`${ICON_PASS} phone server up on :${twilio.port}`);
|
|
55
|
+
if (!twilio.public_base_url) {
|
|
56
56
|
console.log(`${ICON_WARN} public_base_url not set — Twilio cannot reach this server.`);
|
|
57
|
-
console.log(` Start cloudflared (or your tunnel) and set channels.
|
|
57
|
+
console.log(` Start cloudflared (or your tunnel) and set channels.twilio.public_base_url in config.yaml.`);
|
|
58
58
|
await channel!.stop();
|
|
59
59
|
process.exit(1);
|
|
60
60
|
}
|
|
61
|
-
if (!
|
|
61
|
+
if (!phone.openai_api_key) {
|
|
62
62
|
console.log(`${ICON_WARN} openai_api_key not set — realtime voice loop will fall back to TwiML <Say>.`);
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -91,17 +91,23 @@ async function phoneCallCommand(): Promise<void> {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function phoneStatusCommand(): void {
|
|
94
|
-
const
|
|
94
|
+
const { twilio, phone, sms, whatsapp } = getConfig().channels;
|
|
95
95
|
const lines = [
|
|
96
|
-
`
|
|
97
|
-
`
|
|
98
|
-
`
|
|
99
|
-
`
|
|
100
|
-
`
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
`
|
|
104
|
-
`
|
|
96
|
+
`phone enabled: ${phone.enabled}`,
|
|
97
|
+
`phone from: ${phone.from_number ?? "(not set)"}`,
|
|
98
|
+
`sms enabled: ${sms.enabled}`,
|
|
99
|
+
`sms from: ${sms.from_number ?? `(defaults to phone: ${phone.from_number ?? "unset"})`}`,
|
|
100
|
+
`whatsapp enabled: ${whatsapp.enabled}`,
|
|
101
|
+
`whatsapp from: ${whatsapp.from_number ?? "(not set)"}`,
|
|
102
|
+
`owner: ${twilio.owner_number ?? "(not set)"}`,
|
|
103
|
+
`allowlist: ${twilio.allowlist.length ? twilio.allowlist.join(", ") : "(empty)"}`,
|
|
104
|
+
`port: ${twilio.port}`,
|
|
105
|
+
`public_base_url: ${twilio.public_base_url ?? "(not set)"}`,
|
|
106
|
+
`realtime_model: ${phone.realtime_model}`,
|
|
107
|
+
`voice: ${phone.voice}`,
|
|
108
|
+
`twilio creds: ${twilio.sid && twilio.secret ? "configured" : "MISSING"}`,
|
|
109
|
+
`twilio auth_token:${twilio.auth_token ? "configured" : "(falling back to secret)"}`,
|
|
110
|
+
`openai key: ${phone.openai_api_key ? "configured" : "MISSING"}`,
|
|
105
111
|
];
|
|
106
112
|
console.log(lines.join("\n"));
|
|
107
113
|
}
|
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
|
@@ -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 {
|
|
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";
|