用 Twilio + Node.js + Cloudflare Tunnel 搭一套“群发短信/MMS 转发器”(ARM64 服务器实战)

zach Posted on 2025-03-17 67 Views HackMech


场景:任意成员把短信发到你的 Twilio 号码,服务器会把这条消息(可含图片/MMS)自动转发给群里的其他成员,且在正文前加上发送者名字
架构:Twilio → Webhook(Express/Node.js) → Cloudflare Tunnel → 你的域名,并用 PM2 常驻进程。
设备:ARM64 服务器(Ubuntu 24.04 等)


目录

  1. 准备工作
  2. 安装与目录结构
  3. 完整代码(已支持图片/MMS)
  4. Cloudflare Tunnel 与域名
  5. SystemD配置:
  6. Twilio 控制台配置
  7. 使用 PM2 部署为常驻服务
  8. 自检与联调
  9. 常见问题与排错清单(11200、20003、300xx、端口占用等)
  10. 合规与小贴士(A2P 10DLC、STOP/START、隐私)

1) 准备工作

  • 一个 Twilio 账号与可发短信/MMS 的号码(支持区域、支持 MMS 很关键)
  • 一个 Cloudflare 账号,你的域名托管在 Cloudflare(示例:info.example.com
  • 一台 ARM64 服务器(Ubuntu 20.04+/24.04 推荐)
  • Node.js 18+npmPM2cloudflared

不要把你的 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 控制台配置

  1. 进入 Phone Numbers → Active numbers → 你的号码
  2. Messaging → A MESSAGE COMES IN
    • Webhookhttps://info.example.com/sms
    • HTTP MethodHTTP POST
  3. 保存。

升级为正式账户后才不会再自动回“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_SIDTWILIO_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 负责守护;故障日志一目了然。