@mulmobridge/twilio-sms 0.1.0

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 ADDED
@@ -0,0 +1,74 @@
1
+ # @mulmobridge/twilio-sms
2
+
3
+ > **Experimental** — please test and [report issues](https://github.com/receptron/mulmoclaude/issues/new).
4
+
5
+ SMS bridge for [MulmoClaude](https://github.com/receptron/mulmoclaude) via [Twilio Programmable Messaging](https://www.twilio.com/messaging). Every phone on Earth can text your AI agent — no app install needed.
6
+
7
+ **Public URL required** (Twilio posts a webhook every time an SMS arrives on your number).
8
+
9
+ ## Setup
10
+
11
+ ### 1. Get a Twilio number
12
+
13
+ 1. Sign up at [twilio.com](https://www.twilio.com/) (trial includes credits).
14
+ 2. **Phone Numbers → Buy a Number** → pick one with SMS capability.
15
+ 3. Note the **Account SID** + **Auth Token** (Twilio console top-right / Settings → General).
16
+
17
+ ### 2. Expose the bridge with a tunnel
18
+
19
+ ```bash
20
+ ngrok http 3010
21
+ # copy the https URL — e.g. https://abcd.ngrok-free.app
22
+ ```
23
+
24
+ ### 3. Configure the Twilio number
25
+
26
+ In the Twilio console, open the number → **Messaging → A Message Comes In** → Webhook, method **HTTP POST** → URL `https://<your-tunnel>/sms`.
27
+
28
+ ### 4. Run the bridge
29
+
30
+ ```bash
31
+ TWILIO_ACCOUNT_SID=AC... \
32
+ TWILIO_AUTH_TOKEN=... \
33
+ TWILIO_FROM_NUMBER=+15551234567 \
34
+ TWILIO_PUBLIC_URL=https://abcd.ngrok-free.app \
35
+ npx @mulmobridge/twilio-sms
36
+ ```
37
+
38
+ Text the Twilio number — you'll get a reply.
39
+
40
+ ## Environment variables
41
+
42
+ | Variable | Required | Default | Description |
43
+ |--------------------------|-------------|---------|-------------|
44
+ | `TWILIO_ACCOUNT_SID` | yes | — | Twilio Account SID |
45
+ | `TWILIO_AUTH_TOKEN` | yes | — | Twilio Auth Token (used for REST + signature verification) |
46
+ | `TWILIO_FROM_NUMBER` | yes | — | Your Twilio number in E.164, e.g. `+15551234567` |
47
+ | `TWILIO_WEBHOOK_PORT` | no | `3010` | HTTP port |
48
+ | `TWILIO_PUBLIC_URL` | yes (prod) | — | Full public URL the bridge is reachable at (e.g. `https://abcd.ngrok-free.app`, including any query string Twilio signs). Required to verify Twilio's `X-Twilio-Signature`. The bridge refuses to start without it unless `TWILIO_ALLOW_UNVERIFIED=1` is also set. |
49
+ | `TWILIO_ALLOW_UNVERIFIED`| no | — | Set to `1` to skip signature verification (local testing only). Prints a loud warning and leaves `/sms` wide open. Do **not** set in production. |
50
+ | `TWILIO_ALLOWED_NUMBERS` | no | (all) | CSV of sender E.164 numbers allowed (empty = accept every number) |
51
+ | `MULMOCLAUDE_AUTH_TOKEN` | no | auto | MulmoClaude bearer token override |
52
+ | `MULMOCLAUDE_API_URL` | no | `http://localhost:3001` | MulmoClaude server URL |
53
+
54
+ ## How it works
55
+
56
+ 1. Twilio posts form-encoded `From`, `To`, `Body`, `MessageSid` to `/sms` every time an SMS arrives.
57
+ 2. The bridge verifies `X-Twilio-Signature` (HMAC-SHA1 over the full URL — including query string — + sorted form params) using the auth token. `TWILIO_PUBLIC_URL` must match the URL Twilio sees (scheme + host + optional path prefix); the request's actual query string is read from `req.originalUrl`.
58
+ 3. We ACK `204` immediately so Twilio doesn't retry, then (asynchronously) forward the trimmed body to MulmoClaude keyed by the sender's number.
59
+ 4. The reply is sent back via `POST /2010-04-01/Accounts/{SID}/Messages.json` with Basic auth; long replies are chunked at 1 600 chars (Twilio's concatenated-SMS ceiling).
60
+
61
+ ## Troubleshooting
62
+
63
+ | Symptom | Cause | Fix |
64
+ |---|---|---|
65
+ | 401 at Twilio side | Signature verification failed | Verify `TWILIO_PUBLIC_URL` matches the URL Twilio actually hits (scheme + host + path, no trailing `/`) |
66
+ | No reply delivered | `Messages.json` REST call failing | `docker logs` / `npx` output will show `[twilio-sms] send failed: …`. Common cause: trial account can only message verified numbers |
67
+ | Duplicate replies | Twilio retried before ACK | Ensure reachable `https://` endpoint (not HTTP) and the bridge responds 2xx under 15 s |
68
+
69
+ ## Security notes
70
+
71
+ - The auth token is equivalent to root credentials on your Twilio account. Rotate in the console if leaked.
72
+ - `TWILIO_PUBLIC_URL` is strongly recommended — without it, anyone who finds your webhook can impersonate Twilio and converse with your agent.
73
+ - Trial Twilio accounts can only SMS pre-verified numbers. Upgrade to production before real use.
74
+ - SMS is plaintext — don't discuss secrets over it. Use Signal / WhatsApp / Matrix instead for sensitive content.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // @mulmobridge/twilio-sms — Twilio SMS bridge for MulmoClaude.
3
+ //
4
+ // Inbound: Twilio sends form-encoded POST to /sms when an SMS arrives
5
+ // on your Twilio number. The bridge validates Twilio's HMAC-SHA1
6
+ // X-Twilio-Signature, forwards the text to MulmoClaude, and replies
7
+ // via the Twilio REST API. Outbound: Twilio REST API sends SMS.
8
+ //
9
+ // Required env vars:
10
+ // TWILIO_ACCOUNT_SID — Account SID from the Twilio console
11
+ // TWILIO_AUTH_TOKEN — Auth token (used for both REST auth and
12
+ // X-Twilio-Signature verification)
13
+ // TWILIO_FROM_NUMBER — Your Twilio number in E.164 form (+15551234567)
14
+ //
15
+ // Optional:
16
+ // TWILIO_WEBHOOK_PORT — HTTP port (default 3010)
17
+ // TWILIO_PUBLIC_URL — Full public URL of /sms, used for signature
18
+ // verification. Required by default; the
19
+ // bridge refuses to start without it unless
20
+ // TWILIO_ALLOW_UNVERIFIED=1 is also set
21
+ // (dev-only escape hatch).
22
+ // TWILIO_ALLOW_UNVERIFIED — When "1", skip signature verification.
23
+ // Only for local testing — prints a loud
24
+ // warning and leaves /sms wide open.
25
+ // TWILIO_ALLOWED_NUMBERS — CSV of sender numbers allowed (empty = all)
26
+ import "dotenv/config";
27
+ import crypto from "crypto";
28
+ import express from "express";
29
+ import { createBridgeClient, chunkText } from "@mulmobridge/client";
30
+ const TRANSPORT_ID = "twilio-sms";
31
+ const MAX_SMS_LEN = 1_600; // Twilio concatenates segments up to 1600 chars
32
+ const FETCH_TIMEOUT_MS = 15_000;
33
+ const PORT = Number(process.env.TWILIO_WEBHOOK_PORT) || 3010;
34
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
35
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
36
+ const fromNumber = process.env.TWILIO_FROM_NUMBER;
37
+ if (!accountSid || !authToken || !fromNumber) {
38
+ console.error("TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER are required.\n" + "See README for setup instructions.");
39
+ process.exit(1);
40
+ }
41
+ const publicUrl = process.env.TWILIO_PUBLIC_URL?.replace(/\/$/, "");
42
+ const allowUnverified = process.env.TWILIO_ALLOW_UNVERIFIED === "1";
43
+ if (!publicUrl && !allowUnverified) {
44
+ console.error("TWILIO_PUBLIC_URL is required for X-Twilio-Signature verification.\n" +
45
+ "For local testing only, you can set TWILIO_ALLOW_UNVERIFIED=1 to skip\n" +
46
+ "verification — but never in production (open webhook).");
47
+ process.exit(1);
48
+ }
49
+ if (!publicUrl && allowUnverified) {
50
+ console.warn("[twilio-sms] ⚠ TWILIO_ALLOW_UNVERIFIED=1 — signature verification DISABLED. Use only in local testing.");
51
+ }
52
+ const allowedNumbers = new Set((process.env.TWILIO_ALLOWED_NUMBERS ?? "")
53
+ .split(",")
54
+ .map((num) => num.trim())
55
+ .filter(Boolean));
56
+ const allowAll = allowedNumbers.size === 0;
57
+ const mulmo = createBridgeClient({ transportId: TRANSPORT_ID });
58
+ mulmo.onPush((pushEvent) => {
59
+ sendSms(pushEvent.chatId, pushEvent.message).catch((err) => console.error(`[twilio-sms] push send failed: ${err}`));
60
+ });
61
+ // ── Twilio signature validation ─────────────────────────────────
62
+ function expectedSignature(url, params) {
63
+ // Twilio: sort params by key, concat key+value, prepend URL, sign with auth token.
64
+ const sortedKeys = Object.keys(params).sort();
65
+ const data = sortedKeys.reduce((acc, key) => acc + key + params[key], url);
66
+ const hmac = crypto.createHmac("sha1", authToken);
67
+ hmac.update(data);
68
+ return hmac.digest("base64");
69
+ }
70
+ function signatureValid(url, params, provided) {
71
+ const expected = expectedSignature(url, params);
72
+ if (expected.length !== provided.length)
73
+ return false;
74
+ try {
75
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ // ── Twilio REST: send SMS ──────────────────────────────────────
82
+ async function sendSms(toNumber, text) {
83
+ const auth = Buffer.from(`${accountSid}:${authToken}`).toString("base64");
84
+ const chunks = chunkText(text, MAX_SMS_LEN);
85
+ for (const chunk of chunks) {
86
+ const form = new URLSearchParams({ From: fromNumber, To: toNumber, Body: chunk });
87
+ let res;
88
+ try {
89
+ res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, {
90
+ method: "POST",
91
+ headers: {
92
+ Authorization: `Basic ${auth}`,
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ },
95
+ body: form.toString(),
96
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
97
+ });
98
+ }
99
+ catch (err) {
100
+ console.error(`[twilio-sms] network error: ${err}`);
101
+ continue;
102
+ }
103
+ if (!res.ok) {
104
+ const detail = await res.text().catch(() => "");
105
+ console.error(`[twilio-sms] send failed: ${res.status} ${detail.slice(0, 200)}`);
106
+ }
107
+ }
108
+ }
109
+ // ── HTTP server ────────────────────────────────────────────────
110
+ const app = express();
111
+ app.disable("x-powered-by");
112
+ app.use(express.urlencoded({ extended: false }));
113
+ app.get("/health", (__req, res) => {
114
+ res.json({ status: "ok", transport: TRANSPORT_ID });
115
+ });
116
+ function parseTwilioBody(body) {
117
+ if (!body || typeof body !== "object")
118
+ return null;
119
+ const record = body;
120
+ const from = typeof record.From === "string" ? record.From : "";
121
+ const toField = typeof record.To === "string" ? record.To : "";
122
+ const text = typeof record.Body === "string" ? record.Body : "";
123
+ const messageSid = typeof record.MessageSid === "string" ? record.MessageSid : "";
124
+ if (!from || !toField || !messageSid)
125
+ return null;
126
+ return { From: from, To: toField, Body: text, MessageSid: messageSid };
127
+ }
128
+ function stringifyParams(body) {
129
+ const out = {};
130
+ for (const [key, value] of Object.entries(body)) {
131
+ if (typeof value === "string")
132
+ out[key] = value;
133
+ }
134
+ return out;
135
+ }
136
+ app.post("/sms", async (req, res) => {
137
+ const parsed = parseTwilioBody(req.body);
138
+ if (!parsed) {
139
+ res.status(400).send("Bad Request");
140
+ return;
141
+ }
142
+ if (publicUrl) {
143
+ const sig = typeof req.headers["x-twilio-signature"] === "string" ? req.headers["x-twilio-signature"] : "";
144
+ const params = stringifyParams(req.body);
145
+ // Twilio signs the *full* URL it POSTed to — including query string.
146
+ // `req.originalUrl` keeps the querystring that Twilio saw, whereas
147
+ // `req.path` is just "/sms". Without this, any webhook URL with a
148
+ // query parameter (e.g. ?env=prod) would fail verification.
149
+ const fullUrl = `${publicUrl}${req.originalUrl}`;
150
+ if (!sig || !signatureValid(fullUrl, params, sig)) {
151
+ console.warn("[twilio-sms] AUTH_FAILED: X-Twilio-Signature mismatch");
152
+ res.status(401).send("Invalid signature");
153
+ return;
154
+ }
155
+ }
156
+ // ACK right away so Twilio doesn't retry; we'll send the reply asynchronously.
157
+ res.status(204).end();
158
+ if (!allowAll && !allowedNumbers.has(parsed.From)) {
159
+ console.log(`[twilio-sms] denied from=${parsed.From}`);
160
+ return;
161
+ }
162
+ const text = parsed.Body.trim();
163
+ if (!text)
164
+ return;
165
+ console.log(`[twilio-sms] message from=${parsed.From} len=${text.length}`);
166
+ try {
167
+ const ack = await mulmo.send(parsed.From, text);
168
+ if (ack.ok) {
169
+ await sendSms(parsed.From, ack.reply ?? "");
170
+ }
171
+ else {
172
+ const status = ack.status ? ` (${ack.status})` : "";
173
+ await sendSms(parsed.From, `Error${status}: ${ack.error ?? "unknown"}`);
174
+ }
175
+ }
176
+ catch (err) {
177
+ console.error(`[twilio-sms] message handling failed: ${err}`);
178
+ }
179
+ });
180
+ app.listen(PORT, () => {
181
+ console.log("MulmoClaude Twilio SMS bridge");
182
+ console.log(`Webhook listening on http://localhost:${PORT}/sms`);
183
+ console.log(`From number: ${fromNumber}`);
184
+ console.log(`Public URL: ${publicUrl ?? "(not set — signature verification OFF)"}`);
185
+ console.log(`Allowlist: ${allowAll ? "(all)" : [...allowedNumbers].join(", ")}`);
186
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mulmobridge/twilio-sms",
3
+ "version": "0.1.0",
4
+ "description": "Twilio SMS bridge for MulmoBridge — inbound SMS via webhook, outbound via Twilio REST API",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "mulmobridge-twilio-sms": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepack": "yarn build",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "echo no tests yet"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Receptron Team",
23
+ "dependencies": {
24
+ "@mulmobridge/client": "^0.1.0",
25
+ "@mulmobridge/protocol": "^0.1.0",
26
+ "dotenv": "^17.0.0",
27
+ "express": "^5.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/express": "^5.0.0",
31
+ "@types/node": "^25.6.0",
32
+ "typescript": "^6.0.3"
33
+ }
34
+ }