1// SMS channel type handler (Twilio).
2// Output: sends SMS via Twilio API.
3// Input: receives inbound SMS via Twilio webhook.
4// Config: toNumber (required for output). accountSid, authToken, fromNumber from env or per-channel.
5
6import log from "../../seed/log.js";
7import crypto from "crypto";
8
9const PHONE_RE = /^\+[1-9]\d{6,14}$/;
10
11function validateConfig(config, direction) {
12 const hasOutput = direction === "output" || direction === "input-output";
13
14 if (hasOutput) {
15 if (!config.toNumber || !PHONE_RE.test(config.toNumber)) {
16 throw new Error("SMS output requires a valid toNumber in E.164 format (e.g., +15551234567)");
17 }
18 }
19
20 const sid = config.accountSid || process.env.TWILIO_ACCOUNT_SID;
21 const token = config.authToken || process.env.TWILIO_AUTH_TOKEN;
22 const from = config.fromNumber || process.env.TWILIO_FROM_NUMBER;
23
24 if (!sid || !token || !from) {
25 throw new Error("SMS requires TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_FROM_NUMBER in .env or channel config");
26 }
27}
28
29function buildEncryptedConfig(config, direction) {
30 const secrets = {};
31 const metadata = {};
32
33 // Per-channel Twilio overrides (optional, falls back to env)
34 if (config.accountSid) secrets.accountSid = config.accountSid;
35 if (config.authToken) secrets.authToken = config.authToken;
36 if (config.fromNumber) secrets.fromNumber = config.fromNumber;
37
38 if (config.toNumber) metadata.toNumber = config.toNumber;
39
40 // Webhook verification token for inbound
41 const hasInput = direction === "input" || direction === "input-output";
42 if (hasInput) {
43 metadata.webhookSecret = crypto.randomBytes(24).toString("hex");
44 }
45
46 return {
47 secrets,
48 metadata,
49 displayIdentifier: config.toNumber || config.fromNumber || "sms",
50 };
51}
52
53function getTwilioCreds(secrets) {
54 return {
55 accountSid: secrets.accountSid || process.env.TWILIO_ACCOUNT_SID,
56 authToken: secrets.authToken || process.env.TWILIO_AUTH_TOKEN,
57 fromNumber: secrets.fromNumber || process.env.TWILIO_FROM_NUMBER,
58 };
59}
60
61async function sendSms(creds, to, body) {
62 // Twilio REST API. No SDK needed.
63 const url = `https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`;
64 const auth = Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString("base64");
65
66 const params = new URLSearchParams();
67 params.append("To", to);
68 params.append("From", creds.fromNumber);
69 params.append("Body", body);
70
71 const res = await fetch(url, {
72 method: "POST",
73 headers: {
74 "Authorization": `Basic ${auth}`,
75 "Content-Type": "application/x-www-form-urlencoded",
76 },
77 body: params.toString(),
78 });
79
80 if (!res.ok) {
81 const text = await res.text();
82 throw new Error(`Twilio error ${res.status}: ${text}`);
83 }
84}
85
86async function send(secrets, metadata, notification) {
87 const creds = getTwilioCreds(secrets);
88 const body = notification.title
89 ? `${notification.title}\n\n${notification.content}`
90 : notification.content;
91
92 // SMS max 1600 chars (Twilio auto-segments, but keep it reasonable)
93 const trimmed = body.length > 1500 ? body.slice(0, 1497) + "..." : body;
94 await sendSms(creds, metadata.toNumber, trimmed);
95}
96
97async function registerInput(channel, secrets) {
98 log.info("GatewaySMS",
99 `SMS input registered for channel ${channel._id}. ` +
100 `Webhook: POST /api/v1/gateway/sms/${channel._id}`,
101 );
102}
103
104async function unregisterInput(channel, secrets) {
105 log.verbose("GatewaySMS", `SMS input unregistered for channel ${channel._id}`);
106}
107
108// Exported for routes.js reply sending
109export { sendSms, getTwilioCreds };
110
111export default {
112 allowedDirections: ["input", "output", "input-output"],
113 validateConfig,
114 buildEncryptedConfig,
115 send,
116 registerInput,
117 unregisterInput,
118};
119
1import log from "../../seed/log.js";
2import handler from "./handler.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6 const gateway = getExtension("gateway");
7 if (!gateway?.exports?.registerChannelType) {
8 throw new Error("gateway-sms requires the gateway extension to be loaded first");
9 }
10
11 gateway.exports.registerChannelType("sms", handler);
12 log.verbose("GatewaySMS", "Registered SMS channel type");
13
14 const { default: router } = await import("./routes.js");
15
16 return { router };
17}
18
1export default {
2 name: "gateway-sms",
3 version: "1.0.1",
4 builtFor: "treeos-connect",
5 description:
6 "SMS channel type for the gateway extension. Registers the sms channel type at boot, enabling " +
7 "trees to send and receive text messages through Twilio. No Twilio SDK required. The handler " +
8 "calls the Twilio REST API directly using basic auth, sending form-encoded POST requests to " +
9 "the Messages endpoint. Outbound messages are composed from notification title and content, " +
10 "trimmed to 1500 characters to stay within Twilio's segmentation limits.\n\n" +
11 "Three directions are supported: input (receive SMS into a tree), output (send SMS from a " +
12 "tree), and input-output (bidirectional conversation). For output channels, a toNumber in " +
13 "E.164 format is required. Twilio credentials (account SID, auth token, from number) can " +
14 "be set globally via environment variables or overridden per channel through encrypted config. " +
15 "Per-channel secrets are stored using the gateway core's AES encryption, so one land can run " +
16 "multiple Twilio accounts across different trees.\n\n" +
17 "Inbound SMS arrives via a Twilio webhook at POST /api/v1/gateway/sms/:channelId. The endpoint " +
18 "responds immediately with empty TwiML to prevent Twilio retries, then processes the message " +
19 "asynchronously. Twilio sends form-encoded data including From, Body, and MessageSid. The " +
20 "handler validates the channel exists and is enabled, extracts the sender phone number and " +
21 "message text, then delegates to the gateway core's processGatewayMessage function which runs " +
22 "the message through the tree's AI in the configured mode. For input-output channels, the AI " +
23 "reply is sent back as an SMS to the sender's phone number. Trees in your pocket without an app.",
24
25 needs: {
26 extensions: ["gateway"],
27 },
28
29 optional: {},
30
31 provides: {
32 models: {},
33 routes: false,
34 tools: false,
35 jobs: false,
36 orchestrator: false,
37 energyActions: {},
38 sessionTypes: {},
39 env: [
40 { key: "TWILIO_ACCOUNT_SID", required: false, description: "Twilio Account SID" },
41 { key: "TWILIO_AUTH_TOKEN", required: false, secret: true, description: "Twilio Auth Token" },
42 { key: "TWILIO_FROM_NUMBER", required: false, description: "Twilio phone number to send from (e.g., +15551234567)" },
43 ],
44 cli: [],
45 },
46};
47
1// Twilio inbound SMS webhook receiver.
2// No auth middleware. Twilio calls this directly.
3// Twilio sends form-encoded POST: From, To, Body, MessageSid, etc.
4// Webhook URL: POST /api/v1/gateway/sms/:channelId
5// Configure in Twilio console: Messaging > Phone Number > Webhook URL
6
7import log from "../../seed/log.js";
8import { sendOk } from "../../seed/protocol.js";
9import express from "express";
10
11const router = express.Router();
12
13router.use("/gateway/sms/:channelId", express.urlencoded({ extended: true, limit: "64kb" }));
14
15router.post("/gateway/sms/:channelId", async (req, res) => {
16 // Respond with empty TwiML (Twilio expects XML, but empty 200 works too)
17 res.type("text/xml").send("<Response></Response>");
18
19 try {
20 const channelId = req.params.channelId;
21 const body = req.body;
22
23 // Twilio sends: From, To, Body, MessageSid, AccountSid, NumMedia, etc.
24 if (!body || !body.Body || !body.From) return;
25
26 const { getExtension } = await import("../loader.js");
27 const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
28 const channel = await GatewayChannel.findById(channelId).lean();
29 if (!channel || !channel.enabled) return;
30 if (channel.type !== "sms") return;
31
32 const hasInput = channel.direction === "input" || channel.direction === "input-output";
33 if (!hasInput) return;
34
35 // Twilio request validation (optional but recommended)
36 // If TWILIO_AUTH_TOKEN is set, verify the X-Twilio-Signature header
37 // For now, channel-level verification via the unique webhook URL is sufficient.
38
39 const senderName = body.From; // phone number
40 const senderPlatformId = body.From;
41 const messageText = body.Body.trim();
42
43 if (!messageText) return;
44
45 log.verbose("GatewaySMS",
46 `SMS on channel ${channelId} from ${senderName}: "${messageText.slice(0, 80)}"`,
47 );
48
49 // Process via gateway core
50 const gateway = getExtension("gateway");
51 if (!gateway?.exports?.processGatewayMessage) {
52 log.error("GatewaySMS", "Gateway core not loaded");
53 return;
54 }
55
56 const result = await gateway.exports.processGatewayMessage(channelId, {
57 senderName,
58 senderPlatformId,
59 messageText,
60 });
61
62 // Reply via SMS if input-output and there's a reply
63 if (result.reply && channel.direction === "input-output") {
64 const { sendSms, getTwilioCreds } = await import("./handler.js");
65 const fullChannel = await gateway.exports.getChannelWithSecrets(channel._id);
66 const secrets = fullChannel?.config?.decryptedSecrets || {};
67 const creds = getTwilioCreds(secrets);
68
69 const reply = result.reply.length > 1500
70 ? result.reply.slice(0, 1497) + "..."
71 : result.reply;
72
73 await sendSms(creds, body.From, reply);
74 }
75 } catch (err) {
76 log.error("GatewaySMS",
77 `SMS webhook error for channel ${req.params.channelId}:`,
78 err.message,
79 );
80 }
81});
82
83export default router;
84
Loading comments...