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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Phone channel — voice calling via Twilio + OpenAI Realtime.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Registers HTTP + WebSocket routes on the shared TwilioWebhookServer
|
|
5
|
+
* (which other Twilio channels — sms, whatsapp — also use). The server
|
|
6
|
+
* handles signature validation, dedup, and rate-limit middleware so this
|
|
7
|
+
* file only owns voice-specific logic:
|
|
6
8
|
*
|
|
7
9
|
* - Inbound: caller dials our Twilio number; we return TwiML that opens
|
|
8
10
|
* a Media Stream back to us; the stream is bridged to OpenAI Realtime.
|
|
@@ -11,33 +13,30 @@
|
|
|
11
13
|
* with a per-call goal seeded into the realtime session.
|
|
12
14
|
*
|
|
13
15
|
* Submodules:
|
|
14
|
-
* - twilio.ts
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
16
|
+
* - ../twilio/server.ts — shared HTTP+WS server
|
|
17
|
+
* - ../twilio/rest.ts — Twilio REST API helpers (placeCall, etc.)
|
|
18
|
+
* - twiml.ts — TwiML XML builders
|
|
19
|
+
* - relay.ts — Twilio Media Stream <-> OpenAI Realtime bridge
|
|
20
|
+
* - instructions.ts — system-prompt builders for inbound/outbound
|
|
21
|
+
* - tools.ts — function-calling tools exposed to the realtime model
|
|
22
|
+
* - consult.ts — escape hatch to Claude for memory-aware reasoning
|
|
20
23
|
*/
|
|
21
|
-
import type {
|
|
22
|
-
import type { Channel, PhoneConfig } from "../../types";
|
|
24
|
+
import type { ServerWebSocket } from "bun";
|
|
25
|
+
import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
|
|
23
26
|
import { getConfig } from "../../utils/config";
|
|
24
27
|
import { log } from "../../utils/log";
|
|
25
28
|
import { getChannel } from "../registry";
|
|
26
29
|
import { Session, Message } from "../../db/models";
|
|
27
30
|
import { runMigrations } from "../../db/migrate";
|
|
28
31
|
|
|
29
|
-
import {
|
|
32
|
+
import { getTwilioServer, type WsConnectionData } from "../twilio/server";
|
|
33
|
+
import { placeCall as twilioPlaceCall } from "../twilio/rest";
|
|
30
34
|
import { streamTwiML, sayAndHangupTwiML, rejectTwiML } from "./twiml";
|
|
31
35
|
import { createRelay, type CallContext, type RelayHandle, type RelayResult } from "./relay";
|
|
32
36
|
import { buildInboundInstructions, buildOutboundInstructions } from "./instructions";
|
|
33
37
|
import { buildPhoneTools } from "./tools";
|
|
34
38
|
|
|
35
|
-
interface WsData {
|
|
36
|
-
streamSid: string | null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
39
|
interface PendingCall {
|
|
40
|
-
/** Context written when the call is initiated; consumed by the WS upgrade. */
|
|
41
40
|
context: Omit<CallContext, "streamSid" | "tools">;
|
|
42
41
|
startedAt: number;
|
|
43
42
|
}
|
|
@@ -64,97 +63,77 @@ function defer<T>(): Deferred<T> {
|
|
|
64
63
|
const DEFAULT_MAX_MINUTES = 10;
|
|
65
64
|
const HARD_MAX_MINUTES = 30;
|
|
66
65
|
|
|
66
|
+
const WS_PATH = "/twilio/voice/stream";
|
|
67
|
+
|
|
67
68
|
class PhoneChannel implements Channel {
|
|
68
|
-
name = "phone";
|
|
69
|
-
private
|
|
70
|
-
private readonly
|
|
71
|
-
/** Calls placed/answered but whose Media Stream hasn't connected yet. */
|
|
69
|
+
name = "phone" as const;
|
|
70
|
+
private readonly phone: PhoneConfig;
|
|
71
|
+
private readonly twilio: TwilioConfig;
|
|
72
72
|
private readonly pending = new Map<string, PendingCall>();
|
|
73
|
-
/** Active relays
|
|
73
|
+
/** Active relays keyed by streamSid. */
|
|
74
74
|
private readonly active = new Map<string, ActiveCall>();
|
|
75
|
-
/** Per-callSid completion deferreds
|
|
75
|
+
/** Per-callSid completion deferreds; resolved after persistCall. */
|
|
76
76
|
private readonly completions = new Map<string, Deferred<RelayResult>>();
|
|
77
77
|
|
|
78
|
-
constructor(
|
|
79
|
-
this.
|
|
78
|
+
constructor(phone: PhoneConfig, twilio: TwilioConfig) {
|
|
79
|
+
this.phone = phone;
|
|
80
|
+
this.twilio = twilio;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
// --- Channel lifecycle ---
|
|
83
|
-
|
|
84
83
|
async start(): Promise<void> {
|
|
85
84
|
await runMigrations();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
port: this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return await self.handleOutboundTwiml(req);
|
|
104
|
-
}
|
|
105
|
-
if (path === "/twilio/voice/status" && req.method === "POST") {
|
|
106
|
-
return await self.handleStatus(req);
|
|
107
|
-
}
|
|
108
|
-
return new Response("not found", { status: 404 });
|
|
109
|
-
},
|
|
110
|
-
websocket: {
|
|
111
|
-
message(ws, message) {
|
|
112
|
-
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
113
|
-
const sid = ws.data.streamSid;
|
|
114
|
-
if (sid) {
|
|
115
|
-
const active = self.active.get(sid);
|
|
116
|
-
if (active) {
|
|
117
|
-
active.handle.onTwilioMessage(data);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
self.bootstrapWsMessage(ws, data).catch((err) => {
|
|
122
|
-
log.error({ err }, "phone: ws bootstrap failed");
|
|
123
|
-
try {
|
|
124
|
-
ws.close();
|
|
125
|
-
} catch {}
|
|
126
|
-
});
|
|
127
|
-
},
|
|
128
|
-
close(ws) {
|
|
129
|
-
const sid = ws.data.streamSid;
|
|
130
|
-
if (!sid) return;
|
|
131
|
-
const active = self.active.get(sid);
|
|
132
|
-
if (active) active.handle.onTwilioClose();
|
|
133
|
-
},
|
|
134
|
-
},
|
|
85
|
+
|
|
86
|
+
const server = getTwilioServer();
|
|
87
|
+
server.configure({
|
|
88
|
+
port: this.twilio.port,
|
|
89
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
90
|
+
signingToken: this.twilio.auth_token || this.twilio.secret,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
|
|
94
|
+
server.registerHttp("/twilio/voice/outbound", (req, ctx) => this.handleOutboundTwiml(req, ctx.params));
|
|
95
|
+
server.registerHttp("/twilio/voice/status", (_req, ctx) => this.handleStatus(ctx.params), {
|
|
96
|
+
dedupOn: "CallSid",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
server.registerWs(WS_PATH, {
|
|
100
|
+
onMessage: (ws, data) => this.onWsMessage(ws, data),
|
|
101
|
+
onClose: (ws) => this.onWsClose(ws),
|
|
135
102
|
});
|
|
136
103
|
|
|
104
|
+
// Owner number must never be rate-limited (e.g. urgent rapid retries).
|
|
105
|
+
if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
|
|
106
|
+
|
|
107
|
+
await server.start();
|
|
108
|
+
|
|
137
109
|
log.info(
|
|
138
110
|
{
|
|
139
|
-
port: this.
|
|
140
|
-
publicBaseUrl: this.
|
|
141
|
-
from: this.
|
|
142
|
-
owner: this.
|
|
143
|
-
realtimeModel: this.
|
|
144
|
-
voice: this.
|
|
111
|
+
port: this.twilio.port,
|
|
112
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
113
|
+
from: this.phone.from_number,
|
|
114
|
+
owner: this.twilio.owner_number,
|
|
115
|
+
realtimeModel: this.phone.realtime_model,
|
|
116
|
+
voice: this.phone.voice,
|
|
145
117
|
},
|
|
146
118
|
"phone channel started",
|
|
147
119
|
);
|
|
148
120
|
}
|
|
149
121
|
|
|
150
122
|
async stop(): Promise<void> {
|
|
151
|
-
if (this.server) {
|
|
152
|
-
this.server.stop(true);
|
|
153
|
-
this.server = null;
|
|
154
|
-
}
|
|
155
123
|
for (const active of this.active.values()) active.handle.onTwilioClose();
|
|
156
124
|
this.active.clear();
|
|
157
125
|
this.pending.clear();
|
|
126
|
+
// The shared server is stopped by the daemon's channel teardown — leaving
|
|
127
|
+
// it running here would block SMS/WhatsApp if they're also bound to it.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Phone is voice-only — agent-initiated text/media doesn't have a sensible
|
|
132
|
+
* delivery shape over Twilio Voice. Callers that want a text notification
|
|
133
|
+
* about a call should target a text channel (telegram, slack, whatsapp).
|
|
134
|
+
*/
|
|
135
|
+
async deliver(_out: Outbound): Promise<void> {
|
|
136
|
+
throw new Error("phone: text/media delivery is not supported — use a text channel or placeCall() for voice");
|
|
158
137
|
}
|
|
159
138
|
|
|
160
139
|
// --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
|
|
@@ -174,10 +153,6 @@ class PhoneChannel implements Channel {
|
|
|
174
153
|
opts.maxMinutes && opts.maxMinutes > 0 ? Math.min(opts.maxMinutes, HARD_MAX_MINUTES) : DEFAULT_MAX_MINUTES;
|
|
175
154
|
const instructions = buildOutboundInstructions(opts.goal, opts.context);
|
|
176
155
|
|
|
177
|
-
// Twilio always includes `CallSid` in the form body of the TwiML
|
|
178
|
-
// webhook, so we don't need to encode it in the URL path — one static
|
|
179
|
-
// endpoint handles every outbound call, and we look up context by the
|
|
180
|
-
// CallSid Twilio sends us back.
|
|
181
156
|
const result = await twilioPlaceCall({
|
|
182
157
|
...creds,
|
|
183
158
|
to: opts.number,
|
|
@@ -205,21 +180,14 @@ class PhoneChannel implements Channel {
|
|
|
205
180
|
return result;
|
|
206
181
|
}
|
|
207
182
|
|
|
208
|
-
/**
|
|
209
|
-
* Wait for an in-flight call to finish. Resolves with the final transcript
|
|
210
|
-
* once the call has been persisted; returns null if the callSid is unknown.
|
|
211
|
-
*/
|
|
212
183
|
async awaitCallCompletion(callSid: string): Promise<RelayResult | null> {
|
|
213
184
|
const deferred = this.completions.get(callSid);
|
|
214
185
|
return deferred ? deferred.promise : null;
|
|
215
186
|
}
|
|
216
187
|
|
|
217
|
-
// --- HTTP handlers ---
|
|
218
|
-
|
|
219
|
-
private async handleIncoming(req: Request): Promise<Response> {
|
|
220
|
-
const { params } = await readForm(req);
|
|
221
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
188
|
+
// --- HTTP handlers (signature already validated by the shared server) ---
|
|
222
189
|
|
|
190
|
+
private async handleIncoming(_req: Request, params: Record<string, string>): Promise<Response> {
|
|
223
191
|
const callSid = params.CallSid || "";
|
|
224
192
|
const from = params.From || "";
|
|
225
193
|
const { label, allowed } = this.classifyCaller(from);
|
|
@@ -227,7 +195,7 @@ class PhoneChannel implements Channel {
|
|
|
227
195
|
if (!allowed) {
|
|
228
196
|
log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
|
|
229
197
|
getChannel("telegram")
|
|
230
|
-
?.
|
|
198
|
+
?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
|
|
231
199
|
.catch(() => {});
|
|
232
200
|
return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
|
|
233
201
|
}
|
|
@@ -254,10 +222,7 @@ class PhoneChannel implements Channel {
|
|
|
254
222
|
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "inbound" }));
|
|
255
223
|
}
|
|
256
224
|
|
|
257
|
-
private async handleOutboundTwiml(
|
|
258
|
-
const { params } = await readForm(req);
|
|
259
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
260
|
-
|
|
225
|
+
private async handleOutboundTwiml(_req: Request, params: Record<string, string>): Promise<Response> {
|
|
261
226
|
const callSid = params.CallSid || "";
|
|
262
227
|
const pending = this.pending.get(callSid);
|
|
263
228
|
if (!pending) {
|
|
@@ -270,9 +235,7 @@ class PhoneChannel implements Channel {
|
|
|
270
235
|
return twimlResponse(streamTwiML(this.buildWssUrl(), { callSid, direction: "outbound" }));
|
|
271
236
|
}
|
|
272
237
|
|
|
273
|
-
private
|
|
274
|
-
const { params } = await readForm(req);
|
|
275
|
-
if (!this.verifySignature(req, params)) return forbidden();
|
|
238
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
276
239
|
log.info(
|
|
277
240
|
{
|
|
278
241
|
callSid: params.CallSid,
|
|
@@ -285,16 +248,40 @@ class PhoneChannel implements Channel {
|
|
|
285
248
|
return new Response("", { status: 204 });
|
|
286
249
|
}
|
|
287
250
|
|
|
288
|
-
// --- WebSocket
|
|
251
|
+
// --- WebSocket plumbing (delegated by shared server to this channel) ---
|
|
252
|
+
|
|
253
|
+
private onWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): void {
|
|
254
|
+
const sid = (ws.data.channel as { streamSid?: string }).streamSid;
|
|
255
|
+
if (sid) {
|
|
256
|
+
const active = this.active.get(sid);
|
|
257
|
+
if (active) {
|
|
258
|
+
active.handle.onTwilioMessage(data);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this.bootstrapWsMessage(ws, data).catch((err) => {
|
|
263
|
+
log.error({ err }, "phone: ws bootstrap failed");
|
|
264
|
+
try {
|
|
265
|
+
ws.close();
|
|
266
|
+
} catch {}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private onWsClose(ws: ServerWebSocket<WsConnectionData>): void {
|
|
271
|
+
const sid = (ws.data.channel as { streamSid?: string }).streamSid;
|
|
272
|
+
if (!sid) return;
|
|
273
|
+
const active = this.active.get(sid);
|
|
274
|
+
if (active) active.handle.onTwilioClose();
|
|
275
|
+
}
|
|
289
276
|
|
|
290
|
-
private async bootstrapWsMessage(ws: ServerWebSocket<
|
|
277
|
+
private async bootstrapWsMessage(ws: ServerWebSocket<WsConnectionData>, data: string): Promise<void> {
|
|
291
278
|
let msg: any;
|
|
292
279
|
try {
|
|
293
280
|
msg = JSON.parse(data);
|
|
294
281
|
} catch {
|
|
295
282
|
return;
|
|
296
283
|
}
|
|
297
|
-
if (msg.event === "connected") return;
|
|
284
|
+
if (msg.event === "connected") return;
|
|
298
285
|
|
|
299
286
|
if (msg.event !== "start") {
|
|
300
287
|
log.warn({ event: msg.event }, "phone: unexpected first ws event");
|
|
@@ -321,16 +308,16 @@ class PhoneChannel implements Channel {
|
|
|
321
308
|
|
|
322
309
|
const handle = createRelay({
|
|
323
310
|
twilioWs: ws,
|
|
324
|
-
openAiKey: this.
|
|
325
|
-
model: this.
|
|
326
|
-
voice: this.
|
|
311
|
+
openAiKey: this.phone.openai_api_key!,
|
|
312
|
+
model: this.phone.realtime_model,
|
|
313
|
+
voice: this.phone.voice,
|
|
327
314
|
context,
|
|
328
315
|
});
|
|
329
316
|
|
|
330
|
-
ws.data.streamSid = streamSid;
|
|
317
|
+
(ws.data.channel as { streamSid?: string }).streamSid = streamSid;
|
|
331
318
|
this.active.set(streamSid, { handle, context, startedAt: pending.startedAt });
|
|
332
319
|
|
|
333
|
-
handle.onTwilioMessage(data);
|
|
320
|
+
handle.onTwilioMessage(data);
|
|
334
321
|
|
|
335
322
|
handle.completion
|
|
336
323
|
.then((result) => this.persistCall(context, pending.startedAt, result))
|
|
@@ -341,50 +328,36 @@ class PhoneChannel implements Channel {
|
|
|
341
328
|
// --- Helpers ---
|
|
342
329
|
|
|
343
330
|
private canStartRealtime(): boolean {
|
|
344
|
-
return Boolean(this.
|
|
331
|
+
return Boolean(this.phone.openai_api_key && this.twilio.public_base_url);
|
|
345
332
|
}
|
|
346
333
|
|
|
347
334
|
private buildWssUrl(): string {
|
|
348
|
-
|
|
349
|
-
return `${base}/twilio/voice/stream`;
|
|
335
|
+
return getTwilioServer().buildWssUrl(WS_PATH);
|
|
350
336
|
}
|
|
351
337
|
|
|
352
338
|
private classifyCaller(from: string): { label: string; allowed: boolean } {
|
|
353
|
-
if (from && from === this.
|
|
354
|
-
if (from && this.
|
|
339
|
+
if (from && from === this.twilio.owner_number) return { label: "Aman", allowed: true };
|
|
340
|
+
if (from && this.twilio.allowlist.includes(from)) return { label: from, allowed: true };
|
|
355
341
|
return { label: from || "unknown", allowed: false };
|
|
356
342
|
}
|
|
357
343
|
|
|
358
|
-
private
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const signingToken = this.cfg.twilio_auth_token || this.cfg.twilio_secret;
|
|
364
|
-
if (!signingToken) return false;
|
|
365
|
-
const signature = req.headers.get("X-Twilio-Signature") || "";
|
|
366
|
-
const fullUrl = this.cfg.public_base_url ? `${this.cfg.public_base_url}${new URL(req.url).pathname}` : req.url;
|
|
367
|
-
return validateTwilioSignature({ authToken: signingToken, fullUrl, params, signature });
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private requireCreds(): { accountSid: string; authToken: string } {
|
|
371
|
-
const sid = this.cfg.twilio_sid;
|
|
372
|
-
const secret = this.cfg.twilio_secret;
|
|
373
|
-
if (!sid || !secret)
|
|
374
|
-
throw new Error("phone: channels.phone.twilio_sid and twilio_secret not set (config.yaml or env)");
|
|
375
|
-
return { accountSid: sid, authToken: secret };
|
|
344
|
+
private requireCreds(): { accountSid: string; authSid: string; authSecret: string } {
|
|
345
|
+
const sid = this.twilio.sid;
|
|
346
|
+
const secret = this.twilio.secret;
|
|
347
|
+
if (!sid || !secret) throw new Error("twilio: sid and secret not set (channels.twilio.* in config.yaml or env)");
|
|
348
|
+
return { accountSid: sid, authSid: sid, authSecret: secret };
|
|
376
349
|
}
|
|
377
350
|
|
|
378
351
|
private requirePublicBaseUrl(): string {
|
|
379
|
-
if (!this.
|
|
380
|
-
throw new Error("
|
|
381
|
-
return this.
|
|
352
|
+
if (!this.twilio.public_base_url)
|
|
353
|
+
throw new Error("twilio: public_base_url not set (channels.twilio.public_base_url or PUBLIC_BASE_URL env)");
|
|
354
|
+
return this.twilio.public_base_url;
|
|
382
355
|
}
|
|
383
356
|
|
|
384
357
|
private requireFromNumber(): string {
|
|
385
|
-
if (!this.
|
|
386
|
-
throw new Error("phone:
|
|
387
|
-
return this.
|
|
358
|
+
if (!this.phone.from_number)
|
|
359
|
+
throw new Error("phone: from_number not set (channels.phone.from_number or PHONE_FROM_NUMBER env)");
|
|
360
|
+
return this.phone.from_number;
|
|
388
361
|
}
|
|
389
362
|
|
|
390
363
|
private async persistCall(context: CallContext, startedAt: number, result: RelayResult): Promise<void> {
|
|
@@ -432,34 +405,22 @@ class PhoneChannel implements Channel {
|
|
|
432
405
|
|
|
433
406
|
// --- Pure helpers ---
|
|
434
407
|
|
|
435
|
-
async function readForm(req: Request): Promise<{ params: Record<string, string> }> {
|
|
436
|
-
const body = await req.text();
|
|
437
|
-
const params: Record<string, string> = {};
|
|
438
|
-
const usp = new URLSearchParams(body);
|
|
439
|
-
for (const [k, v] of usp) params[k] = v;
|
|
440
|
-
return { params };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
408
|
function twimlResponse(xml: string): Response {
|
|
444
409
|
return new Response(xml, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
445
410
|
}
|
|
446
411
|
|
|
447
|
-
function forbidden(): Response {
|
|
448
|
-
return new Response("invalid signature", { status: 403 });
|
|
449
|
-
}
|
|
450
|
-
|
|
451
412
|
// --- Factory ---
|
|
452
413
|
|
|
453
414
|
let _instance: PhoneChannel | null = null;
|
|
454
415
|
|
|
455
416
|
export function createPhoneChannel(): PhoneChannel | null {
|
|
456
|
-
const
|
|
457
|
-
if (!
|
|
458
|
-
|
|
417
|
+
const { twilio, phone } = getConfig().channels;
|
|
418
|
+
if (!phone.enabled) return null;
|
|
419
|
+
if (!twilio.sid || !twilio.secret || !phone.from_number) return null;
|
|
420
|
+
_instance = new PhoneChannel(phone, twilio);
|
|
459
421
|
return _instance;
|
|
460
422
|
}
|
|
461
423
|
|
|
462
|
-
/** Used by the place_call MCP tool and CLI test entrypoint. Null if not running. */
|
|
463
424
|
export function getPhoneChannel(): PhoneChannel | null {
|
|
464
425
|
return _instance;
|
|
465
426
|
}
|
|
@@ -47,8 +47,8 @@ export function buildPhoneTools(ctx: ToolContextOpts): PhoneToolDefinition[] {
|
|
|
47
47
|
handler: async (args) => {
|
|
48
48
|
const text = String(args.text || "").slice(0, 1000);
|
|
49
49
|
const tg = getChannel("telegram");
|
|
50
|
-
if (!tg
|
|
51
|
-
await tg.
|
|
50
|
+
if (!tg) return "telegram unavailable";
|
|
51
|
+
await tg.deliver({ text: `[Phone] ${text}` });
|
|
52
52
|
return "sent";
|
|
53
53
|
},
|
|
54
54
|
},
|
package/src/channels/slack.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { App } from "@slack/bolt";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { createHash } from "crypto";
|
|
5
|
-
import {
|
|
6
|
-
import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
|
|
5
|
+
import type { Channel, ChatState, Attachment, AttachmentType, Outbound, Recipient } from "../types";
|
|
7
6
|
import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
|
|
8
7
|
import { relativeTime } from "../utils/format";
|
|
9
8
|
import { runMigrations } from "../db/migrate";
|
|
@@ -13,6 +12,7 @@ import { getMcpServers } from "../mcp";
|
|
|
13
12
|
import { getNiaHome, getPaths } from "../utils/paths";
|
|
14
13
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
15
14
|
import { resolveWatchBehavior } from "../utils/watches";
|
|
15
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
16
16
|
|
|
17
17
|
/** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
|
|
18
18
|
function cleanSentinel(text: string): string {
|
|
@@ -20,47 +20,39 @@ function cleanSentinel(text: string): string {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
class SlackChannel implements Channel {
|
|
23
|
-
name = "slack";
|
|
23
|
+
name = "slack" as const;
|
|
24
24
|
private app: App | null = null;
|
|
25
25
|
private dmUserId: string | null = null;
|
|
26
26
|
/** Timestamps of messages Nia posted proactively (used to detect replies to our own messages) */
|
|
27
27
|
private outboundTs = new Set<string>();
|
|
28
28
|
|
|
29
|
-
async
|
|
29
|
+
async deliver(out: Outbound): Promise<void> {
|
|
30
30
|
if (!this.app) throw new Error("Slack not started");
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const dest = this.resolveDest(out.to);
|
|
32
|
+
|
|
33
|
+
if (out.media) {
|
|
34
|
+
const buffer = Buffer.from(out.media.data);
|
|
35
|
+
const filename = out.media.filename || `file.${out.media.mimeType.split("/")[1] || "bin"}`;
|
|
36
|
+
await this.app.client.filesUploadV2({
|
|
37
|
+
channel_id: dest.channel,
|
|
38
|
+
file: buffer,
|
|
39
|
+
filename,
|
|
40
|
+
...(dest.threadTs ? { thread_ts: dest.threadTs } : {}),
|
|
41
|
+
} as any);
|
|
42
|
+
}
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
if (out.text) {
|
|
45
|
+
const opts: Record<string, unknown> = { channel: dest.channel, text: out.text };
|
|
46
|
+
if (dest.threadTs) opts.thread_ts = dest.threadTs;
|
|
47
|
+
const result = await this.app.client.chat.postMessage(opts as any);
|
|
48
|
+
if (result.ts) this.outboundTs.add(result.ts);
|
|
49
|
+
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
await this.app.client.filesUploadV2({
|
|
50
|
-
channel_id: target,
|
|
51
|
-
file: data,
|
|
52
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async sendMediaToThread(channelId: string, data: Buffer, mimeType: string, filename?: string, threadTs?: string): Promise<void> {
|
|
57
|
-
if (!this.app) throw new Error("Slack not started");
|
|
58
|
-
await this.app.client.filesUploadV2({
|
|
59
|
-
channel_id: channelId,
|
|
60
|
-
file: data,
|
|
61
|
-
filename: filename || `file.${mimeType.split("/")[1] || "bin"}`,
|
|
62
|
-
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
63
|
-
} as any);
|
|
52
|
+
private resolveDest(to: Recipient | undefined): { channel: string; threadTs?: string } {
|
|
53
|
+
if (to?.kind === "thread") return { channel: to.channelId, threadTs: to.threadTs };
|
|
54
|
+
if (!this.dmUserId) throw new Error("No Slack recipient — set dm_user_id in config");
|
|
55
|
+
return { channel: this.dmUserId };
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
async start(): Promise<void> {
|
|
@@ -87,62 +79,46 @@ class SlackChannel implements Channel {
|
|
|
87
79
|
return name;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
|
-
function roomPrefix(key: string): string {
|
|
91
|
-
return `slack-${key}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function roomName(key: string, index: number): string {
|
|
95
|
-
return `slack-${key}-${index}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
82
|
interface SlackContext {
|
|
99
83
|
slackChannelId?: string;
|
|
100
84
|
slackThreadTs?: string;
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!state) {
|
|
106
|
-
const prefix = roomPrefix(key);
|
|
107
|
-
const idx = await Session.getLatestRoomIndex(prefix);
|
|
108
|
-
const room = roomName(key, idx);
|
|
109
|
-
const engine = await createChatEngine({
|
|
110
|
-
room,
|
|
111
|
-
channel: "slack",
|
|
112
|
-
resume: true,
|
|
113
|
-
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
114
|
-
watchBehavior,
|
|
115
|
-
});
|
|
116
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
117
|
-
chats.set(key, state);
|
|
118
|
-
}
|
|
119
|
-
return state;
|
|
87
|
+
function roomPrefix(k: string): string {
|
|
88
|
+
return `slack-${k}`;
|
|
120
89
|
}
|
|
121
90
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const prefix = roomPrefix(key);
|
|
127
|
-
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
128
|
-
const newIdx = prevIdx + 1;
|
|
129
|
-
const room = roomName(key, newIdx);
|
|
130
|
-
|
|
131
|
-
// Persist a placeholder session immediately so the room index survives
|
|
132
|
-
// daemon restarts (otherwise getState falls back to the old room).
|
|
133
|
-
await Session.create(`placeholder-${room}`, room);
|
|
91
|
+
function roomName(k: string, index: number): string {
|
|
92
|
+
return `slack-${k}-${index}`;
|
|
93
|
+
}
|
|
134
94
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
room,
|
|
95
|
+
function buildEngineOpts(watchBehavior?: { channel: string; behavior: string }, slackCtx?: SlackContext) {
|
|
96
|
+
return (room: string) => ({
|
|
138
97
|
channel: "slack",
|
|
139
|
-
resume: false,
|
|
140
98
|
mcpServers: getMcpServers({ channel: "slack", room, ...slackCtx }),
|
|
141
99
|
watchBehavior,
|
|
142
100
|
});
|
|
143
|
-
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getState(
|
|
104
|
+
key: string,
|
|
105
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
106
|
+
slackCtx?: SlackContext,
|
|
107
|
+
): Promise<ChatState> {
|
|
108
|
+
let state = chats.get(key);
|
|
109
|
+
if (state) return state;
|
|
110
|
+
state = await openChatEngine(roomPrefix(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
111
|
+
chats.set(key, state);
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function restartChat(
|
|
116
|
+
key: string,
|
|
117
|
+
watchBehavior?: { channel: string; behavior: string },
|
|
118
|
+
slackCtx?: SlackContext,
|
|
119
|
+
): Promise<ChatState> {
|
|
120
|
+
const state = await rotateRoom(roomPrefix(key), chats.get(key), buildEngineOpts(watchBehavior, slackCtx));
|
|
144
121
|
chats.set(key, state);
|
|
145
|
-
log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
|
|
146
122
|
return state;
|
|
147
123
|
}
|
|
148
124
|
|
|
@@ -152,9 +128,7 @@ class SlackChannel implements Channel {
|
|
|
152
128
|
fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
153
129
|
return;
|
|
154
130
|
}
|
|
155
|
-
|
|
156
|
-
if (queued) log.debug({ key }, "slack: message queued behind active lock");
|
|
157
|
-
state.lock = state.lock.then(fn, fn).catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
|
|
131
|
+
chainLock(state, fn);
|
|
158
132
|
}
|
|
159
133
|
|
|
160
134
|
const self = this;
|
|
@@ -364,7 +338,7 @@ class SlackChannel implements Channel {
|
|
|
364
338
|
|
|
365
339
|
try {
|
|
366
340
|
const data = await downloadSlackFile(file.url_private_download);
|
|
367
|
-
const error = validateAttachment(data
|
|
341
|
+
const error = validateAttachment(data);
|
|
368
342
|
if (error) {
|
|
369
343
|
log.warn({ file: file.name, error }, "skipping slack attachment");
|
|
370
344
|
continue;
|
|
@@ -383,7 +357,13 @@ class SlackChannel implements Channel {
|
|
|
383
357
|
const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
|
|
384
358
|
fileIndex.set(indexedKey, entry);
|
|
385
359
|
|
|
386
|
-
attachments.push({
|
|
360
|
+
attachments.push({
|
|
361
|
+
type: attType,
|
|
362
|
+
data: finalData,
|
|
363
|
+
mimeType: finalMime,
|
|
364
|
+
filename: file.name,
|
|
365
|
+
sourcePath: diskPath,
|
|
366
|
+
});
|
|
387
367
|
} catch (err) {
|
|
388
368
|
log.warn({ err, file: file.name }, "failed to download slack file");
|
|
389
369
|
}
|