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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS channel via Twilio.
|
|
3
|
+
*
|
|
4
|
+
* Same Twilio number as voice (channels.phone.from_number by default,
|
|
5
|
+
* overridable via channels.sms.from_number). Inbound webhook →
|
|
6
|
+
* chat engine → REST reply. Reuses the shared TwilioWebhookServer for
|
|
7
|
+
* routing, signature validation, dedup, and rate-limiting.
|
|
8
|
+
*
|
|
9
|
+
* Use case: cellular-only-no-data reachability — Aman can text Nia from
|
|
10
|
+
* patchy zones (Ladakh highways, basements, etc.) where Telegram /
|
|
11
|
+
* WhatsApp / voice over data won't work but SMS over SS7 still does.
|
|
12
|
+
*
|
|
13
|
+
* Note: outbound from US Twilio long codes to Indian mobile numbers has
|
|
14
|
+
* variable deliverability under TRAI scrubbing rules. Test empirically;
|
|
15
|
+
* if outbound fails, the inbound leg (Aman → Nia) is more reliable.
|
|
16
|
+
*/
|
|
17
|
+
import { getMcpServers } from "../mcp";
|
|
18
|
+
import { runMigrations } from "../db/migrate";
|
|
19
|
+
import type { Channel, ChatState, Outbound, TwilioConfig } from "../types";
|
|
20
|
+
import { getConfig } from "../utils/config";
|
|
21
|
+
import { log } from "../utils/log";
|
|
22
|
+
import { sendMessage as twilioSendMessage } from "./twilio/rest";
|
|
23
|
+
import { getTwilioServer } from "./twilio/server";
|
|
24
|
+
import { chainLock, openChatEngine } from "./common/chat-session";
|
|
25
|
+
|
|
26
|
+
const EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
|
27
|
+
|
|
28
|
+
class SmsChannel implements Channel {
|
|
29
|
+
name = "sms" as const;
|
|
30
|
+
private readonly twilio: TwilioConfig;
|
|
31
|
+
/** Cached resolved "from" number: sms.from_number || phone.from_number */
|
|
32
|
+
private readonly fromNumber: string;
|
|
33
|
+
private readonly chats = new Map<string, ChatState>();
|
|
34
|
+
|
|
35
|
+
constructor(twilio: TwilioConfig, fromNumber: string) {
|
|
36
|
+
this.twilio = twilio;
|
|
37
|
+
this.fromNumber = fromNumber;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start(): Promise<void> {
|
|
41
|
+
await runMigrations();
|
|
42
|
+
|
|
43
|
+
const server = getTwilioServer();
|
|
44
|
+
server.configure({
|
|
45
|
+
port: this.twilio.port,
|
|
46
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
47
|
+
signingToken: this.twilio.auth_token || this.twilio.secret,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
server.registerHttp("/twilio/sms/incoming", (_req, ctx) => this.handleInbound(ctx.params), {
|
|
51
|
+
dedupOn: "MessageSid",
|
|
52
|
+
rateLimitOn: "From",
|
|
53
|
+
});
|
|
54
|
+
server.registerHttp("/twilio/sms/status", (_req, ctx) => this.handleStatus(ctx.params), {
|
|
55
|
+
dedupOn: "MessageSid",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (this.twilio.owner_number) server.exemptFromRateLimit(this.twilio.owner_number);
|
|
59
|
+
|
|
60
|
+
await server.start();
|
|
61
|
+
|
|
62
|
+
log.info(
|
|
63
|
+
{
|
|
64
|
+
from: this.fromNumber,
|
|
65
|
+
owner: this.twilio.owner_number,
|
|
66
|
+
publicBaseUrl: this.twilio.public_base_url,
|
|
67
|
+
},
|
|
68
|
+
"sms channel started",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async stop(): Promise<void> {
|
|
73
|
+
for (const state of this.chats.values()) state.engine.close();
|
|
74
|
+
this.chats.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Outbound — used by send_message MCP tool. SMS is text-only; media is dropped with a warning. */
|
|
78
|
+
async deliver(out: Outbound): Promise<void> {
|
|
79
|
+
if (!this.twilio.owner_number) throw new Error("sms: owner_number not set");
|
|
80
|
+
// SMS has no threading; recipient kind is ignored.
|
|
81
|
+
if (out.media) {
|
|
82
|
+
log.warn({ filename: out.media.filename }, "sms: media payload dropped (channel is text-only)");
|
|
83
|
+
}
|
|
84
|
+
if (out.text) {
|
|
85
|
+
await this.sendTo(this.twilio.owner_number, out.text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Inbound webhook ---
|
|
90
|
+
|
|
91
|
+
private async handleInbound(params: Record<string, string>): Promise<Response> {
|
|
92
|
+
const from = params.From || "";
|
|
93
|
+
const body = params.Body || "";
|
|
94
|
+
|
|
95
|
+
if (!this.isAllowed(from)) {
|
|
96
|
+
log.warn({ from }, "sms: rejecting non-allowlisted sender");
|
|
97
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const state = await this.getState(from);
|
|
101
|
+
// Ack the webhook immediately; reply via REST asynchronously to avoid
|
|
102
|
+
// Twilio's ~15s webhook timeout when the engine takes longer.
|
|
103
|
+
chainLock(state, async () => {
|
|
104
|
+
try {
|
|
105
|
+
const { result } = await state.engine.send(body);
|
|
106
|
+
const reply = result.trim() || "(no response)";
|
|
107
|
+
await this.sendTo(from, reply);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
log.error({ err, from }, "sms: engine error");
|
|
110
|
+
await this.sendTo(from, `[error] ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return new Response(EMPTY_TWIML, { status: 200, headers: { "Content-Type": "text/xml" } });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private handleStatus(params: Record<string, string>): Response {
|
|
118
|
+
log.info(
|
|
119
|
+
{
|
|
120
|
+
messageSid: params.MessageSid,
|
|
121
|
+
status: params.MessageStatus,
|
|
122
|
+
errorCode: params.ErrorCode,
|
|
123
|
+
to: params.To,
|
|
124
|
+
},
|
|
125
|
+
"sms: delivery status",
|
|
126
|
+
);
|
|
127
|
+
return new Response("", { status: 204 });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Outbound ---
|
|
131
|
+
|
|
132
|
+
private async sendTo(remoteE164: string, body: string): Promise<void> {
|
|
133
|
+
if (!this.twilio.sid || !this.twilio.secret) {
|
|
134
|
+
log.warn("sms: twilio sid/secret missing, cannot send");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const res = await twilioSendMessage({
|
|
139
|
+
accountSid: this.twilio.sid,
|
|
140
|
+
authSid: this.twilio.sid,
|
|
141
|
+
authSecret: this.twilio.secret,
|
|
142
|
+
to: remoteE164,
|
|
143
|
+
from: this.fromNumber,
|
|
144
|
+
body,
|
|
145
|
+
statusCallbackUrl: this.twilio.public_base_url ? `${this.twilio.public_base_url}/twilio/sms/status` : undefined,
|
|
146
|
+
});
|
|
147
|
+
log.info({ to: remoteE164, sid: res.messageSid, status: res.status }, "sms: sent");
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.error({ err, to: remoteE164 }, "sms: send failed");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Helpers ---
|
|
154
|
+
|
|
155
|
+
private isAllowed(remoteE164: string): boolean {
|
|
156
|
+
if (this.twilio.owner_number && remoteE164 === this.twilio.owner_number) return true;
|
|
157
|
+
return this.twilio.allowlist.includes(remoteE164);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async getState(remoteE164: string): Promise<ChatState> {
|
|
161
|
+
let state = this.chats.get(remoteE164);
|
|
162
|
+
if (state) return state;
|
|
163
|
+
state = await openChatEngine(`sms-${remoteE164}`, () => ({ channel: "sms", mcpServers: getMcpServers() }));
|
|
164
|
+
this.chats.set(remoteE164, state);
|
|
165
|
+
return state;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createSmsChannel(): SmsChannel | null {
|
|
170
|
+
const { twilio, sms, phone } = getConfig().channels;
|
|
171
|
+
if (!sms.enabled) return null;
|
|
172
|
+
if (!twilio.sid || !twilio.secret) return null;
|
|
173
|
+
// sms.from_number falls back to phone.from_number (same number for voice + SMS).
|
|
174
|
+
const fromNumber = sms.from_number ?? phone.from_number;
|
|
175
|
+
if (!fromNumber) return null;
|
|
176
|
+
return new SmsChannel(twilio, fromNumber);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type { SmsChannel };
|
package/src/channels/telegram.ts
CHANGED
|
@@ -2,15 +2,15 @@ import { Bot, InputFile } from "grammy";
|
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
import { mkdirSync, writeFileSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import {
|
|
6
|
-
import type { Channel, ChatState, Attachment } from "../types";
|
|
5
|
+
import type { Channel, ChatState, Attachment, Outbound } from "../types";
|
|
7
6
|
import { getConfig, updateRawConfig } from "../utils/config";
|
|
8
7
|
import { runMigrations } from "../db/migrate";
|
|
9
|
-
import {
|
|
8
|
+
import { Message } from "../db/models";
|
|
10
9
|
import { log } from "../utils/log";
|
|
11
10
|
import { getMcpServers } from "../mcp";
|
|
12
11
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
13
12
|
import { getNiaHome } from "../utils/paths";
|
|
13
|
+
import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
|
|
14
14
|
|
|
15
15
|
function safeExtension(filename?: string): string {
|
|
16
16
|
const ext = filename?.split(".").pop();
|
|
@@ -23,27 +23,27 @@ function cacheExtension(filename: string | undefined, mimeType: string): string
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
class TelegramChannel implements Channel {
|
|
26
|
-
name = "telegram";
|
|
26
|
+
name = "telegram" as const;
|
|
27
27
|
private bot: Bot | null = null;
|
|
28
28
|
private outboundChatId: number | null = null;
|
|
29
29
|
|
|
30
|
-
async
|
|
30
|
+
async deliver(out: Outbound): Promise<void> {
|
|
31
31
|
if (!this.bot) throw new Error("Telegram not started");
|
|
32
32
|
const chatId = this.outboundChatId;
|
|
33
33
|
if (!chatId) throw new Error("No outbound chat ID registered");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await this.bot.api.
|
|
34
|
+
// Telegram has no native threading; thread recipients fall back to the
|
|
35
|
+
// configured DM chat (the same place we'd send to for `owner`).
|
|
36
|
+
|
|
37
|
+
if (out.media) {
|
|
38
|
+
const file = new InputFile(Buffer.from(out.media.data), out.media.filename);
|
|
39
|
+
if (out.media.mimeType.startsWith("image/")) {
|
|
40
|
+
await this.bot.api.sendPhoto(chatId, file);
|
|
41
|
+
} else {
|
|
42
|
+
await this.bot.api.sendDocument(chatId, file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (out.text) {
|
|
46
|
+
await this.bot.api.sendMessage(chatId, out.text);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -57,7 +57,13 @@ class TelegramChannel implements Channel {
|
|
|
57
57
|
return Buffer.from(await resp.arrayBuffer());
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
private cacheAttachment(
|
|
60
|
+
private cacheAttachment(
|
|
61
|
+
chatId: number,
|
|
62
|
+
roomIndex: number,
|
|
63
|
+
data: Buffer,
|
|
64
|
+
mimeType: string,
|
|
65
|
+
filename?: string,
|
|
66
|
+
): string {
|
|
61
67
|
const scope = `telegram-${chatId}-${roomIndex}`;
|
|
62
68
|
const dir = join(getNiaHome(), "tmp", "attachments", scope);
|
|
63
69
|
mkdirSync(dir, { recursive: true });
|
|
@@ -78,44 +84,23 @@ class TelegramChannel implements Channel {
|
|
|
78
84
|
|
|
79
85
|
const chats = new Map<number, ChatState>();
|
|
80
86
|
|
|
81
|
-
function
|
|
87
|
+
function keyOf(chatId: number): string {
|
|
82
88
|
return `tg-${chatId}`;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
|
-
function roomName(chatId: number, index: number): string {
|
|
86
|
-
return `tg-${chatId}-${index}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
91
|
async function getState(chatId: number): Promise<ChatState> {
|
|
90
92
|
let state = chats.get(chatId);
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const room = roomName(chatId, idx);
|
|
95
|
-
log.info({ chatId, room }, "telegram: creating chat engine");
|
|
96
|
-
const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
|
|
97
|
-
state = { engine, roomIndex: idx, lock: Promise.resolve() };
|
|
98
|
-
chats.set(chatId, state);
|
|
99
|
-
log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
|
|
100
|
-
}
|
|
93
|
+
if (state) return state;
|
|
94
|
+
state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
|
|
95
|
+
chats.set(chatId, state);
|
|
101
96
|
return state;
|
|
102
97
|
}
|
|
103
98
|
|
|
104
99
|
async function restartChat(chatId: number): Promise<ChatState> {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const prevIdx = await Session.getLatestRoomIndex(prefix);
|
|
110
|
-
const newIdx = prevIdx + 1;
|
|
111
|
-
const room = roomName(chatId, newIdx);
|
|
112
|
-
|
|
113
|
-
// Persist a placeholder session immediately so the room index survives
|
|
114
|
-
// daemon restarts (otherwise getState falls back to the old room).
|
|
115
|
-
await Session.create(`placeholder-${room}`, room);
|
|
116
|
-
|
|
117
|
-
const engine = await createChatEngine({ room, channel: "telegram", resume: false, mcpServers: getMcpServers() });
|
|
118
|
-
const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
|
|
100
|
+
const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
|
|
101
|
+
channel: "telegram",
|
|
102
|
+
mcpServers: getMcpServers(),
|
|
103
|
+
}));
|
|
119
104
|
chats.set(chatId, state);
|
|
120
105
|
return state;
|
|
121
106
|
}
|
|
@@ -126,9 +111,7 @@ class TelegramChannel implements Channel {
|
|
|
126
111
|
fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
|
|
127
112
|
return;
|
|
128
113
|
}
|
|
129
|
-
|
|
130
|
-
if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
|
|
131
|
-
state.lock = state.lock.then(fn, fn);
|
|
114
|
+
chainLock(state, fn);
|
|
132
115
|
}
|
|
133
116
|
|
|
134
117
|
const isOpen = config.channels.telegram.open;
|
|
@@ -151,7 +134,10 @@ class TelegramChannel implements Channel {
|
|
|
151
134
|
|
|
152
135
|
async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
|
|
153
136
|
const chatId = ctx.chatId;
|
|
154
|
-
log.info(
|
|
137
|
+
log.info(
|
|
138
|
+
{ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
|
|
139
|
+
"telegram message received",
|
|
140
|
+
);
|
|
155
141
|
|
|
156
142
|
// Show typing indicator throughout
|
|
157
143
|
const typingInterval = setInterval(() => {
|
|
@@ -251,7 +237,7 @@ class TelegramChannel implements Channel {
|
|
|
251
237
|
const mime = doc.mime_type || "application/octet-stream";
|
|
252
238
|
const attType = classifyMime(mime) || "file";
|
|
253
239
|
let data = await self.downloadFile(doc.file_id);
|
|
254
|
-
const error = validateAttachment(data
|
|
240
|
+
const error = validateAttachment(data);
|
|
255
241
|
if (error) {
|
|
256
242
|
await ctx.reply(error);
|
|
257
243
|
return;
|
|
@@ -263,7 +249,13 @@ class TelegramChannel implements Channel {
|
|
|
263
249
|
finalMime = prepared.mimeType;
|
|
264
250
|
}
|
|
265
251
|
const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
|
|
266
|
-
const attachment: Attachment = {
|
|
252
|
+
const attachment: Attachment = {
|
|
253
|
+
type: attType,
|
|
254
|
+
data,
|
|
255
|
+
mimeType: finalMime,
|
|
256
|
+
filename: doc.file_name,
|
|
257
|
+
sourcePath,
|
|
258
|
+
};
|
|
267
259
|
const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
|
|
268
260
|
await processMessage(ctx, state, caption, [attachment]);
|
|
269
261
|
} catch (err) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-bounded set for deduplicating webhook deliveries.
|
|
3
|
+
*
|
|
4
|
+
* Twilio retries webhooks on 5xx/timeouts, so the same MessageSid /
|
|
5
|
+
* CallSid can arrive multiple times. We track recently-seen IDs and
|
|
6
|
+
* drop duplicates, expiring entries after `ttlMs`.
|
|
7
|
+
*/
|
|
8
|
+
export class Dedup {
|
|
9
|
+
private readonly seen = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly ttlMs: number = 10 * 60 * 1000,
|
|
13
|
+
private readonly maxEntries: number = 5000,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
/** Returns true if this id was already seen recently; false (and records it) otherwise. */
|
|
17
|
+
check(id: string): boolean {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const cutoff = now - this.ttlMs;
|
|
20
|
+
const seenAt = this.seen.get(id);
|
|
21
|
+
if (seenAt !== undefined && seenAt > cutoff) return true;
|
|
22
|
+
this.seen.set(id, now);
|
|
23
|
+
if (this.seen.size > this.maxEntries) this.prune(cutoff);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private prune(cutoff: number): void {
|
|
28
|
+
for (const [k, v] of this.seen) {
|
|
29
|
+
if (v <= cutoff) this.seen.delete(k);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
size(): number {
|
|
34
|
+
return this.seen.size;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-backed cache for outbound Twilio media.
|
|
3
|
+
*
|
|
4
|
+
* Twilio fetches outbound MMS/WhatsApp media by URL, so we write the
|
|
5
|
+
* payload to ~/.niahere/tmp/outbound/<sha>.<ext>, expose it under
|
|
6
|
+
* GET /twilio/media/<sha>.<ext>, and Twilio retrieves it. Disk (not
|
|
7
|
+
* memory) so the URL survives daemon restarts and Twilio's webhook
|
|
8
|
+
* retries within the eviction window.
|
|
9
|
+
*
|
|
10
|
+
* Eviction is opportunistic: capped at 100 files, 10MB total, 24h max
|
|
11
|
+
* age. Oldest-first; expired-first. Runs after every write; cheap
|
|
12
|
+
* enough at this scale (single user, low traffic).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "fs/promises";
|
|
15
|
+
import { createHash } from "crypto";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { getNiaHome } from "../../utils/paths";
|
|
18
|
+
import { log } from "../../utils/log";
|
|
19
|
+
|
|
20
|
+
const MAX_FILES = 100;
|
|
21
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
25
|
+
"image/jpeg": "jpg",
|
|
26
|
+
"image/png": "png",
|
|
27
|
+
"image/webp": "webp",
|
|
28
|
+
"image/gif": "gif",
|
|
29
|
+
"audio/mpeg": "mp3",
|
|
30
|
+
"audio/mp4": "m4a",
|
|
31
|
+
"audio/ogg": "ogg",
|
|
32
|
+
"audio/wav": "wav",
|
|
33
|
+
"video/mp4": "mp4",
|
|
34
|
+
"application/pdf": "pdf",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const FILENAME_RE = /^[a-f0-9]{16,64}\.[a-z0-9]{1,8}$/i;
|
|
38
|
+
|
|
39
|
+
export function getMediaDir(): string {
|
|
40
|
+
return join(getNiaHome(), "tmp", "outbound");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CachedMedia {
|
|
44
|
+
filename: string;
|
|
45
|
+
path: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function cacheMedia(buffer: Uint8Array, mime: string, ext?: string): Promise<CachedMedia> {
|
|
49
|
+
const dir = getMediaDir();
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
const resolvedExt = (ext ?? MIME_TO_EXT[mime] ?? "bin").replace(/[^a-z0-9]/gi, "").slice(0, 8) || "bin";
|
|
52
|
+
const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 32);
|
|
53
|
+
const filename = `${hash}.${resolvedExt}`;
|
|
54
|
+
const path = join(dir, filename);
|
|
55
|
+
await writeFile(path, buffer);
|
|
56
|
+
await evict().catch((err) => log.warn({ err }, "media-cache: eviction failed"));
|
|
57
|
+
return { filename, path };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function readCachedMedia(filename: string): Promise<{ buffer: Buffer; mime: string } | null> {
|
|
61
|
+
if (!FILENAME_RE.test(filename)) return null;
|
|
62
|
+
const path = join(getMediaDir(), filename);
|
|
63
|
+
try {
|
|
64
|
+
const buffer = await readFile(path);
|
|
65
|
+
const ext = filename.split(".").pop()!.toLowerCase();
|
|
66
|
+
const mime = Object.entries(MIME_TO_EXT).find(([, e]) => e === ext)?.[0] ?? "application/octet-stream";
|
|
67
|
+
return { buffer, mime };
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function evict(): Promise<void> {
|
|
74
|
+
const dir = getMediaDir();
|
|
75
|
+
const entries = await readdir(dir);
|
|
76
|
+
const stats = await Promise.all(
|
|
77
|
+
entries.map(async (name) => {
|
|
78
|
+
const path = join(dir, name);
|
|
79
|
+
const s = await stat(path);
|
|
80
|
+
return { name, path, mtime: s.mtimeMs, size: s.size };
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
let alive: typeof stats = [];
|
|
86
|
+
for (const s of stats) {
|
|
87
|
+
if (now - s.mtime > MAX_AGE_MS) {
|
|
88
|
+
await unlink(s.path).catch(() => {});
|
|
89
|
+
} else {
|
|
90
|
+
alive.push(s);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
alive.sort((a, b) => a.mtime - b.mtime);
|
|
95
|
+
|
|
96
|
+
while (alive.length > MAX_FILES) {
|
|
97
|
+
const victim = alive.shift()!;
|
|
98
|
+
await unlink(victim.path).catch(() => {});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let totalBytes = alive.reduce((sum, f) => sum + f.size, 0);
|
|
102
|
+
while (totalBytes > MAX_BYTES && alive.length > 0) {
|
|
103
|
+
const victim = alive.shift()!;
|
|
104
|
+
totalBytes -= victim.size;
|
|
105
|
+
await unlink(victim.path).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download inbound media attached to a Twilio webhook.
|
|
3
|
+
*
|
|
4
|
+
* Twilio's MediaUrlN values are HTTPS URLs that 302-redirect to S3.
|
|
5
|
+
* They sit behind Basic auth (same Twilio creds), so a vanilla fetch
|
|
6
|
+
* without an Authorization header gets 401. Bun's fetch follows the
|
|
7
|
+
* redirect transparently.
|
|
8
|
+
*
|
|
9
|
+
* Concurrency is bounded (4 in flight, 10s per item) so a sender that
|
|
10
|
+
* attaches many large files cannot stall the webhook handler.
|
|
11
|
+
*/
|
|
12
|
+
import { log } from "../../utils/log";
|
|
13
|
+
import type { TwilioCreds } from "./rest";
|
|
14
|
+
|
|
15
|
+
const MAX_CONCURRENT = 4;
|
|
16
|
+
const TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export interface InboundMedia {
|
|
19
|
+
index: number;
|
|
20
|
+
url: string;
|
|
21
|
+
mime: string;
|
|
22
|
+
data: Buffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MediaDescriptor {
|
|
26
|
+
index: number;
|
|
27
|
+
url: string;
|
|
28
|
+
mime: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pull out the NumMedia / MediaUrlN / MediaContentTypeN fields from a
|
|
33
|
+
* Twilio webhook form body.
|
|
34
|
+
*/
|
|
35
|
+
export function extractMedia(params: Record<string, string>): MediaDescriptor[] {
|
|
36
|
+
const num = parseInt(params.NumMedia || "0", 10);
|
|
37
|
+
if (!Number.isFinite(num) || num <= 0) return [];
|
|
38
|
+
const items: MediaDescriptor[] = [];
|
|
39
|
+
for (let i = 0; i < num; i++) {
|
|
40
|
+
const url = params[`MediaUrl${i}`];
|
|
41
|
+
const mime = params[`MediaContentType${i}`];
|
|
42
|
+
if (url && mime) items.push({ index: i, url, mime });
|
|
43
|
+
}
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function downloadInboundMedia(
|
|
48
|
+
descriptors: MediaDescriptor[],
|
|
49
|
+
creds: TwilioCreds,
|
|
50
|
+
): Promise<InboundMedia[]> {
|
|
51
|
+
const out: InboundMedia[] = [];
|
|
52
|
+
for (let i = 0; i < descriptors.length; i += MAX_CONCURRENT) {
|
|
53
|
+
const slice = descriptors.slice(i, i + MAX_CONCURRENT);
|
|
54
|
+
const results = await Promise.allSettled(slice.map((d) => downloadOne(d, creds)));
|
|
55
|
+
for (let j = 0; j < results.length; j++) {
|
|
56
|
+
const r = results[j];
|
|
57
|
+
if (r.status === "fulfilled") {
|
|
58
|
+
out.push(r.value);
|
|
59
|
+
} else {
|
|
60
|
+
log.warn({ err: r.reason, descriptor: slice[j] }, "twilio: media download failed");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function downloadOne(d: MediaDescriptor, creds: TwilioCreds): Promise<InboundMedia> {
|
|
68
|
+
const auth = `Basic ${Buffer.from(`${creds.authSid}:${creds.authSecret}`).toString("base64")}`;
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
71
|
+
try {
|
|
72
|
+
const resp = await fetch(d.url, { headers: { Authorization: auth }, signal: controller.signal });
|
|
73
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
74
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
75
|
+
return { index: d.index, url: d.url, mime: d.mime, data: buffer };
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding-window rate limiter keyed by an arbitrary string (e.g. caller
|
|
3
|
+
* E.164). Protects against runaway costs when the WhatsApp Sandbox's
|
|
4
|
+
* shared number gets random opt-ins and someone spams.
|
|
5
|
+
*/
|
|
6
|
+
export class RateLimiter {
|
|
7
|
+
private readonly hits = new Map<string, number[]>();
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly maxPerWindow: number = 30,
|
|
11
|
+
private readonly windowMs: number = 60_000,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
/** Returns true if this hit was allowed (and recorded); false if over limit. */
|
|
15
|
+
allow(key: string): boolean {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
const cutoff = now - this.windowMs;
|
|
18
|
+
const arr = this.hits.get(key) ?? [];
|
|
19
|
+
const recent = arr.filter((t) => t > cutoff);
|
|
20
|
+
if (recent.length >= this.maxPerWindow) {
|
|
21
|
+
this.hits.set(key, recent);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
recent.push(now);
|
|
25
|
+
this.hits.set(key, recent);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Drop tracking for a key (e.g. owner number — never rate-limit yourself). */
|
|
30
|
+
exempt(key: string): void {
|
|
31
|
+
this.hits.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|