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
|
@@ -0,0 +1,376 @@
|
|
|
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 { getMcpServers } from "../mcp";
|
|
21
|
+
import { Message } from "../db/models";
|
|
22
|
+
import { runMigrations } from "../db/migrate";
|
|
23
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
24
|
+
import type { Attachment, Channel, ChatState, Outbound, 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" as const;
|
|
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 to the owner — used by send_message MCP tool. WhatsApp has no threading. */
|
|
94
|
+
async deliver(out: Outbound): Promise<void> {
|
|
95
|
+
if (!this.twilio.owner_number) throw new Error("whatsapp: owner_number not set");
|
|
96
|
+
const to = this.twilio.owner_number;
|
|
97
|
+
if (out.media) {
|
|
98
|
+
await this.sendMediaTo(to, Buffer.from(out.media.data), out.media.mimeType, out.media.filename);
|
|
99
|
+
}
|
|
100
|
+
if (out.text) {
|
|
101
|
+
await this.sendTextTo(to, out.text);
|
|
102
|
+
}
|
|
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
|
+
chainLock(state, async () => {
|
|
123
|
+
const newState = await this.restartChat(from);
|
|
124
|
+
await this.sendTextTo(from, `New conversation started (room ${this.roomPrefix(from)}-${newState.roomIndex}).`);
|
|
125
|
+
});
|
|
126
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const descriptors = extractMedia(params);
|
|
130
|
+
|
|
131
|
+
const state = await this.getState(from);
|
|
132
|
+
chainLock(state, async () => {
|
|
133
|
+
let userText = body;
|
|
134
|
+
let attachments: Attachment[] | undefined;
|
|
135
|
+
|
|
136
|
+
if (descriptors.length > 0) {
|
|
137
|
+
const downloaded = await downloadInboundMedia(descriptors, {
|
|
138
|
+
accountSid: this.twilio.sid!,
|
|
139
|
+
authSid: this.twilio.sid!,
|
|
140
|
+
authSecret: this.twilio.secret!,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const voiceParts: string[] = [];
|
|
144
|
+
const built: Attachment[] = [];
|
|
145
|
+
|
|
146
|
+
for (const item of downloaded) {
|
|
147
|
+
if (item.mime.startsWith(VOICE_MIME_PREFIX)) {
|
|
148
|
+
if (!this.phone.openai_api_key) {
|
|
149
|
+
voiceParts.push("[voice note: transcription unavailable — channels.phone.openai_api_key not set]");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const transcript = await transcribeAudio({
|
|
154
|
+
apiKey: this.phone.openai_api_key,
|
|
155
|
+
data: item.data,
|
|
156
|
+
mime: item.mime,
|
|
157
|
+
});
|
|
158
|
+
voiceParts.push(transcript || "[empty voice note]");
|
|
159
|
+
} catch (err) {
|
|
160
|
+
log.error({ err, from }, "whatsapp: voice transcription failed");
|
|
161
|
+
voiceParts.push(
|
|
162
|
+
`[voice note: transcription failed — ${err instanceof Error ? err.message : String(err)}]`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const error = validateAttachment(item.data);
|
|
169
|
+
if (error) {
|
|
170
|
+
log.warn({ from, mime: item.mime, error }, "whatsapp: rejecting attachment");
|
|
171
|
+
await this.sendTextTo(from, `[error] ${error}`).catch(() => {});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const attType = classifyMime(item.mime) || "file";
|
|
176
|
+
let data = item.data;
|
|
177
|
+
let mime = item.mime;
|
|
178
|
+
if (attType === "image") {
|
|
179
|
+
const prepared = await prepareImage(data, mime);
|
|
180
|
+
data = prepared.data;
|
|
181
|
+
mime = prepared.mimeType;
|
|
182
|
+
}
|
|
183
|
+
built.push({ type: attType, data, mimeType: mime });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (voiceParts.length > 0) {
|
|
187
|
+
const joined = voiceParts.join("\n\n");
|
|
188
|
+
userText = userText ? `${userText}\n\n${joined}` : joined;
|
|
189
|
+
}
|
|
190
|
+
if (built.length > 0) attachments = built;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!userText && !attachments) {
|
|
194
|
+
log.debug({ from }, "whatsapp: empty inbound (no body, no usable media)");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
|
|
200
|
+
const reply = result.trim() || "(no response)";
|
|
201
|
+
try {
|
|
202
|
+
await this.sendTextTo(from, reply);
|
|
203
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
204
|
+
} catch (sendErr) {
|
|
205
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
206
|
+
throw sendErr;
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.error({ err, from }, "whatsapp: engine error");
|
|
210
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
211
|
+
await this.sendTextTo(from, `[error] ${errText}`).catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
219
|
+
log.info(
|
|
220
|
+
{
|
|
221
|
+
messageSid: params.MessageSid,
|
|
222
|
+
status: params.MessageStatus,
|
|
223
|
+
errorCode: params.ErrorCode,
|
|
224
|
+
to: params.To,
|
|
225
|
+
},
|
|
226
|
+
"whatsapp: delivery status",
|
|
227
|
+
);
|
|
228
|
+
return new Response("", { status: 204 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Outbound ---
|
|
232
|
+
|
|
233
|
+
private async sendTextTo(remoteE164: string, body: string): Promise<void> {
|
|
234
|
+
if (!this.canSend(remoteE164)) return;
|
|
235
|
+
const converted = toWhatsAppMarkdown(body);
|
|
236
|
+
const chunks = chunkText(converted, CHUNK_LIMIT);
|
|
237
|
+
for (const chunk of chunks) {
|
|
238
|
+
await this.postMessage(remoteE164, chunk, undefined);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async sendMediaTo(remoteE164: string, data: Buffer, mimeType: string, filename?: string): Promise<void> {
|
|
243
|
+
if (!this.canSend(remoteE164)) return;
|
|
244
|
+
const ext = filename ? extOf(filename) : undefined;
|
|
245
|
+
let mediaUrl: string;
|
|
246
|
+
try {
|
|
247
|
+
mediaUrl = await getTwilioServer().serveMedia(new Uint8Array(data), mimeType, ext);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
log.error({ err }, "whatsapp: serveMedia failed");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await this.postMessage(remoteE164, "", [mediaUrl]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async postMessage(remoteE164: string, body: string, mediaUrl: string[] | undefined): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
const res = await twilioSendMessage({
|
|
258
|
+
accountSid: this.twilio.sid!,
|
|
259
|
+
authSid: this.twilio.sid!,
|
|
260
|
+
authSecret: this.twilio.secret!,
|
|
261
|
+
to: `${WA_PREFIX}${remoteE164}`,
|
|
262
|
+
from: `${WA_PREFIX}${this.whatsapp.from_number}`,
|
|
263
|
+
body,
|
|
264
|
+
mediaUrl,
|
|
265
|
+
statusCallbackUrl: this.twilio.public_base_url
|
|
266
|
+
? `${this.twilio.public_base_url}/twilio/whatsapp/status`
|
|
267
|
+
: undefined,
|
|
268
|
+
});
|
|
269
|
+
log.info({ to: remoteE164, sid: res.messageSid, status: res.status, hasMedia: !!mediaUrl }, "whatsapp: sent");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
log.error({ err, to: remoteE164 }, "whatsapp: send failed");
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Returns true if we have credentials AND we're inside the 24h window. */
|
|
277
|
+
private canSend(remoteE164: string): boolean {
|
|
278
|
+
if (!this.twilio.sid || !this.twilio.secret) {
|
|
279
|
+
log.warn("whatsapp: twilio sid/secret missing, cannot send");
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
if (!this.whatsapp.from_number) {
|
|
283
|
+
log.warn("whatsapp: from_number not configured");
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
const lastIn = this.lastInboundAt.get(remoteE164);
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
if (!lastIn || now - lastIn > TWENTY_FOUR_HOURS_MS) {
|
|
289
|
+
log.warn(
|
|
290
|
+
{
|
|
291
|
+
remoteE164,
|
|
292
|
+
lastInboundAt: lastIn ? new Date(lastIn).toISOString() : null,
|
|
293
|
+
},
|
|
294
|
+
"whatsapp: outside 24h customer-service window — drop (Twilio rejects free-form; approved template needed)",
|
|
295
|
+
);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- Helpers ---
|
|
302
|
+
|
|
303
|
+
private isAllowed(remoteE164: string): boolean {
|
|
304
|
+
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
305
|
+
return this.twilio.allowlist.includes(remoteE164);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private roomPrefix(remoteE164: string): string {
|
|
309
|
+
return `wa-${remoteE164}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async getState(remoteE164: string): Promise<ChatState> {
|
|
313
|
+
let state = this.chats.get(remoteE164);
|
|
314
|
+
if (state) return state;
|
|
315
|
+
state = await openChatEngine(this.roomPrefix(remoteE164), () => ({
|
|
316
|
+
channel: "whatsapp",
|
|
317
|
+
mcpServers: getMcpServers(),
|
|
318
|
+
}));
|
|
319
|
+
this.chats.set(remoteE164, state);
|
|
320
|
+
return state;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async restartChat(remoteE164: string): Promise<ChatState> {
|
|
324
|
+
const state = await rotateRoom(this.roomPrefix(remoteE164), this.chats.get(remoteE164), () => ({
|
|
325
|
+
channel: "whatsapp",
|
|
326
|
+
mcpServers: getMcpServers(),
|
|
327
|
+
}));
|
|
328
|
+
this.chats.set(remoteE164, state);
|
|
329
|
+
return state;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Translate the slice of Markdown the agent uses to WhatsApp's flavor.
|
|
335
|
+
* WhatsApp's renderer accepts `*bold*`, `_italic_`, `~strike~`, and
|
|
336
|
+
* triple-backtick code blocks. We only rewrite forms that would render
|
|
337
|
+
* as literal punctuation otherwise (`**bold**`, `~~strike~~`); single
|
|
338
|
+
* `*italic*` is left alone since detecting it without false positives
|
|
339
|
+
* around bold is more trouble than it's worth.
|
|
340
|
+
*/
|
|
341
|
+
export function toWhatsAppMarkdown(text: string): string {
|
|
342
|
+
return text.replace(/\*\*(.+?)\*\*/gs, "*$1*").replace(/~~(.+?)~~/gs, "~$1~");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Split text into chunks bounded by `limit` chars, preferring paragraph then line breaks. */
|
|
346
|
+
export function chunkText(text: string, limit: number): string[] {
|
|
347
|
+
if (text.length <= limit) return [text];
|
|
348
|
+
const chunks: string[] = [];
|
|
349
|
+
let remaining = text;
|
|
350
|
+
while (remaining.length > limit) {
|
|
351
|
+
let cut = remaining.lastIndexOf("\n\n", limit);
|
|
352
|
+
if (cut < limit / 2) cut = remaining.lastIndexOf("\n", limit);
|
|
353
|
+
if (cut < limit / 2) cut = remaining.lastIndexOf(" ", limit);
|
|
354
|
+
if (cut <= 0) cut = limit;
|
|
355
|
+
chunks.push(remaining.slice(0, cut).trimEnd());
|
|
356
|
+
remaining = remaining.slice(cut).trimStart();
|
|
357
|
+
}
|
|
358
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
359
|
+
return chunks;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extOf(filename: string): string | undefined {
|
|
363
|
+
const dot = filename.lastIndexOf(".");
|
|
364
|
+
if (dot < 0 || dot === filename.length - 1) return undefined;
|
|
365
|
+
return filename.slice(dot + 1).toLowerCase();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function createWhatsAppChannel(): WhatsAppChannel | null {
|
|
369
|
+
const { twilio, whatsapp, phone } = getConfig().channels;
|
|
370
|
+
if (!whatsapp.enabled) return null;
|
|
371
|
+
if (!twilio.sid || !twilio.secret) return null;
|
|
372
|
+
if (!whatsapp.from_number) return null;
|
|
373
|
+
return new WhatsAppChannel(twilio, whatsapp, phone);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export type { WhatsAppChannel };
|
package/src/chat/identity.ts
CHANGED
|
@@ -8,8 +8,7 @@ import { getEmployeesSummary } from "../core/employees";
|
|
|
8
8
|
import { Session } from "../db/models";
|
|
9
9
|
import type { Mode } from "../types";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
export { scanSkills as loadSkills, getSkillNames as loadSkillNames, type SkillInfo } from "../core/skills";
|
|
11
|
+
export { type SkillInfo } from "../core/skills";
|
|
13
12
|
|
|
14
13
|
function loadFile(dir: string, name: string): string {
|
|
15
14
|
const filePath = join(dir, name);
|
package/src/cli/phone.ts
CHANGED
|
@@ -45,20 +45,20 @@ async function phoneCallCommand(): Promise<void> {
|
|
|
45
45
|
const channel = createPhoneChannel();
|
|
46
46
|
if (!channel) {
|
|
47
47
|
fail(
|
|
48
|
-
"Phone channel not configured. Set channels.
|
|
48
|
+
"Phone channel not configured. Set channels.twilio.{sid,secret} and channels.phone.from_number in ~/.niahere/config.yaml (also channels.phone.openai_api_key and channels.twilio.public_base_url for the realtime voice loop). Env vars TWILIO_SID / TWILIO_SECRET / PHONE_FROM_NUMBER / OPENAI_API_KEY / PUBLIC_BASE_URL override if you prefer .env.",
|
|
49
49
|
);
|
|
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
|
}
|
|
@@ -119,11 +125,14 @@ function helpText(): string {
|
|
|
119
125
|
" phone server, dials, waits, prints transcript.",
|
|
120
126
|
" status Show phone channel configuration.",
|
|
121
127
|
"",
|
|
122
|
-
"Config lives in ~/.niahere/config.yaml
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
128
|
+
"Config lives in ~/.niahere/config.yaml:",
|
|
129
|
+
" channels.twilio.{sid, secret} (required)",
|
|
130
|
+
" channels.phone.from_number (required)",
|
|
131
|
+
" channels.phone.openai_api_key, channels.twilio.public_base_url",
|
|
132
|
+
" (required for realtime voice loop)",
|
|
133
|
+
" channels.twilio.auth_token (required if sid is an API Key SID)",
|
|
134
|
+
" channels.twilio.{owner_number, allowlist, port} (optional)",
|
|
135
|
+
" channels.phone.{voice, realtime_model} (optional)",
|
|
127
136
|
"",
|
|
128
137
|
"Each field can be overridden by the matching env var (TWILIO_SID, OPENAI_API_KEY, etc.)",
|
|
129
138
|
"if you prefer .env. See the nia-phone skill for full deploy walkthrough.",
|
package/src/commands/init.ts
CHANGED
|
@@ -215,14 +215,18 @@ export async function runInit(): Promise<void> {
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
// Phone (Twilio Voice + OpenAI Realtime)
|
|
218
|
+
// Phone (Twilio Voice + OpenAI Realtime). Shared Twilio creds (sid,
|
|
219
|
+
// secret, auth_token, owner_number, public_base_url) live under
|
|
220
|
+
// channels.twilio so SMS and WhatsApp can reuse them; voice-specific
|
|
221
|
+
// fields stay under channels.phone.
|
|
222
|
+
const exTw = (exCh.twilio || {}) as Record<string, unknown>;
|
|
219
223
|
const exPh = (exCh.phone || {}) as Record<string, unknown>;
|
|
220
|
-
let phoneTwilioSid = (
|
|
221
|
-
let phoneTwilioSecret = (
|
|
222
|
-
let phoneTwilioAuthToken = (
|
|
224
|
+
let phoneTwilioSid = (exTw.sid as string) || "";
|
|
225
|
+
let phoneTwilioSecret = (exTw.secret as string) || "";
|
|
226
|
+
let phoneTwilioAuthToken = (exTw.auth_token as string) || "";
|
|
223
227
|
let phoneFromNumber = (exPh.from_number as string) || "";
|
|
224
|
-
let phoneOwnerNumber = (
|
|
225
|
-
let phonePublicBaseUrl = (
|
|
228
|
+
let phoneOwnerNumber = (exTw.owner_number as string) || "";
|
|
229
|
+
let phonePublicBaseUrl = (exTw.public_base_url as string) || "";
|
|
226
230
|
let phoneOpenAiKey = (exPh.openai_api_key as string) || "";
|
|
227
231
|
let phoneVoice = (exPh.voice as string) || "";
|
|
228
232
|
|
|
@@ -485,14 +489,13 @@ export async function runInit(): Promise<void> {
|
|
|
485
489
|
channels.default = "slack";
|
|
486
490
|
}
|
|
487
491
|
if (phoneTwilioSid && phoneTwilioSecret && phoneFromNumber) {
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (phonePublicBaseUrl) ph.public_base_url = phonePublicBaseUrl.replace(/\/$/, "");
|
|
492
|
+
const tw: Record<string, unknown> = { sid: phoneTwilioSid, secret: phoneTwilioSecret };
|
|
493
|
+
if (phoneTwilioAuthToken) tw.auth_token = phoneTwilioAuthToken;
|
|
494
|
+
if (phoneOwnerNumber) tw.owner_number = phoneOwnerNumber;
|
|
495
|
+
if (phonePublicBaseUrl) tw.public_base_url = phonePublicBaseUrl.replace(/\/$/, "");
|
|
496
|
+
channels.twilio = tw;
|
|
497
|
+
|
|
498
|
+
const ph: Record<string, unknown> = { from_number: phoneFromNumber };
|
|
496
499
|
if (phoneOpenAiKey) ph.openai_api_key = phoneOpenAiKey;
|
|
497
500
|
if (phoneVoice && phoneVoice !== "marin") ph.voice = phoneVoice;
|
|
498
501
|
channels.phone = ph;
|
package/src/commands/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { getPaths } from "../utils/paths";
|
|
5
5
|
import { findDaemonPids } from "../core/daemon";
|
|
@@ -29,6 +29,9 @@ function plistPath(): string {
|
|
|
29
29
|
function buildPlist(): string {
|
|
30
30
|
const paths = getPaths();
|
|
31
31
|
const [execPath, cliPath] = getExecCommand();
|
|
32
|
+
// Bun auto-loads .env from cwd. Without WorkingDirectory, launchd
|
|
33
|
+
// spawns the daemon with cwd=/ and any credentials in .env never load.
|
|
34
|
+
const workingDir = resolve(cliPath, "../../..");
|
|
32
35
|
|
|
33
36
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
37
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -42,6 +45,8 @@ function buildPlist(): string {
|
|
|
42
45
|
<string>${cliPath}</string>
|
|
43
46
|
<string>run</string>
|
|
44
47
|
</array>
|
|
48
|
+
<key>WorkingDirectory</key>
|
|
49
|
+
<string>${workingDir}</string>
|
|
45
50
|
<key>RunAtLoad</key>
|
|
46
51
|
<true/>
|
|
47
52
|
<key>KeepAlive</key>
|
|
@@ -108,6 +113,7 @@ function unitPath(): string {
|
|
|
108
113
|
function buildUnit(): string {
|
|
109
114
|
const paths = getPaths();
|
|
110
115
|
const [execPath, cliPath] = getExecCommand();
|
|
116
|
+
const workingDir = resolve(cliPath, "../../..");
|
|
111
117
|
|
|
112
118
|
return `[Unit]
|
|
113
119
|
Description=nia personal AI assistant
|
|
@@ -115,6 +121,7 @@ After=network.target
|
|
|
115
121
|
|
|
116
122
|
[Service]
|
|
117
123
|
ExecStart=${execPath} ${cliPath} run
|
|
124
|
+
WorkingDirectory=${workingDir}
|
|
118
125
|
Restart=always
|
|
119
126
|
RestartSec=5
|
|
120
127
|
StandardOutput=append:${paths.daemonLog}
|
|
@@ -137,7 +144,10 @@ async function installSystemd(): Promise<void> {
|
|
|
137
144
|
const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
|
|
138
145
|
await reload.exited;
|
|
139
146
|
|
|
140
|
-
const enable = Bun.spawn(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], {
|
|
147
|
+
const enable = Bun.spawn(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], {
|
|
148
|
+
stdout: "pipe",
|
|
149
|
+
stderr: "pipe",
|
|
150
|
+
});
|
|
141
151
|
const exitCode = await enable.exited;
|
|
142
152
|
|
|
143
153
|
if (exitCode !== 0) {
|
|
@@ -150,10 +160,17 @@ async function uninstallSystemd(): Promise<void> {
|
|
|
150
160
|
const path = unitPath();
|
|
151
161
|
if (!existsSync(path)) return;
|
|
152
162
|
|
|
153
|
-
const disable = Bun.spawn(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], {
|
|
163
|
+
const disable = Bun.spawn(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT], {
|
|
164
|
+
stdout: "pipe",
|
|
165
|
+
stderr: "pipe",
|
|
166
|
+
});
|
|
154
167
|
await disable.exited;
|
|
155
168
|
|
|
156
|
-
try {
|
|
169
|
+
try {
|
|
170
|
+
unlinkSync(path);
|
|
171
|
+
} catch {
|
|
172
|
+
/* already gone */
|
|
173
|
+
}
|
|
157
174
|
|
|
158
175
|
const reload = Bun.spawn(["systemctl", "--user", "daemon-reload"], { stdout: "pipe", stderr: "pipe" });
|
|
159
176
|
await reload.exited;
|
|
@@ -197,7 +214,7 @@ export async function restartService(opts: { force?: boolean } = {}): Promise<vo
|
|
|
197
214
|
const path = plistPath();
|
|
198
215
|
// Unload — sends SIGTERM and disables KeepAlive respawn
|
|
199
216
|
const unload = Bun.spawn(["launchctl", "unload", path], { stdout: "pipe", stderr: "pipe" });
|
|
200
|
-
if (await unload.exited !== 0) {
|
|
217
|
+
if ((await unload.exited) !== 0) {
|
|
201
218
|
const stderr = await new Response(unload.stderr).text();
|
|
202
219
|
console.error(` warning: launchctl unload failed: ${stderr.trim()}`);
|
|
203
220
|
}
|
|
@@ -206,7 +223,7 @@ export async function restartService(opts: { force?: boolean } = {}): Promise<vo
|
|
|
206
223
|
if (opts.force) clearForceShutdownRequest();
|
|
207
224
|
// Load — starts a fresh single instance
|
|
208
225
|
const load = Bun.spawn(["launchctl", "load", path], { stdout: "pipe", stderr: "pipe" });
|
|
209
|
-
if (await load.exited !== 0) {
|
|
226
|
+
if ((await load.exited) !== 0) {
|
|
210
227
|
const stderr = await new Response(load.stderr).text();
|
|
211
228
|
console.error(` warning: launchctl load failed: ${stderr.trim()}`);
|
|
212
229
|
}
|
|
@@ -227,6 +244,8 @@ async function waitForDaemonExit(timeoutMs: number): Promise<void> {
|
|
|
227
244
|
}
|
|
228
245
|
// Force kill any stragglers
|
|
229
246
|
for (const pid of findDaemonPids()) {
|
|
230
|
-
try {
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, "SIGKILL");
|
|
249
|
+
} catch {}
|
|
231
250
|
}
|
|
232
251
|
}
|