Compare commits

..
19 Commits
Author SHA1 Message Date
wuxu 1bed4c73eb docs: 补充 ML307A USB 识别说明 2026-06-28 08:51:25 +08:00
wuxu 78f7a5f46e feat: 调整 SIM 切换入口 2026-06-27 21:56:41 +08:00
wuxu 05a4fd960d fix: 修复单 SIM 卡逻辑 2026-06-27 21:56:41 +08:00
wuxu ea6afcc08c docs: 补充配置说明 2026-06-27 21:56:41 +08:00
wuxu dd4a156b9c refactor: 缩减 Docker 镜像体积 2026-06-27 16:57:48 +08:00
wuxu e03a7a141c feat: 取消 Web 挑战头鉴权 2026-06-27 16:57:48 +08:00
wuxu ecca396792 feat: 添加双卡支持 2026-06-27 16:57:48 +08:00
wuxu 57ca2f1ea3 fix: 调整 ICCID 查询方式 2026-06-27 09:31:09 +08:00
wuxu c395c3fc4e feat: 按 SIM ICCID 隔离短信 2026-06-27 09:27:16 +08:00
wuxu d8ef61adec feat: 添加低流量保活 2026-06-27 09:16:25 +08:00
wuxu 5d7ca83162 fix: 优化断网处理 2026-06-27 09:10:01 +08:00
wuxu 993b5ee4c0 refactor: 重构前端 UI 2026-06-27 08:42:27 +08:00
wuxu ae27984fb0 feat: 添加移动数据控制 2026-06-26 16:48:40 +08:00
wuxu 4aa93d8a9d feat: 添加串口自动探测 2026-06-26 16:34:27 +08:00
wuxu d1a87556c5 feat: 添加 Docker 部署和短信持久化 2026-06-26 16:29:19 +08:00
wuxu 557049b308 feat: 支持选择推送通道类型 2026-06-24 23:34:33 +08:00
wuxu 15b15b79c9 feat: 添加推送通道管理 2026-06-24 22:39:12 +08:00
wuxu deafe6b1c4 feat: 添加 Web 管理台 2026-06-24 22:24:43 +08:00
wuxu db3c32a871 feat: 初始化短信网关核心 2026-06-24 22:24:43 +08:00
13 changed files with 4342 additions and 706 deletions
+5 -14
View File
@@ -1,34 +1,25 @@
FROM node:20-bookworm-slim AS deps FROM alpine:3.22 AS deps
ENV NODE_ENV=production \ ENV NODE_ENV=production \
DEBIAN_FRONTEND=noninteractive \
NPM_CONFIG_AUDIT=false \ NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false \ NPM_CONFIG_FUND=false \
NPM_CONFIG_UPDATE_NOTIFIER=false NPM_CONFIG_UPDATE_NOTIFIER=false
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apk add --no-cache nodejs npm python3 make g++
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev \ RUN npm ci --omit=dev \
&& npm cache clean --force && npm cache clean --force
FROM node:20-bookworm-slim AS runtime FROM alpine:3.22 AS runtime
ENV NODE_ENV=production \ ENV NODE_ENV=production
DEBIAN_FRONTEND=noninteractive \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false \
NPM_CONFIG_UPDATE_NOTIFIER=false
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apk add --no-cache nodejs eudev
&& apt-get install -y --no-install-recommends udev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
+383
View File
@@ -0,0 +1,383 @@
# SMS Forwarding
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。
## 功能
- 通过 AT 指令和 4G 模组通信
- 自动探测可用 AT 串口
- PDU 模式接收短信
- 长短信自动合并
- 收件箱持久化到本地文件
- 多推送通道转发短信
- Web 管理台
- Token 鉴权的 HTTP API
- Web/API 发送短信
- 双 SIM 状态识别和发送前切卡
- 移动数据开关和启动断网保护
- AT 命令调试入口
## 环境要求
- Node.js 18 或更高版本
- 可通过串口访问的 4G 模组
- 已插入可收发短信的 SIM 卡
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。
## 快速启动
```bash
npm install
cp config.example.json config.json
```
编辑 `config.json`,至少确认:
```json
{
"serial": {
"path": "/dev/ttyUSB0",
"baudRate": 115200,
"autoDetect": true
},
"api": {
"port": 3000,
"webToken": "change-this-token"
}
}
```
启动:
```bash
npm start
```
访问管理台:
```text
http://localhost:3000/admin
```
第一次访问会要求输入 `api.webToken`。也可以临时通过 URL 传入:
```text
http://localhost:3000/admin?token=change-this-token
```
## Docker 运行
先准备配置:
```bash
cp config.example.json config.json
```
然后启动:
```bash
docker compose up --build -d
```
查看日志:
```bash
docker compose logs -f sms-gateway
```
`docker-compose.yml` 会挂载:
- `./config.json:/app/config.json`
- `./logs:/app/logs`
- `./data:/app/data`
- `/dev:/dev`
- `/sys:/sys:ro`
默认端口是 `3000`。如需修改宿主机端口:
```bash
SMS_GATEWAY_PORT=8080 docker compose up -d
```
## PM2 运行
```bash
npm install
cp config.example.json config.json
pm2 start ecosystem.config.js
pm2 save
```
## 配置
`config.json` 必须存在,否则程序会退出。完整模板见 `config.example.json`。
### 必填项
| 配置项 | 说明 |
| --- | --- |
| `serial.path` | 串口路径。Linux 常见为 `/dev/ttyUSB0`,Windows 可写 `COM3`。`serial.autoDetect=true` 时它是优先探测项;`false` 时它就是实际打开的串口。 |
| `serial.baudRate` | 串口波特率,通常是 `115200`。 |
| `api.port` | Web 管理台和 HTTP API 监听端口。 |
| `api.webToken` | 唯一访问凭据。项目没有默认账号密码,也没有 `admin/admin123` Basic Auth。 |
### 可选项
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| `serial.autoDetect` | `true` | 是否自动探测可响应 `AT` 的串口。 |
| `serial.probeTimeout` | `1200` | 单个串口探测超时,单位毫秒。 |
| `mobileData.cid` | `1` | 移动数据 PDP CID。有效范围 `1-15`,不要使用 `8`。 |
| `simCards` | `[]` | 只影响 Web 显示的 SIM 手机号或标签,不影响实际收发。`slot=0` 是 SIM1,`slot=1` 是 SIM2。 |
| `smtp` | 未启用 | 配置后会额外发送邮件通知。填写 `smtp.server` 后需要同时正确填写 `port`、`user`、`pass`、`sendTo`。 |
| `inbox.maxReceivedMessages` | `200` | 收件箱最多保留条数,代码上限是 `5000`。 |
| `inbox.receivedMessagesFile` | `data/received-messages.json` | 收件箱持久化文件。相对路径按项目根目录解析。 |
| `inbox.partitionBySim` | `true` | 查询收件箱时是否按 SIM 信息隔离。 |
| `inbox.allowAllSimMessages` | `false` | 是否允许显式查询全部 SIM 历史。 |
| `inbox.dualSimDefaultScope` | `all` | 双卡场景下默认收件箱范围,可选 `all` 或 `current`。 |
| `pushChannels` | `[]` | 推送通道列表。通道 `enabled=true` 时必须填写该类型要求的目标字段。 |
`api.webToken` 支持以下传入方式:
- `Authorization: Bearer <token>`
- `X-Web-Token: <token>`
- `?token=<token>`
- Web 管理台登录页
## 推送通道
支持的 `pushChannels[].type`:
| 类型 | 必填字段 |
| --- | --- |
| `dingtalk` | `url`,`secret` 可选 |
| `feishu` | `url`,`secret` 可选 |
| `telegram` | `url` 填 Bot Token,`key1` 填 Chat ID |
| `pushplus` | `key1` 填 PushPlus Token |
| `serverchan` | `key1` 填 Server 酱 SendKey |
| `bark` | `url` |
| `post_json` | `url` |
| `get` | `url` |
| `custom` | `url`,`customBody` 可选 |
`customBody` 必须是合法 JSON 字符串,支持以下占位符:
- `{sender}`
- `{message}`
- `{timestamp}`
`enabled=false` 的通道可以保留空字段;`enabled=true` 时会校验目标字段。
## SIM 行为
启动初始化时程序会探测 SIM 状态。运行期间的普通状态刷新不会为了探测卡槽而持续切换 SIM。
发送短信时使用当前绑定的发送 SIM。Web 管理台的切卡入口放在“发送短信”区域,切换会同时设置模组的 `SWITCHSIM` 和 `BINDSIM`,然后恢复短信直出配置。
短信接收使用 `CNMI` 直出模式。收到短信时,如果模组 URC 携带卡槽信息,会记录为 SIM1 或 SIM2;如果 URC 没有卡槽信息,收件箱会显示为未知 SIM。
如果只想给管理台显示手机号,可配置:
```json
{
"simCards": [
{ "slot": 0, "phoneNumber": "+8613800138000" },
{ "slot": 1, "phoneNumber": "+447700900000" }
]
}
```
## HTTP API
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`:
```bash
TOKEN=change-this-token
BASE=http://localhost:3000
```
### 状态和日志
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/status"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/info"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/signal"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/logs"
```
### 收件箱
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&scope=current"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=0"
curl -H "X-Web-Token: $TOKEN" "$BASE/api/sms/received?limit=50&simSlot=1"
```
### 发送短信
```bash
curl -X POST "$BASE/api/sms/send" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"phone":"13800138000","message":"测试短信"}'
```
### SIM 状态和切换
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/sim"
curl -X POST "$BASE/api/modem/sim/switch" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"slot":1}'
```
`slot=0` 表示 SIM1,`slot=1` 表示 SIM2。
### 移动数据
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/modem/mobile-data"
curl -X POST "$BASE/api/modem/mobile-data" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"enabled":false}'
curl -X POST "$BASE/api/modem/mobile-data/consume" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"target":"8.8.8.8"}'
```
### AT 命令
```bash
curl -X POST "$BASE/api/modem/at" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"command":"AT+CSQ"}'
```
### 推送通道管理
```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/push/channels"
curl -X PUT "$BASE/api/push/channels" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"channels":[]}'
curl -X POST "$BASE/api/push/test" \
-H "Content-Type: application/json" \
-H "X-Web-Token: $TOKEN" \
-d '{"enabled":true,"type":"post_json","name":"测试","url":"https://example.com/webhook"}'
```
## 目录结构
```text
.
├── src/
│ ├── index.js # 程序入口
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录
│ ├── concat.js # 长短信分段合并
│ ├── push.js # 推送通道
│ ├── api.js # Web 管理台和 HTTP API
│ └── logger.js # 日志
├── public/ # Web 管理台静态文件
├── data/ # 收件箱持久化数据
├── logs/ # 运行日志
├── config.example.json
├── config.json # 本地配置,不应提交
├── docker-compose.yml
├── Dockerfile
└── ecosystem.config.js
```
## 常见问题
### 串口打不开
Linux 下先确认设备存在:
```bash
ls -l /dev/ttyUSB*
```
如果是权限问题,把运行用户加入串口权限组,重新登录后生效:
```bash
sudo usermod -a -G dialout "$USER"
```
Docker 场景下确认 `docker-compose.yml` 已挂载 `/dev`,并保留了 `device_cgroup_rules`。
### 自动探测不到模组
- 确认 USB 连接和供电。
- 确认 `serial.baudRate` 正确。
- 临时关闭自动探测,把 `serial.autoDetect` 设为 `false`,并把 `serial.path` 写成真实 AT 串口。
- 查看日志中每个候选串口的探测结果。
### ML307A 首次上电被识别成网卡
ML307A 首次上电或恢复默认 USB 模式时,系统可能先把它识别为 RNDIS 网卡,而不是 USB 串口。此时 `lsusb` 能看到设备,但 `/dev/ttyUSB*` 里找不到可用 AT 串口。
可以用下面命令确认 USB 设备和内核识别结果:
```bash
lsusb
dmesg | grep -E 'ML307A|2ecc|3012|rndis|ttyUSB'
```
典型日志会包含类似内容:
```text
usb 1-3: New USB device found, idVendor=2ecc, idProduct=3012
usb 1-3: Product: ML307A
usb 1-3: Manufacturer: CMIOT
rndis_host 1-3:1.0: rndis media connect
rndis_host 1-3:1.0 enxac0c29a39b6d: renamed from eth0
```
如果出现上述 RNDIS 网卡日志,并且系统没有生成 USB 串口,可以把该 USB ID 添加到 `option` 串口驱动:
```bash
sudo modprobe option
echo 2ecc 3012 | sudo tee /sys/bus/usb-serial/drivers/option1/new_id
```
执行后重新查看 `/dev/ttyUSB*`,再把 `config.json` 里的 `serial.path` 指向实际 AT 串口。不同内核、发行版或模组固件的枚举行为可能不完全一致;如果后续仍会被识别为网卡,可按系统发行版方式配置 udev/modprobe 规则。
### 收不到短信
- 确认 SIM 卡已插好且可以注册网络。
- 查看 `/api/status` 里的 `ready`、`operator`、`signal`。
- 确认日志里有 `PDU模式设置完成` 和 `CNMI参数设置完成`。
- 部分模组不会在 URC 里带卡槽信息,这只影响收件箱显示的 SIM 归属,不代表短信没有收到。
### 发送短信失败
- 先在管理台确认当前发送 SIM。
- 确认目标号码格式正确。
- 查看日志里的 AT 响应。
- 如果刚切换 SIM,等待模组重新注册网络后再发。
## 安全说明
- 不要提交 `config.json`、token、手机号、Webhook URL、SMTP 密码和日志。
- 管理台和 API 只使用 `api.webToken` 鉴权。
- `POST /api/modem/at` 可以执行任意 AT 命令,只应暴露在可信网络内。
## License
MIT
+29 -10
View File
@@ -2,20 +2,42 @@
"serial": { "serial": {
"path": "COM3", "path": "COM3",
"baudRate": 115200, "baudRate": 115200,
"_comment": "Windows使用COM1/COM2/COM3等,也可以直接写数字如3; Linux使用/dev/ttyUSB0" "autoDetect": true,
"probeTimeout": 1200,
"_comment": "必填。path是手动串口或自动探测优先项;autoDetect=false时必须写真实串口。Windows使用COM1/COM2/COM3等,也可以直接写数字如3;Linux使用/dev/ttyUSB0。baudRate通常为115200,probeTimeout为AT探测超时毫秒。"
}, },
"mobileData": {
"cid": 1,
"_comment": "可选,默认cid=1。有效范围1-15,cid8为IMS专用,请勿使用。ML307每次启动都会强制断开MIPCALL应用层拨号,并保护性尝试CGACT;管理台开关只影响当前运行期。"
},
"_simCards_comment": "可选。用于给SIM1/SIM2显示手机号;slot=0是SIM1,slot=1是SIM2。也兼容旧字段名simSlots、sims、sim。",
"simCards": [
{
"slot": 0,
"phoneNumber": "+8613800138000"
},
{
"slot": 1,
"phoneNumber": "+447700900000"
}
],
"smtp": { "smtp": {
"_comment": "可选。填写server后会启用邮件通知;启用时server、port、user、pass、sendTo都需要正确填写。不需要邮件可删除smtp或清空server。",
"server": "smtp.qq.com", "server": "smtp.qq.com",
"port": 465, "port": 465,
"user": "your@qq.com", "user": "your@qq.com",
"pass": "your_auth_code", "pass": "your_auth_code",
"sendTo": "recipient@example.com" "sendTo": "recipient@example.com"
}, },
"adminPhone": "13800138000", "inbox": {
"numberBlackList": [ "maxReceivedMessages": 200,
"10086", "receivedMessagesFile": "data/received-messages.json",
"10010" "partitionBySim": true,
], "allowAllSimMessages": false,
"dualSimDefaultScope": "all",
"_comment": "全部可选。maxReceivedMessages默认200且最大5000;receivedMessagesFile默认data/received-messages.json;partitionBySim默认true;allowAllSimMessages默认false;dualSimDefaultScope可为all或current。默认显示全部短信;直出URC未携带卡槽时,新短信不区分SIM1/SIM2收件归属。"
},
"_pushChannels_comment": "可选。enabled=false时目标字段可以为空;enabled=true时按type要求填写目标:post_json/bark/get/dingtalk/custom/feishu需要url,telegram需要url(bot token)+key1(chat id),pushplus需要key1(token),serverchan需要key1(sendkey)。customBody如果填写必须是合法JSON字符串。",
"pushChannels": [ "pushChannels": [
{ {
"enabled": true, "enabled": true,
@@ -71,9 +93,6 @@
"api": { "api": {
"port": 3000, "port": 3000,
"webToken": "change-this-token", "webToken": "change-this-token",
"auth": { "_comment": "必填。port是API和管理台监听端口;webToken是唯一鉴权凭据,可通过Authorization: Bearer、X-Web-Token、token查询参数或管理台登录页提交。"
"username": "admin",
"password": "admin123"
}
} }
} }
-1
View File
@@ -13,7 +13,6 @@
"axios": "^1.6.2", "axios": "^1.6.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"node-pdu": "^2.1.2", "node-pdu": "^2.1.2",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
"serialport": "^12.0.0", "serialport": "^12.0.0",
-1
View File
@@ -23,7 +23,6 @@
"@serialport/parser-readline": "^12.0.0", "@serialport/parser-readline": "^12.0.0",
"node-pdu": "^2.1.2", "node-pdu": "^2.1.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
"axios": "^1.6.2", "axios": "^1.6.2",
"winston": "^3.11.0", "winston": "^3.11.0",
+233 -88
View File
@@ -1,20 +1,30 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMS Gateway 管理台</title> <title>SMS Gateway · 管理台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="/assets/admin.css"> <link rel="stylesheet" href="/assets/admin.css">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
<div class="top-nav__inner"> <div class="top-nav__inner">
<a class="brand" href="/admin" aria-label="SMS Gateway 管理台"> <a class="brand" href="/admin" aria-label="SMS Gateway 管理台">
<span class="brand__mark" aria-hidden="true">✣</span> <svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
<span>SMS Gateway</span> <path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
<span class="brand__name">SMS Gateway</span>
</a> </a>
<nav class="nav-links" aria-label="管理导航"> <nav class="nav-links" aria-label="管理导航">
<a href="#status">状态</a> <a href="#dashboard">状态</a>
<a href="#sim">SIM</a>
<a href="#inbox">收件箱</a> <a href="#inbox">收件箱</a>
<a href="#send">短信</a> <a href="#send">短信</a>
<a href="#console">AT 控制台</a> <a href="#console">AT 控制台</a>
@@ -22,91 +32,172 @@
</nav> </nav>
<div class="top-nav__actions"> <div class="top-nav__actions">
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button> <button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
</div> </div>
</div> </div>
</header> </header>
<main> <main>
<!-- ============ HERO ============ -->
<section class="hero-band" id="status"> <section class="hero-band" id="status">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<span class="badge badge--coral" id="readyBadge">连接检测中</span> <span class="eyebrow eyebrow--coral">
<h1>短信网关管理台</h1> <span class="eyebrow__dot" aria-hidden="true"></span>
<p>集中查看 ML307A 模组状态、发送短信、执行 AT 命令,并检查运行日志。</p> <span id="readyBadge">连接检测中</span>
</span>
<h1>短信<em>网关</em><br>管理台</h1>
<p class="lede">集中查看 ML307A 模组状态、收发短信、执行 AT 命令、检查运行日志。</p>
<div class="hero-actions">
<a class="text-link" href="#send">发送短信</a>
<span class="hero-divider" aria-hidden="true">·</span>
<a class="text-link" href="#console">查看 AT 控制台</a>
</div>
</div> </div>
<div class="product-panel" aria-label="运行摘要"> <aside class="runtime-card" aria-label="运行摘要">
<div class="panel-header"> <header class="runtime-card__header">
<span>runtime</span> <span class="runtime-card__label">
<strong id="panelStatus">loading</strong> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
runtime
</span>
<span class="runtime-card__status">
<span class="status-dot" id="panelStatusDot" aria-hidden="true"></span>
<strong id="panelStatus">loading</strong>
</span>
</header>
<div class="runtime-card__hero">
<span class="runtime-card__caption">uptime</span>
<span class="runtime-card__value" id="uptime">--</span>
</div> </div>
<dl class="runtime-list"> <dl class="runtime-card__meta">
<div> <div>
<dt>模组</dt> <dt>operator</dt>
<dd id="modelName">--</dd>
</div>
<div>
<dt>运营商</dt>
<dd id="operator">--</dd> <dd id="operator">--</dd>
</div> </div>
<div> <div>
<dt>信号</dt> <dt>signal</dt>
<dd id="signalQuality">--</dd> <dd id="signalQuality">--</dd>
</div> </div>
<div>
<dt>运行时长</dt>
<dd id="uptime">--</dd>
</div>
</dl> </dl>
</div> </aside>
</div> </div>
</section> </section>
<section class="section-band section-band--soft"> <!-- ============ STATUS BENTO ============ -->
<section class="section-band section-band--soft" id="dashboard">
<div class="container"> <div class="container">
<div class="section-heading"> <div class="section-heading">
<h2>当前状态</h2> <div>
<p>这些数据来自模组实时 AT 查询,刷新时会串行读取,避免同时发送多条 AT 命令。</p> <span class="eyebrow eyebrow--spike">
<svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
status board
</span>
<h2>当前<em>状态</em></h2>
</div>
<button class="button button--primary" id="refreshButton" type="button">刷新状态</button>
</div> </div>
<div class="status-grid">
<article class="feature-card"> <div class="bento-grid">
<span class="eyebrow">connection</span> <article class="bento-hero">
<span class="eyebrow eyebrow--coral">
<span class="eyebrow__dot" aria-hidden="true"></span>
connection
</span>
<h3 id="connectionState">--</h3> <h3 id="connectionState">--</h3>
<p id="connectionHint">等待状态刷新。</p> <p id="connectionHint">等待状态刷新。</p>
</article> </article>
<article class="feature-card">
<article class="bento-control">
<span class="eyebrow">mobile data</span>
<h3 id="mobileDataState">--</h3>
<p id="mobileDataHint">启动时会自动断开应用层拨号。</p>
<div class="bento-control__actions">
<button class="button button--secondary button--compact" id="mobileDataToggle" type="button"
data-action="disable">强制关闭流量</button>
<button class="button button--primary button--compact is-hidden" id="mobileDataConsume"
type="button">消耗一点流量</button>
<span class="form-status mobile-data-traffic-hint" id="mobileDataTrafficHint">开启流量后才会显示消耗流量按钮。</span>
<p class="form-status mobile-data-status" id="mobileDataStatus" role="status"></p>
</div>
</article>
</div>
<div class="sim-manager" id="sim">
<div class="sim-manager__header">
<div>
<span class="eyebrow">sim manager</span>
<h3 id="simMode">--</h3>
</div>
<p class="form-status sim-manager__status" id="simStatus" role="status"></p>
</div>
<div class="sim-slot-grid" aria-label="SIM卡槽">
<article class="sim-slot" id="simSlot0">
<div class="sim-slot__topline">
<span class="sim-slot__name">SIM1</span>
<span class="sim-slot__state" id="simSlot0State">--</span>
</div>
<span class="sim-slot__phone" id="simSlot0Phone">--</span>
</article>
<article class="sim-slot" id="simSlot1">
<div class="sim-slot__topline">
<span class="sim-slot__name">SIM2</span>
<span class="sim-slot__state" id="simSlot1State">--</span>
</div>
<span class="sim-slot__phone" id="simSlot1Phone">--</span>
</article>
</div>
</div>
<div class="stat-strip">
<article class="stat-tile">
<span class="eyebrow">signal</span> <span class="eyebrow">signal</span>
<h3 id="signalValue">--</h3> <span class="stat-tile__value" id="signalValue">--</span>
<p id="signalHint">RSSI 与误码率将在刷新后显示。</p> <span class="stat-tile__hint" id="signalHint">RSSI 与误码率将在刷新后显示。</span>
</article> </article>
<article class="feature-card"> <article class="stat-tile">
<span class="eyebrow">module</span> <span class="eyebrow">module</span>
<h3 id="moduleModel">--</h3> <span class="stat-tile__value" id="moduleModel">--</span>
<p id="moduleVersion">--</p> <span class="stat-tile__hint" id="moduleVersion">--</span>
</article> </article>
<article class="feature-card"> <article class="stat-tile">
<span class="eyebrow">sim</span> <span class="eyebrow">current sim</span>
<h3 id="iccid">--</h3> <span class="stat-tile__value" id="currentSim">--</span>
<p>ICCID 查询来自当前串口模组。</p> <span class="stat-tile__hint">发送短信会使用当前绑定的 SIM。</span>
</article> </article>
</div> </div>
</div> </div>
</section> </section>
<section class="section-band"> <!-- ============ INBOX ============ -->
<div class="container" id="inbox"> <section class="section-band" id="inbox">
<div class="container">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<span class="badge badge--soft">inbox</span> <span class="eyebrow eyebrow--spike">
<h2>收到的短信</h2> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
inbox
</span>
<h2>收到的<em>短信</em></h2>
</div>
<div class="inbox-actions">
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
</div> </div>
<button class="button button--secondary" id="messagesButton" type="button">刷新短信</button>
</div> </div>
<div class="inbox-panel"> <div class="inbox-panel">
<div class="inbox-meta"> <div class="inbox-meta">
<span id="messageCount">最近 0 条</span> <span id="messageCount">最近 0 条</span>
<span>新短信会自动出现在这里</span> <span class="inbox-meta__hint" id="messageScope">新短信会自动出现在这里</span>
</div> </div>
<div class="message-list" id="messageList" aria-live="polite"> <div class="message-list" id="messageList" aria-live="polite">
<p class="empty-state empty-state--light">暂无收到的短信。</p> <p class="empty-state empty-state--light">暂无收到的短信。</p>
@@ -115,21 +206,38 @@
</div> </div>
</section> </section>
<!-- ============ WORKBENCH ============ -->
<section class="section-band section-band--soft"> <section class="section-band section-band--soft">
<div class="container workbench-grid"> <div class="container workbench-grid">
<section class="form-card" id="send"> <section class="form-card" id="send">
<div class="section-heading section-heading--compact"> <div class="section-heading section-heading--compact">
<h2>发送短信</h2> <span class="eyebrow eyebrow--spike">
<p>使用当前 PDU 模式发送短信,提交后等待模组返回发送结果。</p> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
compose
</span>
<h2>发送<em>短信</em></h2>
<p>PDU 模式发送,提交后等待模组返回结果。</p>
</div>
<p class="send-sim-banner" aria-live="polite">
<span>当前发送SIM</span>
<strong id="sendSimLabel">--</strong>
</p>
<div class="send-sim-picker" aria-label="选择发送SIM">
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">SIM1</button>
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">SIM2</button>
</div> </div>
<form id="smsForm" class="stack-form"> <form id="smsForm" class="stack-form">
<label> <label class="field">
<span>目标号码</span> <span class="field__label">目标号码</span>
<input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required> <input class="text-input" type="tel" name="phone" autocomplete="tel" placeholder="13800138000" required>
</label> </label>
<label> <label class="field">
<span>短信内容</span> <span class="field__label">短信内容</span>
<textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容" required></textarea> <textarea class="text-input text-area" name="message" rows="6" placeholder="输入要发送的短信内容"
required></textarea>
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button class="button button--primary" type="submit">发送短信</button> <button class="button button--primary" type="submit">发送短信</button>
@@ -139,27 +247,44 @@
</section> </section>
<section class="console-card" id="console"> <section class="console-card" id="console">
<div class="panel-header"> <header class="panel-header">
<span>AT console</span> <span class="panel-header__label">
<strong id="consoleState">idle</strong> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
</div> <path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
at console
</span>
<span class="panel-header__status">
<span class="status-dot status-dot--amber" aria-hidden="true"></span>
<strong id="consoleState">idle</strong>
</span>
</header>
<form id="atForm" class="at-form"> <form id="atForm" class="at-form">
<input class="text-input text-input--dark" name="command" value="AT+CSQ" autocomplete="off" spellcheck="false" required> <input class="text-input text-input--mono" name="command" value="AT+CSQ" autocomplete="off"
<button class="button button--secondary-on-dark" type="submit">执行</button> spellcheck="false" required>
<button class="button button--primary" type="submit">执行</button>
</form> </form>
<pre class="console-output" id="atOutput">等待命令...</pre> <pre class="console-output" id="atOutput">› 等待命令...</pre>
</section> </section>
</div> </div>
</section> </section>
<section class="section-band section-band--dark" id="logs"> <!-- ============ LOGS ============ -->
<section class="section-band" id="logs">
<div class="container"> <div class="container">
<div class="dark-heading"> <div class="section-heading">
<div> <div>
<span class="badge badge--dark">logs</span> <span class="eyebrow eyebrow--spike">
<h2>运行日志</h2> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
logs
</span>
<h2>运行<em>日志</em></h2>
</div> </div>
<button class="button button--secondary-on-dark" id="logsButton" type="button">刷新日志</button> <button class="button button--secondary" id="logsButton" type="button">刷新日志</button>
</div> </div>
<div class="log-window" id="logWindow" aria-live="polite"> <div class="log-window" id="logWindow" aria-live="polite">
<p class="empty-state">等待日志加载。</p> <p class="empty-state">等待日志加载。</p>
@@ -168,21 +293,32 @@
</section> </section>
</main> </main>
<!-- ============ PUSH MODAL ============ -->
<div class="modal" id="pushModal" aria-hidden="true"> <div class="modal" id="pushModal" aria-hidden="true">
<div class="modal__backdrop" data-close-push-modal></div> <div class="modal__backdrop" data-close-push-modal></div>
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle"> <section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
<div class="modal__header"> <header class="modal__header">
<div> <div>
<span class="badge badge--soft">push</span> <span class="eyebrow eyebrow--spike">
<h2 id="pushModalTitle">推送渠道设置</h2> <svg class="spike spike--coral" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
push channels
</span>
<h2 id="pushModalTitle">推送渠道<em>设置</em></h2>
</div> </div>
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button> <button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">
</div> <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</header>
<div class="push-settings"> <div class="push-settings">
<aside class="channel-list-panel" aria-label="推送渠道列表"> <aside class="channel-list-panel" aria-label="推送渠道列表">
<div class="panel-header panel-header--light"> <div class="panel-header panel-header--light">
<span>channels</span> <span class="panel-header__label">channels</span>
<div class="channel-add"> <div class="channel-add">
<select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型"> <select class="text-input select-input channel-add__select" id="pushAddTypeSelect" aria-label="新增推送类型">
<option value="dingtalk">钉钉机器人</option> <option value="dingtalk">钉钉机器人</option>
@@ -209,13 +345,13 @@
<span>启用通道</span> <span>启用通道</span>
</label> </label>
<label> <label class="field">
<span>通道名称</span> <span class="field__label">通道名称</span>
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知"> <input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
</label> </label>
<label> <label class="field">
<span>推送类型</span> <span class="field__label">推送类型</span>
<select class="text-input select-input" name="type"> <select class="text-input select-input" name="type">
<option value="dingtalk">钉钉机器人</option> <option value="dingtalk">钉钉机器人</option>
<option value="feishu">飞书机器人</option> <option value="feishu">飞书机器人</option>
@@ -229,29 +365,30 @@
</select> </select>
</label> </label>
<label class="field-row field-row--wide" data-push-field="url"> <label class="field field-row field-row--wide" data-push-field="url">
<span id="pushUrlLabel">Webhook URL</span> <span class="field__label" id="pushUrlLabel">Webhook URL</span>
<input class="text-input" type="text" name="url" autocomplete="off"> <input class="text-input" type="text" name="url" autocomplete="off">
</label> </label>
<label class="field-row field-row--wide" data-push-field="secret"> <label class="field field-row field-row--wide" data-push-field="secret">
<span id="pushSecretLabel">加签密钥</span> <span class="field__label" id="pushSecretLabel">加签密钥</span>
<input class="text-input" type="password" name="secret" autocomplete="new-password"> <input class="text-input" type="password" name="secret" autocomplete="new-password">
</label> </label>
<label class="field-row" data-push-field="key1"> <label class="field field-row" data-push-field="key1">
<span id="pushKey1Label">Key1</span> <span class="field__label" id="pushKey1Label">Key1</span>
<input class="text-input" type="text" name="key1" autocomplete="off"> <input class="text-input" type="text" name="key1" autocomplete="off">
</label> </label>
<label class="field-row" data-push-field="key2"> <label class="field field-row" data-push-field="key2">
<span id="pushKey2Label">Key2</span> <span class="field__label" id="pushKey2Label">Key2</span>
<input class="text-input" type="text" name="key2" autocomplete="off"> <input class="text-input" type="text" name="key2" autocomplete="off">
</label> </label>
<label class="field-row field-row--wide" data-push-field="customBody"> <label class="field field-row field-row--wide" data-push-field="customBody">
<span>自定义请求体 JSON</span> <span class="field__label">自定义请求体 JSON</span>
<textarea class="text-input text-area text-area--code" name="customBody" rows="7" spellcheck="false"></textarea> <textarea class="text-input text-area text-area--code" name="customBody" rows="7"
spellcheck="false"></textarea>
</label> </label>
</div> </div>
</fieldset> </fieldset>
@@ -269,11 +406,19 @@
<footer class="footer"> <footer class="footer">
<div class="container footer__inner"> <div class="container footer__inner">
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span> <span class="brand brand--footer">
<span>Basic Auth protected management surface</span> <svg class="brand__mark" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 1v22M1 12h22M3.5 3.5l17 17M20.5 3.5l-17 17" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" fill="none" />
</svg>
<span>SMS Gateway</span>
</span>
<span class="footer__meta">© <span id="footerYear">2026</span> · Token protected · Built on Node.js +
ML307A</span>
</div> </div>
</footer> </footer>
<script src="/assets/admin.js" type="module"></script> <script src="/assets/admin.js" type="module"></script>
</body> </body>
</html> </html>
+959 -327
View File
File diff suppressed because it is too large Load Diff
+383 -12
View File
@@ -1,5 +1,10 @@
const state = { const state = {
busy: false, busy: false,
mobileDataBusy: false,
trafficBusy: false,
simBusy: false,
currentMobileData: null,
currentSim: null,
pushChannels: [], pushChannels: [],
selectedPushChannel: -1, selectedPushChannel: -1,
pushLoaded: false pushLoaded: false
@@ -29,18 +34,37 @@ const elements = {
modelName: document.querySelector('#modelName'), modelName: document.querySelector('#modelName'),
operator: document.querySelector('#operator'), operator: document.querySelector('#operator'),
signalQuality: document.querySelector('#signalQuality'), signalQuality: document.querySelector('#signalQuality'),
mobileDataSummary: document.querySelector('#mobileDataSummary'),
uptime: document.querySelector('#uptime'), uptime: document.querySelector('#uptime'),
connectionState: document.querySelector('#connectionState'), connectionState: document.querySelector('#connectionState'),
connectionHint: document.querySelector('#connectionHint'), connectionHint: document.querySelector('#connectionHint'),
signalValue: document.querySelector('#signalValue'), signalValue: document.querySelector('#signalValue'),
signalHint: document.querySelector('#signalHint'), signalHint: document.querySelector('#signalHint'),
mobileDataState: document.querySelector('#mobileDataState'),
mobileDataHint: document.querySelector('#mobileDataHint'),
mobileDataToggle: document.querySelector('#mobileDataToggle'),
mobileDataConsume: document.querySelector('#mobileDataConsume'),
mobileDataTrafficHint: document.querySelector('#mobileDataTrafficHint'),
mobileDataStatus: document.querySelector('#mobileDataStatus'),
simMode: document.querySelector('#simMode'),
simStatus: document.querySelector('#simStatus'),
simSlot0: document.querySelector('#simSlot0'),
simSlot1: document.querySelector('#simSlot1'),
simSlotGrid: document.querySelector('.sim-slot-grid'),
simSlot0State: document.querySelector('#simSlot0State'),
simSlot1State: document.querySelector('#simSlot1State'),
simSlot0Phone: document.querySelector('#simSlot0Phone'),
simSlot1Phone: document.querySelector('#simSlot1Phone'),
simSwitchButtons: [...document.querySelectorAll('[data-sim-switch]')],
moduleModel: document.querySelector('#moduleModel'), moduleModel: document.querySelector('#moduleModel'),
moduleVersion: document.querySelector('#moduleVersion'), moduleVersion: document.querySelector('#moduleVersion'),
iccid: document.querySelector('#iccid'), currentSim: document.querySelector('#currentSim'),
smsForm: document.querySelector('#smsForm'), smsForm: document.querySelector('#smsForm'),
sendSimLabel: document.querySelector('#sendSimLabel'),
smsStatus: document.querySelector('#smsStatus'), smsStatus: document.querySelector('#smsStatus'),
messagesButton: document.querySelector('#messagesButton'), messagesButton: document.querySelector('#messagesButton'),
messageCount: document.querySelector('#messageCount'), messageCount: document.querySelector('#messageCount'),
messageScope: document.querySelector('#messageScope'),
messageList: document.querySelector('#messageList'), messageList: document.querySelector('#messageList'),
atForm: document.querySelector('#atForm'), atForm: document.querySelector('#atForm'),
atOutput: document.querySelector('#atOutput'), atOutput: document.querySelector('#atOutput'),
@@ -103,10 +127,12 @@ const PUSH_TYPE_META = {
}; };
function setText(node, value) { function setText(node, value) {
if (!node) return;
node.textContent = value || '--'; node.textContent = value || '--';
} }
function setStatusMessage(node, message, type = '') { function setStatusMessage(node, message, type = '') {
if (!node) return;
node.textContent = message; node.textContent = message;
node.classList.toggle('is-success', type === 'success'); node.classList.toggle('is-success', type === 'success');
node.classList.toggle('is-error', type === 'error'); node.classList.toggle('is-error', type === 'error');
@@ -152,12 +178,225 @@ function formatModel(model = {}) {
return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--'; return [model.manufacturer, model.model].filter(Boolean).join(' ') || '--';
} }
function renderStatus(status, info) { function formatMobileDataMode(mode) {
if (mode === 'mipcall') {
return 'MIPCALL';
}
if (mode === 'cgact') {
return 'CGACT';
}
return 'AT';
}
function formatMobileDataContexts(contexts = []) {
const active = contexts
.filter(item => item.active)
.map(item => `CID ${item.cid}`)
.join('、');
return active || '';
}
function renderMobileData(mobileData = {}) {
state.currentMobileData = mobileData;
const unknown = mobileData.status === 'unknown';
const enabled = Boolean(mobileData.enabled || mobileData.anyActive);
const cid = mobileData.cid || 1;
const mode = formatMobileDataMode(mobileData.mode);
const isMipcall = mobileData.mode === 'mipcall';
const activeContexts = formatMobileDataContexts(mobileData.contexts || []);
if (unknown) {
setText(elements.mobileDataSummary, '未知');
setText(elements.mobileDataState, '状态未知');
setText(elements.mobileDataHint, mobileData.error || (isMipcall ? '未能确认应用层拨号状态,可先强制断开。' : '未能确认移动数据状态,可先强制关闭。'));
elements.mobileDataToggle.textContent = isMipcall ? '强制断开拨号' : '强制关闭流量';
elements.mobileDataToggle.dataset.action = 'disable';
elements.mobileDataConsume.classList.add('is-hidden');
elements.mobileDataTrafficHint.classList.remove('is-hidden');
elements.mobileDataTrafficHint.textContent = '状态确认后,开启流量才会显示消耗流量按钮。';
} else if (enabled) {
setText(elements.mobileDataSummary, isMipcall ? '已连接' : '已开启');
setText(elements.mobileDataState, isMipcall ? '应用拨号已连接' : '流量已开启');
setText(elements.mobileDataHint, activeContexts ? `${mode} 检测到 ${activeContexts} 处于活动状态。` : `CID ${cid} 处于活动状态。`);
elements.mobileDataToggle.textContent = isMipcall ? '断开拨号' : '关闭流量';
elements.mobileDataToggle.dataset.action = 'disable';
elements.mobileDataConsume.classList.remove('is-hidden');
elements.mobileDataTrafficHint.classList.add('is-hidden');
} else {
setText(elements.mobileDataSummary, isMipcall ? '已断开' : '已关闭');
setText(elements.mobileDataState, isMipcall ? '应用拨号已断开' : '流量已关闭');
setText(elements.mobileDataHint, isMipcall ? `${mode} CID ${cid} 未建立应用层拨号连接;已保护性尝试停用PDP。` : `${mode} CID ${cid} 未建立移动数据连接,重启后也会先保持关闭。`);
elements.mobileDataToggle.textContent = isMipcall ? '开启拨号' : '开启流量';
elements.mobileDataToggle.dataset.action = 'enable';
elements.mobileDataConsume.classList.add('is-hidden');
elements.mobileDataTrafficHint.classList.remove('is-hidden');
elements.mobileDataTrafficHint.textContent = isMipcall ? '先开启拨号后,才可以执行一次MPING消耗少量流量。' : '先开启流量后,才可以执行一次MPING消耗少量流量。';
}
elements.mobileDataToggle.disabled = state.mobileDataBusy;
elements.mobileDataConsume.disabled = state.mobileDataBusy || state.trafficBusy || !enabled;
}
function getActiveSimSlot(sim = {}) {
return (sim.slots || []).find(slot => slot.active) || null;
}
function getSimDisplayLabel(slot) {
return slot?.phoneLabel || slot?.phoneNumber || slot?.name || 'SIM';
}
function getActiveSimLabel() {
return getSimDisplayLabel(getActiveSimSlot(state.currentSim || {}));
}
function renderSendSimLabel() {
setText(elements.sendSimLabel, getActiveSimLabel());
}
function setSimSwitchStatus(message, type = '') {
setStatusMessage(elements.simStatus, message);
setStatusMessage(elements.smsStatus, message, type);
}
function getSimSlot(sim = {}, slotNumber) {
return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
slot: slotNumber,
name: `SIM${slotNumber + 1}`,
active: false,
phoneNumber: '',
phoneLabel: `SIM${slotNumber + 1}`
};
}
function formatSimSlotPhone(slot) {
if (slot?.phoneNumber) {
return slot.phoneNumber;
}
if (slot?.phoneLabel && slot.phoneLabel !== slot.name) {
return slot.phoneLabel;
}
return '';
}
function renderSimSlot(slot, card, stateNode, phoneNode) {
if (!card) {
return;
}
card.classList.toggle('is-active', Boolean(slot.active));
if (slot.active && slot.present === false) {
setText(stateNode, '当前未就绪');
} else if (slot.active) {
setText(stateNode, '当前使用');
} else if (slot.present === true) {
setText(stateNode, '可用');
} else if (slot.present === false) {
setText(stateNode, '未检测到SIM');
} else {
setText(stateNode, '未确认');
}
const phoneText = formatSimSlotPhone(slot);
if (phoneNode) {
phoneNode.textContent = phoneText;
phoneNode.hidden = !phoneText;
}
}
function getVisibleSimSlots(sim = {}, activeSlot = null) {
const slots = sim.slots || [];
const presentSlots = slots.filter(slot => slot.present === true);
if (presentSlots.length >= 2) {
return presentSlots;
}
if (activeSlot?.present === true) {
return [activeSlot];
}
return slots.filter(slot => slot.active || slot.present !== false);
}
function getSimModeLabel(sim = {}, activeSlot = null) {
const hardwareSwitchable = Boolean(sim.supported && sim.canSwitch);
if (!hardwareSwitchable) {
return '单卡/未检测到双卡';
}
const presentSlots = (sim.slots || []).filter(slot => slot.present === true);
if (presentSlots.length >= 2) {
return sim.modeLabel || '双卡';
}
if (activeSlot?.present === true) {
return `仅检测到 ${getSimDisplayLabel(activeSlot)}`;
}
return '未检测到可用SIM';
}
function renderSimStatus(sim = {}) {
state.currentSim = sim;
const supported = Boolean(sim.supported && sim.canSwitch);
const activeSlot = getActiveSimSlot(sim);
const modeLabel = getSimModeLabel(sim, activeSlot);
setText(elements.simMode, modeLabel);
const visibleSlots = getVisibleSimSlots(sim, activeSlot);
const singleSlot = visibleSlots.length === 1;
const visibleSlotNumbers = new Set(visibleSlots.map(slot => slot.slot));
elements.simSlotGrid?.classList.toggle('is-single', singleSlot);
[
[getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone],
[getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone]
].forEach(([slot, card, stateNode, phoneNode]) => {
if (card) {
card.hidden = !visibleSlotNumbers.has(slot.slot);
card.classList.toggle('is-single', singleSlot && visibleSlotNumbers.has(slot.slot));
}
renderSimSlot(slot, card, stateNode, phoneNode);
});
elements.simSwitchButtons.forEach((button) => {
const slot = Number.parseInt(button.dataset.simSwitch, 10);
const slotInfo = getSimSlot(sim, slot);
const isActive = activeSlot?.slot === slot;
const unavailable = slotInfo.present === false;
button.hidden = singleSlot;
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable;
button.textContent = isActive ? `SIM${slot + 1} 当前` : `切到SIM${slot + 1}`;
});
if (state.simBusy || sim.switching) {
setSimSwitchStatus('正在切换SIM...');
} else if (supported && activeSlot?.present === true) {
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`);
} else if (supported && activeSlot?.present === false) {
setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`);
} else if (!supported) {
setStatusMessage(elements.simStatus, '双卡不可用');
} else {
setStatusMessage(elements.simStatus, '');
}
renderSendSimLabel();
}
function renderStatus(status, info, sim) {
const data = status.data || {}; const data = status.data || {};
const infoData = info.data || {}; const infoData = info.data || {};
const model = data.model || infoData.model || {}; const model = data.model || infoData.model || {};
const signal = data.signal || {}; const signal = data.signal || {};
const ready = Boolean(data.ready); const ready = Boolean(data.ready);
const simData = sim?.data || data.sim || {};
const activeSim = getActiveSimSlot(simData);
setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪'); setText(elements.readyBadge, ready ? '模组已就绪' : '模组未就绪');
setText(elements.panelStatus, ready ? 'ready' : 'not ready'); setText(elements.panelStatus, ready ? 'ready' : 'not ready');
@@ -165,6 +404,7 @@ function renderStatus(status, info) {
setText(elements.operator, data.operator || '未知'); setText(elements.operator, data.operator || '未知');
setText(elements.signalQuality, signal.quality || '未知'); setText(elements.signalQuality, signal.quality || '未知');
setText(elements.uptime, formatUptime(data.uptime)); setText(elements.uptime, formatUptime(data.uptime));
renderMobileData(data.mobileData || {});
setText(elements.connectionState, ready ? '运行中' : '未就绪'); setText(elements.connectionState, ready ? '运行中' : '未就绪');
setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。'); setText(elements.connectionHint, ready ? '串口、SIM、驻网和短信配置均已通过启动检查。' : '请检查启动日志和模组初始化状态。');
@@ -172,7 +412,8 @@ function renderStatus(status, info) {
setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`); setText(elements.signalHint, signal.ber === undefined ? 'RSSI 与误码率将在刷新后显示。' : `误码率 ${signal.ber},质量 ${signal.quality || '未知'}。`);
setText(elements.moduleModel, model.model || '--'); setText(elements.moduleModel, model.model || '--');
setText(elements.moduleVersion, model.version || '--'); setText(elements.moduleVersion, model.version || '--');
setText(elements.iccid, infoData.iccid || '--'); setText(elements.currentSim, getSimDisplayLabel(activeSim) || infoData.simLabel || '--');
renderSimStatus(simData);
} }
function parseLogLine(line) { function parseLogLine(line) {
@@ -238,9 +479,13 @@ function formatDateTime(value) {
}); });
} }
function renderMessages(messages) { function renderMessages(messages, meta = {}) {
elements.messageList.replaceChildren(); elements.messageList.replaceChildren();
setText(elements.messageCount, `最近 ${messages.length} 条`); const countLabel = `最近 ${messages.length} 条`;
const scopeLabel = '新短信自动显示';
setText(elements.messageCount, countLabel);
setText(elements.messageScope, scopeLabel);
if (!messages.length) { if (!messages.length) {
const empty = document.createElement('p'); const empty = document.createElement('p');
@@ -590,8 +835,111 @@ async function refreshLogs() {
} }
async function refreshMessages() { async function refreshMessages() {
const messages = await requestJSON('/api/sms/received?limit=50'); const params = new URLSearchParams({ limit: '50', scope: 'all' });
renderMessages(messages.data || []); const messages = await requestJSON(`/api/sms/received?${params.toString()}`);
renderMessages(messages.data || [], messages.meta || {});
}
async function refreshSim() {
const sim = await requestJSON('/api/modem/sim?refresh=true');
renderSimStatus(sim.data || {});
return sim;
}
async function switchSim(slot) {
if (state.simBusy) {
return;
}
const slotNumber = Number.parseInt(slot, 10);
if (!Number.isInteger(slotNumber)) {
return;
}
if (!window.confirm(`切换到SIM${slotNumber + 1}会重新初始化SIM、驻网和短信功能,期间可能短暂收不到短信。确定继续吗?`)) {
return;
}
state.simBusy = true;
renderSimStatus({
...(state.currentSim || {}),
switching: true
});
try {
const result = await requestJSON('/api/modem/sim/switch', {
method: 'POST',
body: JSON.stringify({ slot: slotNumber })
});
renderSimStatus(result.data || {});
setSimSwitchStatus(result.message || `已切换到SIM${slotNumber + 1}`, 'success');
await refreshAll();
} catch (err) {
setSimSwitchStatus(err.message, 'error');
await refreshSim().catch(() => {});
} finally {
state.simBusy = false;
renderSimStatus(state.currentSim || {});
}
}
async function toggleMobileData() {
if (state.mobileDataBusy) {
return;
}
const action = elements.mobileDataToggle.dataset.action || 'disable';
const enabled = action === 'enable';
if (enabled && !window.confirm('开启移动数据可能产生流量费用,确定要开启吗?')) {
return;
}
state.mobileDataBusy = true;
elements.mobileDataToggle.disabled = true;
setStatusMessage(elements.mobileDataStatus, enabled ? '正在开启...' : '正在关闭...');
try {
const result = await requestJSON('/api/modem/mobile-data', {
method: 'POST',
body: JSON.stringify({ enabled })
});
renderMobileData(result.data || {});
setStatusMessage(elements.mobileDataStatus, result.message || (enabled ? '已开启。' : '已关闭。'), 'success');
await refreshLogs();
} catch (err) {
setStatusMessage(elements.mobileDataStatus, err.message, 'error');
} finally {
state.mobileDataBusy = false;
renderMobileData(state.currentMobileData || {});
}
}
async function consumeMobileDataTraffic() {
if (state.mobileDataBusy || state.trafficBusy) {
return;
}
state.trafficBusy = true;
elements.mobileDataToggle.disabled = true;
elements.mobileDataConsume.disabled = true;
setStatusMessage(elements.mobileDataStatus, '正在执行MPING,会消耗少量流量...');
try {
const result = await requestJSON('/api/modem/mobile-data/consume', {
method: 'POST',
body: JSON.stringify({ target: '8.8.8.8' })
});
renderMobileData(result.data?.mobileData || state.currentMobileData || {});
const pingMessage = result.data?.ping?.message || result.message || '已完成流量消耗测试。';
setStatusMessage(elements.mobileDataStatus, pingMessage, 'success');
await refreshLogs();
} catch (err) {
setStatusMessage(elements.mobileDataStatus, err.message, 'error');
} finally {
state.trafficBusy = false;
renderMobileData(state.currentMobileData || {});
}
} }
async function refreshAll() { async function refreshAll() {
@@ -606,7 +954,9 @@ async function refreshAll() {
try { try {
const status = await requestJSON('/api/status'); const status = await requestJSON('/api/status');
const info = await requestJSON('/api/modem/info'); const info = await requestJSON('/api/modem/info');
renderStatus(status, info); const sim = await requestJSON('/api/modem/sim?refresh=true');
renderStatus(status, info, sim);
setStatusMessage(elements.mobileDataStatus, '');
await refreshMessages(); await refreshMessages();
await refreshLogs(); await refreshLogs();
} catch (err) { } catch (err) {
@@ -635,7 +985,8 @@ async function sendSMS(event) {
} }
submit.disabled = true; submit.disabled = true;
setStatusMessage(elements.smsStatus, '发送中...'); renderSendSimLabel();
setStatusMessage(elements.smsStatus, `正在使用 ${getActiveSimLabel()} 发送...`);
try { try {
const result = await requestJSON('/api/sms/send', { const result = await requestJSON('/api/sms/send', {
@@ -663,18 +1014,18 @@ async function runATCommand(event) {
submit.disabled = true; submit.disabled = true;
setText(elements.consoleState, 'running'); setText(elements.consoleState, 'running');
elements.atOutput.textContent = `> ${command}\n等待响应...`; elements.atOutput.textContent = `› ${command}\n等待响应...`;
try { try {
const result = await requestJSON('/api/modem/at', { const result = await requestJSON('/api/modem/at', {
method: 'POST', method: 'POST',
body: JSON.stringify({ command }) body: JSON.stringify({ command })
}); });
elements.atOutput.textContent = `> ${command}\n${result.data.response || ''}`.trim(); elements.atOutput.textContent = `› ${command}\n${result.data.response || ''}`.trim();
setText(elements.consoleState, 'done'); setText(elements.consoleState, 'done');
await refreshLogs(); await refreshLogs();
} catch (err) { } catch (err) {
elements.atOutput.textContent = `> ${command}\nERROR: ${err.message}`; elements.atOutput.textContent = `› ${command}\nERROR: ${err.message}`;
setText(elements.consoleState, 'error'); setText(elements.consoleState, 'error');
} finally { } finally {
submit.disabled = false; submit.disabled = false;
@@ -694,10 +1045,30 @@ elements.pushChannelForm.addEventListener('submit', savePushChannels);
elements.pushTestButton.addEventListener('click', testPushChannel); elements.pushTestButton.addEventListener('click', testPushChannel);
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange); elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
elements.refreshButton.addEventListener('click', refreshAll); elements.refreshButton.addEventListener('click', refreshAll);
elements.mobileDataToggle.addEventListener('click', toggleMobileData);
elements.mobileDataConsume.addEventListener('click', consumeMobileDataTraffic);
elements.simSwitchButtons.forEach((button) => {
button.addEventListener('click', () => {
switchSim(button.dataset.simSwitch);
});
});
elements.logsButton.addEventListener('click', refreshLogs); elements.logsButton.addEventListener('click', refreshLogs);
elements.messagesButton.addEventListener('click', refreshMessages); elements.messagesButton.addEventListener('click', refreshMessages);
elements.smsForm.addEventListener('submit', sendSMS); elements.smsForm.addEventListener('submit', sendSMS);
elements.atForm.addEventListener('submit', runATCommand); elements.atForm.addEventListener('submit', runATCommand);
const ctaPushButton = document.querySelector('#ctaPushButton');
const ctaRefreshButton = document.querySelector('#ctaRefreshButton');
const footerPushLink = document.querySelector('#footerPushLink');
const footerYear = document.querySelector('#footerYear');
if (ctaPushButton) ctaPushButton.addEventListener('click', openPushModal);
if (ctaRefreshButton) ctaRefreshButton.addEventListener('click', refreshAll);
if (footerPushLink) footerPushLink.addEventListener('click', (event) => {
event.preventDefault();
openPushModal();
});
if (footerYear) footerYear.textContent = String(new Date().getFullYear());
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) { if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
closePushModal(); closePushModal();
+153 -38
View File
@@ -50,7 +50,7 @@ class APIServer {
// JSON解析 // JSON解析
this.app.use(express.json()); this.app.use(express.json());
// Web Token认证,API保留Basic Auth兼容 // Web Token认证
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
this.authenticateRequest(req, res, next); this.authenticateRequest(req, res, next);
}); });
@@ -75,22 +75,23 @@ class APIServer {
return next(); return next();
} }
if (this.isWebRoute(req) && this.getConfiguredWebToken()) { if (this.isWebRoute(req)) {
return this.sendTokenGate(res); const webToken = this.getConfiguredWebToken();
} if (webToken) {
return this.sendTokenGate(res);
}
if (this.hasValidBasicAuth(req)) { return res.status(401).send('未配置 webToken');
return next();
} }
if (req.path.startsWith('/api/')) { if (req.path.startsWith('/api/')) {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: this.getConfiguredWebToken() ? '需要有效token或Basic Auth' : '需要Basic Auth' error: this.getConfiguredWebToken() ? '需要有效token' : '未配置webToken'
}); });
} }
return this.sendBasicAuthChallenge(res); return res.status(401).send(this.getConfiguredWebToken() ? '需要有效token' : '未配置 webToken');
} }
getConfiguredWebToken() { getConfiguredWebToken() {
@@ -143,24 +144,6 @@ class APIServer {
return `${url.pathname}${url.search}`; return `${url.pathname}${url.search}`;
} }
hasValidBasicAuth(req) {
const auth = req.get('authorization') || '';
if (!auth.startsWith('Basic ')) {
return false;
}
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
const separatorIndex = decoded.indexOf(':');
if (separatorIndex === -1) {
return false;
}
const username = decoded.slice(0, separatorIndex);
const password = decoded.slice(separatorIndex + 1);
return this.safeEqual(username, this.config.api.auth.username) &&
this.safeEqual(password, this.config.api.auth.password);
}
safeEqual(actual, expected) { safeEqual(actual, expected) {
const actualBuffer = Buffer.from(String(actual)); const actualBuffer = Buffer.from(String(actual));
const expectedBuffer = Buffer.from(String(expected)); const expectedBuffer = Buffer.from(String(expected));
@@ -176,11 +159,6 @@ class APIServer {
return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/'); return req.path === '/' || req.path === '/admin' || req.path.startsWith('/assets/');
} }
sendBasicAuthChallenge(res) {
res.set('WWW-Authenticate', 'Basic realm="SMS Gateway"');
return res.status(401).send('Authentication required');
}
sendTokenGate(res) { sendTokenGate(res) {
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
return res.status(401).type('html').send(`<!doctype html> return res.status(401).type('html').send(`<!doctype html>
@@ -364,6 +342,7 @@ class APIServer {
try { try {
const signal = await this.modem.getSignalQuality(); const signal = await this.modem.getSignalQuality();
const operator = await this.modem.getOperator(); const operator = await this.modem.getOperator();
const mobileData = await this.modem.getMobileDataStatus();
res.json({ res.json({
success: true, success: true,
@@ -372,6 +351,8 @@ class APIServer {
model: this.modem.modelInfo, model: this.modem.modelInfo,
signal, signal,
operator, operator,
mobileData,
sim: this.modem.getCachedSimStatus?.() || null,
uptime: process.uptime() uptime: process.uptime()
} }
}); });
@@ -396,11 +377,14 @@ class APIServer {
}); });
} }
const success = await this.modem.sendSMS(phone, message); const result = await this.modem.sendSMS(phone, message);
const success = Boolean(result?.success);
const simLabel = result?.simLabel || '未知SIM';
res.json({ res.json({
success, success,
message: success ? '短信发送成功' : '短信发送失败' message: success ? `短信发送成功,发送SIM: ${simLabel}` : `短信发送失败,发送SIM: ${simLabel}`,
data: result
}); });
} catch (err) { } catch (err) {
logger.error('发送短信失败:', err); logger.error('发送短信失败:', err);
@@ -412,12 +396,20 @@ class APIServer {
}); });
// 查询收到的短信 // 查询收到的短信
this.app.get('/api/sms/received', (req, res) => { this.app.get('/api/sms/received', async (req, res) => {
try { try {
const messages = this.smsProcessor.getReceivedMessages(req.query.limit); await this.smsProcessor.getCurrentSimIdentity();
const currentSimIdentity = this.smsProcessor.getCurrentModemSimIdentity();
const result = this.smsProcessor.listReceivedMessages(req.query.limit, {
currentSimIdentity,
scope: req.query.scope,
simSlot: req.query.simSlot ?? req.query.slot
});
res.json({ res.json({
success: true, success: true,
data: messages data: result.messages,
meta: result.meta
}); });
} catch (err) { } catch (err) {
logger.error('查询收到短信失败:', err); logger.error('查询收到短信失败:', err);
@@ -448,12 +440,19 @@ class APIServer {
// 查询模组信息 // 查询模组信息
this.app.get('/api/modem/info', async (req, res) => { this.app.get('/api/modem/info', async (req, res) => {
try { try {
const iccid = await this.modem.getICCID(); const simStatus = this.modem.getCachedSimStatus?.();
const currentSlot = simStatus?.slots?.find(slot => slot.active);
const simIdentity = this.smsProcessor.setCurrentSimIdentity(
this.smsProcessor.getCurrentModemSimIdentity()
);
res.json({ res.json({
success: true, success: true,
data: { data: {
model: this.modem.modelInfo, model: this.modem.modelInfo,
iccid, phoneNumber: currentSlot?.phoneNumber || '',
simId: simIdentity?.simId || null,
simLabel: simIdentity?.simLabel || '未知SIM',
ready: this.modem.ready ready: this.modem.ready
} }
}); });
@@ -466,6 +465,122 @@ class APIServer {
} }
}); });
// 查询SIM状态和双卡能力
this.app.get('/api/modem/sim', async (req, res) => {
try {
const sim = await this.modem.getSimStatus({
refreshIdentity: req.query.refresh !== 'false'
});
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
res.json({
success: true,
data: sim
});
} catch (err) {
logger.error('查询SIM状态失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 切换当前SIM卡槽
this.app.post('/api/modem/sim/switch', async (req, res) => {
try {
const slot = req.body?.slot ?? req.body?.sim;
const result = await this.modem.switchSim(slot);
const currentSlot = result.status.slots.find(item => item.active);
this.smsProcessor.setCurrentSimIdentity(this.smsProcessor.getCurrentModemSimIdentity());
const currentLabel = currentSlot?.phoneLabel || currentSlot?.phoneNumber || currentSlot?.name || '目标SIM';
res.json({
success: true,
message: result.changed ? `已绑定并切到${currentLabel}` : `${currentLabel}已绑定并切换`,
data: result.status
});
} catch (err) {
logger.error('切换SIM失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 查询移动数据连接状态
this.app.get('/api/modem/mobile-data', async (req, res) => {
try {
const mobileData = await this.modem.getMobileDataStatus();
res.json({
success: true,
data: mobileData
});
} catch (err) {
logger.error('查询移动数据状态失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 开启或关闭移动数据连接
this.app.post('/api/modem/mobile-data', async (req, res) => {
try {
if (typeof req.body?.enabled !== 'boolean') {
return res.status(400).json({
success: false,
error: '缺少必要参数: enabled'
});
}
const mobileData = await this.modem.setMobileDataEnabled(req.body.enabled);
const message = mobileData.mode === 'mipcall'
? (req.body.enabled ? '应用层拨号已开启' : '应用层拨号已断开')
: (req.body.enabled ? '移动数据已开启' : '移动数据已关闭');
res.json({
success: true,
message,
data: mobileData
});
} catch (err) {
logger.error('设置移动数据状态失败:', err);
res.status(500).json({
success: false,
error: err.message
});
}
});
// 通过模组执行一次MPING,消耗少量流量验证连通性
this.app.post('/api/modem/mobile-data/consume', async (req, res) => {
try {
const ping = await this.modem.consumeMobileDataTraffic({
target: req.body?.target || '8.8.8.8'
});
const mobileData = await this.modem.getMobileDataStatus();
res.json({
success: true,
message: '已消耗少量流量完成连通性测试',
data: {
ping,
mobileData
}
});
} catch (err) {
logger.error('流量消耗测试失败:', err);
res.status(err.statusCode || 500).json({
success: false,
error: err.message,
data: err.data || null
});
}
});
// 发送AT命令 // 发送AT命令
this.app.post('/api/modem/at', async (req, res) => { this.app.post('/api/modem/at', async (req, res) => {
try { try {
+15 -8
View File
@@ -13,10 +13,12 @@ class ConcatManager extends EventEmitter {
/** /**
* 查找或创建长短信槽位 * 查找或创建长短信槽位
*/ */
findOrCreateSlot(refNumber, sender, totalParts) { findOrCreateSlot(refNumber, sender, totalParts, simIdentity = null) {
const simId = simIdentity?.simId || '';
// 先查找是否已存在 // 先查找是否已存在
let slot = this.buffer.find(s => let slot = this.buffer.find(s =>
s.inUse && s.refNumber === refNumber && s.sender === sender s.inUse && s.refNumber === refNumber && s.sender === sender && s.simId === simId
); );
if (slot) { if (slot) {
@@ -26,7 +28,7 @@ class ConcatManager extends EventEmitter {
// 查找空闲槽位 // 查找空闲槽位
slot = this.buffer.find(s => !s.inUse); slot = this.buffer.find(s => !s.inUse);
if (slot) { if (slot) {
this.initSlot(slot, refNumber, sender, totalParts); this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot; return slot;
} }
@@ -44,17 +46,19 @@ class ConcatManager extends EventEmitter {
logger.warn('长短信缓存已满,覆盖最老的槽位'); logger.warn('长短信缓存已满,覆盖最老的槽位');
} }
this.initSlot(slot, refNumber, sender, totalParts); this.initSlot(slot, refNumber, sender, totalParts, simIdentity);
return slot; return slot;
} }
/** /**
* 初始化槽位 * 初始化槽位
*/ */
initSlot(slot, refNumber, sender, totalParts) { initSlot(slot, refNumber, sender, totalParts, simIdentity = null) {
slot.inUse = true; slot.inUse = true;
slot.refNumber = refNumber; slot.refNumber = refNumber;
slot.sender = sender; slot.sender = sender;
slot.simId = simIdentity?.simId || '';
slot.simIdentity = simIdentity;
slot.totalParts = totalParts; slot.totalParts = totalParts;
slot.receivedParts = 0; slot.receivedParts = 0;
slot.firstPartTime = Date.now(); slot.firstPartTime = Date.now();
@@ -65,10 +69,10 @@ class ConcatManager extends EventEmitter {
/** /**
* 添加短信分段 * 添加短信分段
*/ */
addPart(refNumber, sender, partNumber, totalParts, text, timestamp) { addPart(refNumber, sender, partNumber, totalParts, text, timestamp, options = {}) {
logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`); logger.info(`收到长短信分段 ${partNumber}/${totalParts}, 参考号: ${refNumber}`);
const slot = this.findOrCreateSlot(refNumber, sender, totalParts); const slot = this.findOrCreateSlot(refNumber, sender, totalParts, options.simIdentity);
const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始 const partIndex = partNumber - 1; // partNumber从1开始,数组从0开始
if (partIndex >= 0 && partIndex < this.maxParts) { if (partIndex >= 0 && partIndex < this.maxParts) {
@@ -110,7 +114,8 @@ class ConcatManager extends EventEmitter {
this.emit('complete', { this.emit('complete', {
sender: slot.sender, sender: slot.sender,
text: fullText, text: fullText,
timestamp: slot.timestamp timestamp: slot.timestamp,
simIdentity: slot.simIdentity
}); });
// 清空槽位 // 清空槽位
@@ -124,6 +129,8 @@ class ConcatManager extends EventEmitter {
slot.inUse = false; slot.inUse = false;
slot.parts = []; slot.parts = [];
slot.receivedParts = 0; slot.receivedParts = 0;
slot.simId = '';
slot.simIdentity = null;
} }
/** /**
+20 -10
View File
@@ -46,7 +46,7 @@ async function main() {
logger.info('配置加载完成'); logger.info('配置加载完成');
// 创建模组管理器 // 创建模组管理器
const modem = new ModemManager(config.serial); const modem = new ModemManager(config.serial, config.mobileData, config);
// 创建长短信管理器 // 创建长短信管理器
const concatManager = new ConcatManager(); const concatManager = new ConcatManager();
@@ -60,7 +60,9 @@ async function main() {
// 监听长短信合并完成事件 // 监听长短信合并完成事件
concatManager.on('complete', async (sms) => { concatManager.on('complete', async (sms) => {
logger.info('收到长短信合并完成事件'); logger.info('收到长短信合并完成事件');
await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp); await smsProcessor.processSmsContent(sms.sender, sms.text, sms.timestamp, {
simIdentity: sms.simIdentity
});
}); });
// 监听模组短信事件 // 监听模组短信事件
@@ -111,19 +113,27 @@ async function main() {
process.exit(1); process.exit(1);
} }
// 优雅退出 async function shutdown(signal) {
process.on('SIGINT', async () => { logger.info(`\n收到 ${signal} 信号,正在关闭...`);
logger.info('\n收到 SIGINT 信号,正在关闭...');
concatManager.stopTimeoutChecker(); concatManager.stopTimeoutChecker();
await modem.forceMobileDataOff(`${signal}退出保护`);
await modem.close(); await modem.close();
process.exit(0); process.exit(0);
}
// 优雅退出
process.on('SIGINT', () => {
shutdown('SIGINT').catch((err) => {
logger.error('关闭失败:', err);
process.exit(1);
});
}); });
process.on('SIGTERM', async () => { process.on('SIGTERM', () => {
logger.info('\n收到 SIGTERM 信号,正在关闭...'); shutdown('SIGTERM').catch((err) => {
concatManager.stopTimeoutChecker(); logger.error('关闭失败:', err);
await modem.close(); process.exit(1);
process.exit(0); });
}); });
// 捕获未处理的异常 // 捕获未处理的异常
+1832 -84
View File
File diff suppressed because it is too large Load Diff
+328 -111
View File
@@ -1,4 +1,5 @@
import { parse } from 'node-pdu'; import { parse } from 'node-pdu';
import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -17,16 +18,135 @@ class SMSProcessor {
this.receivedMessageSeq = 0; this.receivedMessageSeq = 0;
this.maxReceivedMessages = this.resolveMaxReceivedMessages(); this.maxReceivedMessages = this.resolveMaxReceivedMessages();
this.receivedMessagesFile = this.resolveReceivedMessagesFile(); this.receivedMessagesFile = this.resolveReceivedMessagesFile();
this.partitionMessagesBySim = this.resolvePartitionMessagesBySim();
this.allowAllSimMessages = this.resolveAllowAllSimMessages();
this.currentSimIdentity = null;
this.currentSimCheckedAt = 0;
this.currentSimLookup = null;
this.simIdentityTtlMs = 5 * 1000;
this.loadReceivedMessages(); this.loadReceivedMessages();
} }
normalizeIncomingPduEvent(input) {
if (typeof input === 'string') {
return {
pdu: input,
metadata: {
type: 'CMT',
raw: '',
simSlot: null,
receivedAt: new Date().toISOString()
}
};
}
if (input && typeof input === 'object') {
return {
pdu: String(input.pdu || input.pduHex || ''),
metadata: {
...(input.metadata || {}),
simSlot: this.normalizeSimSlot(input.metadata?.simSlot ?? input.simSlot),
receivedAt: input.metadata?.receivedAt || input.receivedAt || new Date().toISOString()
}
};
}
return {
pdu: '',
metadata: {
type: 'CMT',
raw: '',
simSlot: null,
receivedAt: new Date().toISOString()
}
};
}
resolveIncomingSimIdentity({ metadata = {} } = {}) {
const urcSlot = this.normalizeSimSlot(metadata.simSlot);
if (urcSlot !== null) {
return this.createSimIdentityFromSlot(urcSlot, {
simSource: metadata.simSource || 'urc'
});
}
const identified = this.modem?.identifyIncomingSms?.(metadata);
if (identified?.slot !== undefined && identified?.slot !== null) {
return this.createSimIdentityFromSlot(identified.slot, {
simSource: identified.source || 'slot'
});
}
const directSource = metadata.simSource || (metadata.type === 'CMT' || metadata.type === 'CMTI' ? 'urc' : '');
if (directSource) {
return {
simId: crypto.createHash('sha256').update(`unknown-${directSource}`).digest('hex').slice(0, 24),
simLabel: '未知SIM',
simSlot: null,
simSource: directSource,
smsCenter: ''
};
}
return null;
}
createSimIdentityFromSlot(slotNumber, extra = {}) {
const slot = this.normalizeSimSlot(slotNumber);
if (slot === null) {
return null;
}
const simStatus = this.modem?.getCachedSimStatus?.();
const slotInfo = simStatus?.slots?.find(item => item.slot === slot);
const name = slotInfo?.phoneLabel || slotInfo?.phoneNumber || slotInfo?.name || `SIM${slot + 1}`;
return {
simId: crypto.createHash('sha256').update(`slot:${slot}`).digest('hex').slice(0, 24),
simLabel: name,
simSlot: slot,
simSource: extra.simSource || 'slot',
smsCenter: ''
};
}
normalizeSimSlot(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (normalized === 'SIM1') {
return 0;
}
if (normalized === 'SIM2') {
return 1;
}
}
const slot = Number.parseInt(value, 10);
return slot === 0 || slot === 1 ? slot : null;
}
formatSimSource(source) {
const labels = {
urc: 'URC上报',
bind_context: '绑定上下文',
active_context: '当前卡槽',
slot: '卡槽',
current_slot: '当前卡槽'
};
return labels[source] || '未知';
}
/** /**
* 处理接收到的PDU短信 * 处理接收到的PDU短信
*/ */
async processPDU(pduHex) { async processPDU(input) {
try { try {
logger.info('开始解析PDU数据...'); logger.info('开始解析PDU数据...');
const smsEvent = this.normalizeIncomingPduEvent(input);
const pduHex = smsEvent.pdu;
// 解析PDU // 解析PDU
const parsed = parse(pduHex); const parsed = parse(pduHex);
@@ -40,10 +160,15 @@ class SMSProcessor {
const text = parsed.data?.getText?.() || ''; const text = parsed.data?.getText?.() || '';
const part = parsed.data?.parts?.[0]; const part = parsed.data?.parts?.[0];
const header = part?.header; const header = part?.header;
const simIdentity = this.resolveIncomingSimIdentity({
metadata: smsEvent.metadata
});
logger.info('✓ PDU解析成功'); logger.info('✓ PDU解析成功');
logger.info(`发送者: ${sender}`); logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${timestamp}`); logger.info(`时间戳: ${timestamp}`);
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
logger.info(`内容: ${text}`); logger.info(`内容: ${text}`);
// 检查是否为长短信 // 检查是否为长短信
@@ -62,11 +187,12 @@ class SMSProcessor {
partNumber, partNumber,
totalParts, totalParts,
text, text,
timestamp timestamp,
{ simIdentity }
); );
} else { } else {
// 普通短信,直接处理 // 普通短信,直接处理
await this.processSmsContent(sender, text, timestamp); await this.processSmsContent(sender, text, timestamp, { simIdentity });
} }
} catch (err) { } catch (err) {
logger.error('处理PDU失败:', err); logger.error('处理PDU失败:', err);
@@ -74,41 +200,22 @@ class SMSProcessor {
} }
/** /**
* 处理短信内容(管理员命令检查和转发) * 处理短信内容并转发
*/ */
async processSmsContent(sender, text, timestamp) { async processSmsContent(sender, text, timestamp, options = {}) {
const simIdentity = options.simIdentity !== undefined
? this.normalizeSimIdentity(options.simIdentity)
: await this.getCurrentSimIdentity();
logger.info('=== 处理短信内容 ==='); logger.info('=== 处理短信内容 ===');
logger.info(`发送者: ${sender}`); logger.info(`发送者: ${sender}`);
logger.info(`时间戳: ${timestamp}`); logger.info(`时间戳: ${timestamp}`);
logger.info(`收件SIM: ${simIdentity?.simLabel || '未知SIM'}`);
logger.info(`识别方式: ${this.formatSimSource(simIdentity?.simSource)}`);
logger.info(`内容: ${text}`); logger.info(`内容: ${text}`);
logger.info('===================='); logger.info('====================');
const messageRecord = this.addReceivedMessage(sender, text, timestamp); const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity);
// 检查是否在黑名单中
if (this.isInBlacklist(sender)) {
this.updateReceivedMessage(messageRecord.id, {
status: 'ignored',
statusText: '黑名单忽略'
});
logger.info('发送者在黑名单中,忽略该短信');
return;
}
// 检查是否为管理员命令
if (this.isAdmin(sender)) {
logger.info('收到管理员短信,检查命令...');
const trimmedText = text.trim();
if (trimmedText.startsWith('SMS:') || trimmedText === 'RESET') {
this.updateReceivedMessage(messageRecord.id, {
status: 'admin',
statusText: '管理员命令'
});
await this.processAdminCommand(sender, trimmedText);
return; // 命令已处理,不再发送普通通知
}
}
// 推送到所有通道 // 推送到所有通道
await this.pushManager.pushToAll(sender, text, timestamp); await this.pushManager.pushToAll(sender, text, timestamp);
@@ -127,13 +234,19 @@ class SMSProcessor {
/** /**
* 记录收到的短信,供Web管理端展示。 * 记录收到的短信,供Web管理端展示。
*/ */
addReceivedMessage(sender, text, timestamp) { addReceivedMessage(sender, text, timestamp, simIdentity = null) {
const normalizedSimIdentity = this.normalizeSimIdentity(simIdentity);
const message = { const message = {
id: `${Date.now()}-${++this.receivedMessageSeq}`, id: `${Date.now()}-${++this.receivedMessageSeq}`,
sender, sender,
text, text,
timestamp, timestamp,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
simId: normalizedSimIdentity?.simId || '',
simLabel: normalizedSimIdentity?.simLabel || '未知SIM',
simSlot: normalizedSimIdentity?.simSlot ?? null,
simSource: normalizedSimIdentity?.simSource || 'unknown',
smsCenter: normalizedSimIdentity?.smsCenter || '',
status: 'received', status: 'received',
statusText: '已接收' statusText: '已接收'
}; };
@@ -144,7 +257,7 @@ class SMSProcessor {
} }
this.persistReceivedMessages(); this.persistReceivedMessages();
logger.info(`短信已进入Web收件箱: ${sender}`); logger.info(`短信已进入Web收件箱: ${sender} (${message.simLabel})`);
return message; return message;
} }
@@ -163,8 +276,87 @@ class SMSProcessor {
* 获取最近收到的短信。 * 获取最近收到的短信。
*/ */
getReceivedMessages(limit = 50) { getReceivedMessages(limit = 50) {
const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages); return this.receivedMessages.slice(0, this.resolveReceivedMessagesLimit(limit));
return this.receivedMessages.slice(0, safeLimit); }
/**
* 获取按SIM作用域过滤后的短信。
*/
listReceivedMessages(limit = 50, options = {}) {
const safeLimit = this.resolveReceivedMessagesLimit(limit);
const requestedSimSlot = this.normalizeSimSlot(options.simSlot);
const hasRequestedScope = options.scope !== undefined && options.scope !== null && String(options.scope).trim() !== '';
const requestedScope = requestedSimSlot !== null
? `sim${requestedSimSlot + 1}`
: hasRequestedScope
? String(options.scope).trim().toLowerCase()
: (this.shouldDefaultToAllSimMessages() ? 'all' : 'current');
const currentSim = this.normalizeSimIdentity(options.currentSimIdentity);
const autoAllSims = !hasRequestedScope && requestedScope === 'all';
const includeAllSims = requestedSimSlot === null && requestedScope === 'all';
let effectiveScope = includeAllSims ? 'all' : 'current';
let messages = this.receivedMessages;
if (requestedSimSlot !== null) {
messages = this.receivedMessages.filter(message => this.normalizeSimSlot(message.simSlot) === requestedSimSlot);
effectiveScope = `sim${requestedSimSlot + 1}`;
} else if (this.partitionMessagesBySim && !includeAllSims) {
if (currentSim?.simId) {
messages = this.receivedMessages.filter(message => this.messageMatchesSim(message, currentSim));
} else {
messages = [];
effectiveScope = 'unavailable';
}
} else if (!this.partitionMessagesBySim) {
effectiveScope = 'all';
}
return {
messages: messages.slice(0, safeLimit),
meta: {
limit: safeLimit,
total: messages.length,
scope: effectiveScope,
requestedScope,
partitionBySim: this.partitionMessagesBySim,
allSimMessagesAllowed: this.allowAllSimMessages || includeAllSims || autoAllSims,
allSimMessagesDenied: false,
simSlot: requestedSimSlot,
currentSim,
currentSimKnown: Boolean(currentSim?.simId)
}
};
}
shouldDefaultToAllSimMessages() {
if (this.config.inbox?.dualSimDefaultScope === 'current') {
return false;
}
const simStatus = this.modem?.getCachedSimStatus?.();
return simStatus?.supported === true && (simStatus.mode === 0 || simStatus.mode === 3);
}
messageMatchesSim(message, currentSim) {
if (!message || !currentSim) {
return false;
}
if (message.simId && currentSim.simId && message.simId === currentSim.simId) {
return true;
}
const messageSlot = this.normalizeSimSlot(message.simSlot);
const currentSlot = this.normalizeSimSlot(currentSim.simSlot);
return messageSlot !== null && currentSlot !== null && messageSlot === currentSlot;
}
/**
* 限制收件箱查询条数。
*/
resolveReceivedMessagesLimit(limit = 50) {
return Math.min(Math.max(Number(limit) || 50, 1), this.maxReceivedMessages);
} }
/** /**
@@ -179,6 +371,20 @@ class SMSProcessor {
return Math.min(Math.floor(configured), 5000); return Math.min(Math.floor(configured), 5000);
} }
/**
* 是否按当前SIM隔离Web收件箱。默认开启,避免换卡后读取其他SIM历史。
*/
resolvePartitionMessagesBySim() {
return this.config.inbox?.partitionBySim !== false;
}
/**
* 是否允许显式查询全部SIM历史。默认关闭。
*/
resolveAllowAllSimMessages() {
return this.config.inbox?.allowAllSimMessages === true;
}
/** /**
* 解析收件箱持久化文件路径。 * 解析收件箱持久化文件路径。
*/ */
@@ -257,112 +463,123 @@ class SMSProcessor {
return null; return null;
} }
const simIdentity = this.normalizeStoredSimIdentity(item);
return { return {
id, id,
sender: String(item.sender || '未知号码'), sender: String(item.sender || '未知号码'),
text: String(item.text || ''), text: String(item.text || ''),
timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()), timestamp: String(item.timestamp || item.receivedAt || new Date().toISOString()),
receivedAt: String(item.receivedAt || new Date().toISOString()), receivedAt: String(item.receivedAt || new Date().toISOString()),
simId: simIdentity?.simId || '',
simLabel: simIdentity?.simLabel || '未知SIM',
simSlot: simIdentity?.simSlot ?? null,
simSource: simIdentity?.simSource || item.simSource || 'unknown',
smsCenter: '',
status: String(item.status || 'received'), status: String(item.status || 'received'),
statusText: String(item.statusText || '已接收') statusText: String(item.statusText || '已接收')
}; };
} }
/** /**
* 检查是否在黑名单中 * 从历史记录中恢复SIM身份。旧记录没有SIM字段,会归到未知SIM。
*/ */
isInBlacklist(sender) { normalizeStoredSimIdentity(item) {
const blacklist = this.config.numberBlackList || []; if (item.simId || item.simLabel) {
return this.normalizeSimIdentity({
// 检查完整号码 simId: item.simId,
if (blacklist.includes(sender)) { simLabel: item.simLabel,
return true; simSlot: item.simSlot,
simSource: item.simSource
});
} }
// 检查去除+86后的号码 if (item.sim && typeof item.sim === 'object') {
if (sender.startsWith('+86')) { return this.normalizeSimIdentity(item.sim);
const stripped = sender.substring(3);
if (blacklist.includes(stripped)) {
return true;
}
} }
return false; return null;
} }
/** /**
* 检查是否为管理员 * 获取当前SIM身份,用于收到短信时打标和Web收件箱过滤。
*/ */
isAdmin(sender) { async getCurrentSimIdentity(options = {}) {
if (!this.config.adminPhone) { const refresh = options.refresh === true;
return false; const now = Date.now();
if (!refresh && this.currentSimCheckedAt && now - this.currentSimCheckedAt < this.simIdentityTtlMs) {
return this.currentSimIdentity;
} }
let senderStr = sender; if (this.currentSimLookup) {
let adminStr = this.config.adminPhone; return this.currentSimLookup;
// 去除+86前缀
if (senderStr.startsWith('+86')) {
senderStr = senderStr.substring(3);
}
if (adminStr.startsWith('+86')) {
adminStr = adminStr.substring(3);
} }
return senderStr === adminStr; if (this.modem?.isSimSwitching?.()) {
return this.currentSimIdentity;
}
const modemSim = this.getCurrentModemSimIdentity();
if (modemSim?.simId) {
return this.setCurrentSimIdentity(modemSim);
}
this.currentSimIdentity = null;
this.currentSimCheckedAt = now;
return null;
}
getCurrentModemSimIdentity() {
const simStatus = this.modem?.getCachedSimStatus?.();
const activeSlot = simStatus?.activeSlot;
if (activeSlot === null || activeSlot === undefined) {
return this.currentSimIdentity;
}
const identity = this.createSimIdentityFromSlot(activeSlot, {
simSource: 'current_slot'
});
return identity || this.currentSimIdentity;
} }
/** /**
* 处理管理员命令 * 更新当前SIM身份缓存。
*/ */
async processAdminCommand(sender, command) { setCurrentSimIdentity(identity) {
logger.info(`处理管理员命令: ${command}`); this.currentSimIdentity = this.normalizeSimIdentity(identity);
this.currentSimCheckedAt = Date.now();
// 处理 SMS:号码:内容 命令 return this.currentSimIdentity;
if (command.startsWith('SMS:')) {
const parts = command.split(':');
if (parts.length >= 3) {
const targetPhone = parts[1].trim();
const smsContent = parts.slice(2).join(':').trim(); // 支持内容中包含冒号
logger.info(`目标号码: ${targetPhone}`);
logger.info(`短信内容: ${smsContent}`);
const success = await this.modem.sendSMS(targetPhone, smsContent);
// 发送邮件通知结果
const subject = success ? '短信发送成功' : '短信发送失败';
const body = `管理员命令执行结果:\n命令: ${command}\n目标号码: ${targetPhone}\n短信内容: ${smsContent}\n执行结果: ${success ? '成功' : '失败'}`;
await this.pushManager.sendEmail(subject, body);
} else {
logger.error('SMS命令格式错误');
await this.pushManager.sendEmail(
'命令执行失败',
'SMS命令格式错误,正确格式: SMS:号码:内容'
);
}
}
// 处理 RESET 命令
else if (command === 'RESET') {
logger.info('执行RESET命令');
// 先发送邮件通知
await this.pushManager.sendEmail(
'重启命令已执行',
'收到RESET命令,即将重启系统...'
);
// 等待邮件发送完成
await new Promise(resolve => setTimeout(resolve, 2000));
// 重启进程
logger.info('正在重启系统...');
process.exit(0); // PM2会自动重启
}
else {
logger.warn(`未知命令: ${command}`);
}
} }
normalizeSimIdentity(identity) {
if (!identity) {
return null;
}
if (typeof identity === 'string') {
return null;
}
if (typeof identity !== 'object' || Array.isArray(identity)) {
return null;
}
const simSlot = this.normalizeSimSlot(identity.simSlot ?? identity.slot);
const simId = String(identity.simId || identity.id || '').trim();
if (!simId && simSlot === null) {
return null;
}
return {
simId: simId || crypto.createHash('sha256').update(`slot:${simSlot}`).digest('hex').slice(0, 24),
simLabel: String(identity.simLabel || identity.label || (simSlot === null ? '未知SIM' : `SIM${simSlot + 1}`)),
simSlot,
simSource: String(identity.simSource || identity.source || 'unknown'),
smsCenter: ''
};
}
} }
export default SMSProcessor; export default SMSProcessor;