场景:任意成员把短信发到你的 Twilio 号码,服务器会把这条消息(可含图片/MMS)自动转发给群里的其他成员,且在正文前加上发送者名字。
架构:Twilio → Webhook(Express/Node.js) → Cloudflare Tunnel → 你的域名,并用 PM2 常驻进程。
设备:ARM64 服务器(Ubuntu 24.04 等)
目录
- 准备工作
- 安装与目录结构
- 完整代码(已支持图片/MMS)
- Cloudflare Tunnel 与域名
- SystemD配置:
- Twilio 控制台配置
- 使用 PM2 部署为常驻服务
- 自检与联调
- 常见问题与排错清单(11200、20003、300xx、端口占用等)
- 合规与小贴士(A2P 10DLC、STOP/START、隐私)
1) 准备工作
- 一个 Twilio 账号与可发短信/MMS 的号码(支持区域、支持 MMS 很关键)
- 一个 Cloudflare 账号,你的域名托管在 Cloudflare(示例:
info.example.com
) - 一台 ARM64 服务器(Ubuntu 20.04+/24.04 推荐)
- Node.js 18+、npm、PM2、cloudflared
不要把你的 Twilio
AUTH_TOKEN
放进代码仓库或博客! 在本文示例里用占位符代替。
2) 安装与目录结构
# 安装必要软件
sudo apt update
sudo apt install -y curl build-essential nodejs=18* npm pm2
# Node.js(官方或 nvm 均可,这里略)
# PM2
sudo npm i -g pm2
# Cloudflare Tunnel
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
# 新建项目
mkdir -p ~/.cloudflared/sms-broadcast && cd ~/.cloudflared/sms-broadcast
# 初始化
npm init -y
npm i express body-parser twilio dotenv axios
# 预备静态目录用于重新托管图片
mkdir public
目录大致如下:
~/.cloudflared/sms-broadcast
├─ server.js
├─ .env
└─ public/ # 服务器重新托管的图片会放在这里
3) 完整代码(已支持图片/MMS)
在 server.js
写入(一字不漏可用):
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const twilio = require('twilio');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const app = express();
const port = process.env.WEBHOOK_PORT || 3000;
const publicBaseUrl = process.env.PUBLIC_BASE_URL; // 例如:https://info.example.com
// 解析 application/x-www-form-urlencoded(Twilio Webhook 默认)
app.use(bodyParser.urlencoded({ extended: false }));
// 确保 public 目录存在,并暴露为静态资源
const publicDir = path.join(__dirname, 'public');
fs.mkdirSync(publicDir, { recursive: true });
app.use('/public', express.static(publicDir));
// Twilio 客户端
const client = new twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
/**
* 维护群成员(E.164 格式:+国家码+号码)
* “发消息的人”会被自动排除,不给自己转发
*/
const groupMembers = [
'+11234567890', // Zach
'+10987654321' // Bob
];
/** 发件人号码 -> 显示昵称 */
const userNames = {
'+11234567890': 'Zach',
'+10987654321': 'Bob'
};
// 健康检查/联调用:GET https://你的域名/sms
app.get('/sms', (_, res) => res.send('Twilio Webhook is running.'));
/** 下载 Twilio 媒体到本地并返回公网可访问 URL */
async function downloadAndRehost(mediaUrl) {
const fileName = `m_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`;
const filePath = path.join(publicDir, fileName);
const response = await axios({
url: mediaUrl,
method: 'GET',
responseType: 'stream',
// Twilio 的媒体 URL 需要基本认证
auth: {
username: process.env.TWILIO_ACCOUNT_SID,
password: process.env.TWILIO_AUTH_TOKEN
}
});
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(filePath);
response.data.pipe(writer);
writer.on('finish', resolve);
writer.on('error', reject);
});
if (!publicBaseUrl) {
throw new Error('PUBLIC_BASE_URL 未配置,无法拼接公网图片 URL');
}
return `${publicBaseUrl}/public/${fileName}`;
}
/** Twilio Webhook:入站短信/MMS */
app.post('/sms', async (req, res) => {
const senderNumber = req.body.From;
const rawBody = req.body.Body || '';
const messageBody = rawBody.trim();
const numMedia = parseInt(req.body.NumMedia || '0', 10);
console.log(`📩 来自 ${senderNumber}: ${messageBody} (NumMedia=${numMedia})`);
// 合规:不要转发 STOP/UNSUBSCRIBE/HELP 等关键词(Twilio 会自动处理封禁)
if (/^(stop|stopall|unsubscribe|cancel|end|quit)$/i.test(messageBody)) {
console.log('⏹️ 接收到 STOP 类关键词,跳过群发');
return res.status(200).send('OK');
}
const senderName = userNames[senderNumber] || senderNumber.replace('+', '');
const formattedMessage = `${senderName}:${messageBody}`;
// 处理媒体(多图)
const mediaUrls = [];
try {
for (let i = 0; i < numMedia; i++) {
const key = `MediaUrl${i}`;
const mediaUrl = req.body[key];
if (mediaUrl && mediaUrl.startsWith('http')) {
const publicUrl = await downloadAndRehost(mediaUrl);
mediaUrls.push(publicUrl);
console.log(`🖼️ 已托管: ${publicUrl}`);
}
}
} catch (err) {
console.error('❌ 图片下载/托管失败:', err.message);
// 可以选择继续发送纯文本,或直接返回
}
// 群发(排除自己)
for (const member of groupMembers) {
if (member === senderNumber) continue;
try {
const payload = {
body: formattedMessage,
from: process.env.TWILIO_PHONE_NUMBER,
to: member
};
if (mediaUrls.length > 0) payload.mediaUrl = mediaUrls;
console.log(`📤 正在发送到 ${member} ...`);
const resp = await client.messages.create(payload);
console.log(`✅ 发送成功: ${resp.sid} -> ${member}`);
} catch (e) {
console.error(`❌ 发送失败 -> ${member}: ${e.message}`);
}
}
// 告诉 Twilio Webhook 已处理
res.status(200).send('Message processed.');
});
// 启动服务
app.listen(port, () => {
console.log(`🚀 Webhook 端口 ${port} 已启动`);
});
.env
示例(请用你自己的值)
切勿把真实密钥贴到公共场所!
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_PHONE_NUMBER=+1XXXXXXXXXX
WEBHOOK_PORT=3000
PUBLIC_BASE_URL=https://info.example.com
4) Cloudflare Tunnel 与域名
常用命令:
# 登录并创建 Tunnel(若已创建可略过)
cloudflared tunnel login
cloudflared tunnel create sms-tunnel
cloudflared tunnel route dns sms-tunnel info.example.com
~/.cloudflared/config.yml
(修改 Tunnel ID):
tunnel: <你的 Tunnel ID>
credentials-file: /home/ubuntu/.cloudflared/<你的TunnelID>.json
ingress:
- hostname: info.example.com
service: http://localhost:3000
- service: http_status:404
# 运行 (testing only, we can control-C later)
cloudflared tunnel run sms-tunnel
你也可以把 cloudflared 配成 systemd 服务,这里从简。
5) SystemD配置:
sudo nano /etc/systemd/system/cloudflared.service
[Unit]
Description=Cloudflare Tunnel
After=network.target
[Service]
ExecStart=/usr/local/bin/cloudflared tunnel --config /home/ubuntu/.cloudflared/config.yml run
Restart=always
User=ubuntu
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start cloudflared
sudo systemctl enable cloudflared
6) Twilio 控制台配置
- 进入 Phone Numbers → Active numbers → 你的号码
- 在 Messaging → A MESSAGE COMES IN:
- Webhook:
https://info.example.com/sms
- HTTP Method:
HTTP POST
- Webhook:
- 保存。
升级为正式账户后才不会再自动回“Sent from your Twilio trial account...”。
7) 用 PM2 部署为常驻服务
# 在项目目录 ~/.cloudflared/sms-broadcast 下:
pm2 start server.js --name twilio-sms
pm2 save
pm2 startup # 按提示执行一条 sudo 命令,开机自启
常用:
pm2 logs twilio-sms
pm2 restart twilio-sms
pm2 stop twilio-sms
更多常用命令:
pm2 list
---
## **🚀 1. PM2 安装**
### **1️⃣ 安装 PM2**
```sh
npm install -g pm2
```
### **2️⃣ 检查 PM2 是否安装成功**
```sh
pm2 -v
```
✅ **如果返回版本号,说明安装成功**
---
## **🛠 2. 使用 PM2 启动进程**
### **1️⃣ 启动 Node.js 服务器**
```sh
pm2 start server.js --name twilio-sms
```
📌 **参数说明**
- `server.js` → 你要运行的 **Node.js 服务器文件**
- `--name twilio-sms` → 给进程起一个名字,方便管理
---
### **2️⃣ 查看运行中的进程**
```sh
pm2 list
```
✅ **返回示例**
```
┌────┬───────────────┬──────┬───────┬───────────┬─────────┐
│ id │ name │ mode │ status│ cpu │ memory │
├────┼───────────────┼──────┼───────┼───────────┼─────────┤
│ 0 │ twilio-sms │ fork │ online│ 0% │ 20MB │
└────┴───────────────┴──────┴───────┴───────────┴─────────┘
```
---
### **3️⃣ 查看进程详细信息**
```sh
pm2 show twilio-sms
```
📌 **会显示 CPU 占用、日志路径、进程 ID 等信息**
---
## **🛠 3. 管理 PM2 进程**
### **1️⃣ 停止进程**
```sh
pm2 stop twilio-sms
```
✅ **停止运行 `twilio-sms` 进程**
---
### **2️⃣ 重新启动进程**
```sh
pm2 restart twilio-sms
```
✅ **用于更新代码后重新运行**
---
### **3️⃣ 删除进程**
```sh
pm2 delete twilio-sms
```
✅ **彻底删除 `twilio-sms` 进程**
---
## **📌 4. 监控 & 日志管理**
### **1️⃣ 监控所有进程**
```sh
pm2 monit
```
✅ **实时显示 CPU、内存使用情况**
---
### **2️⃣ 查看日志**
```sh
pm2 logs twilio-sms
```
✅ **实时查看 `twilio-sms` 进程的日志**
---
### **3️⃣ 仅显示最新 100 行日志**
```sh
pm2 logs twilio-sms --lines 100
```
✅ **快速查看最近的日志**
---
## **🛠 5. 开机自启**
### **1️⃣ 设置 PM2 开机自启**
```sh
pm2 startup
```
---
### **2️⃣ 保存当前所有进程**
```sh
pm2 save
```
✅ **这样所有进程会在系统重启后自动恢复**
---
## **🛠 6. 其他常用命令**
### **1️⃣ 重新启动所有 PM2 进程**
```sh
pm2 restart all
```
✅ **适用于所有进程的更新重启**
---
### **2️⃣ 停止所有进程**
```sh
pm2 stop all
```
✅ **暂停所有 PM2 管理的应用**
---
### **3️⃣ 删除所有进程**
```sh
pm2 delete all
```
✅ **删除所有正在运行的进程**
---
### **4️⃣ 强制重启进程**
```sh
pm2 reload twilio-sms
```
✅ **比 `restart` 更安全,适用于零宕机重启**
---
### **5️⃣ 彻底清除 PM2 进程数据**
```sh
pm2 kill
```
✅ **会清空 PM2 管理的所有进程,并需要重新启动**
8) 自检与联调
健康检查
curl -s https://info.example.com/sms
# 预期:Twilio Webhook is running.
端口占用
sudo lsof -i :3000
# 如果被占用,结束相应进程或改端口
Twilio → 服务器是否通?
- 从手机给 Twilio 号码发短信,
pm2 logs twilio-sms
里应看到:📩 来自 +1xxx: hello (NumMedia=0) 📤 正在发送到: +1yyy ✅ 发送成功: SMxxxxxxxx -> +1yyy
图片/MMS 测试
- 发一张图到 Twilio 号码,日志应出现:
🖼️ 已托管: https://info.example.com/public/m_....jpg
- 群成员应能收到携带该图片的短信。
9) 常见问题与排错清单
11200(HTTP retrieval failure)
- 症状:转发 MMS 报 11200
- 原因:Twilio 无法抓取
mediaUrl
(需要认证/被防火墙/URL 不可达) - 解决:本文代码已改为先用账户 SID/TOKEN 拉取 Twilio 媒体,再在本站
/public
重新托管,从而给群成员发送公网可访问的 URL。
20003(Authentication Error)
- 症状:curl/代码访问 Twilio 媒体 401/认证失败
- 检查:
TWILIO_ACCOUNT_SID
、TWILIO_AUTH_TOKEN
是否最新且正确;shell 中echo $TWILIO_...
是否显示到值;.env
是否被加载。 - 刷新:Twilio Console 里重置/复制最新 Token,重启 PM2。
部分成员收不到
- 检查
groupMembers
是否包含其号码(E.164 格式) - Twilio 控制台 SMS Logs 看状态:
Delivered / Undelivered / Failed
- 运营商拦截:让对方回复 START 解除屏蔽
- 美国业务量发信请留意 A2P 10DLC 注册(Twilio Console → Messaging → Compliance)
EADDRINUSE 端口占用
sudo lsof -i :3000
sudo kill -9 <pid>
pm2 restart twilio-sms
“Cannot GET /sms”
- 你访问的是 GET,本文已提供
app.get('/sms')
健康检查; - Twilio Webhook 是 POST,在控制台要填 POST。
10) 合规与小贴士
- STOP/UNSUBSCRIBE/HELP:Twilio 会自动处理屏蔽,服务器端最好不要继续广播(本文已跳过 STOP 类关键词)。
- A2P 10DLC(美国):企业/应用场景强烈建议完成合规注册,提高到达率、降低被拦截。
- 隐私与安全:
.env
不要提交;日志避免打印敏感数据;public 目录仅用于展示媒体。 - 媒体时效:Twilio 原始媒体 URL 有时效,重新托管可避免失效问题。
- 号码格式:全部使用 E.164(如
+1XXXXXXXXXX
)。
结语
到这里,你已经拥有一套稳定可用的“群发短信/MMS 转发”后端:
- 成员发给 Twilio → 你的服务加上昵称 → 群内其它成员全部收到(含图片)。
- Cloudflare Tunnel 让你无需暴露服务器公网端口;PM2 负责守护;故障日志一目了然。
Comments NOTHING