Compare commits

..
16 Commits
7 changed files with 433 additions and 652 deletions
+248 -306
View File
@@ -1,48 +1,47 @@
# SMS Forwarding # Node.js 4G SMS Gateway
一个运行在 Node.js 上的 4G 短信网关。程序通过串口控制 4G 模组,接收 PDU 短信,合并长短信,并把短信转发到邮件、钉钉、飞书、Telegram、PushPlus、Server 酱、Bark 或自定义 Webhook。 基于Node.js的4G短信网关,通过串口与4G模块通信,接收短信并转发到多种推送渠道。
项目同时提供 Web 管理台和 HTTP API,可用于查看模组状态、收件箱、日志、推送通道,发送短信,切换发送短信使用的 SIM,以及执行 AT 命令。 ## 功能特性
## 功能 - ✅ 4G模块AT指令交互
- ✅ 自动接收短信(PDU模式)
- 通过 AT 指令和 4G 模组通信 - ✅ 长短信自动合并
- 自动探测可用 AT 串口 - ✅ 多通道推送(邮件/钉钉/飞书/Telegram等)
- PDU 模式接收短信 - ✅ 短信发送功能
- 长短信自动合并 - ✅ 号码黑名单
- 收件箱持久化到本地文件 - ✅ 管理员远程命令
- 多推送通道转发短信 - ✅ RESTful API接口
- Web 管理台 - ✅ Web管理界面
- Token 鉴权的 HTTP API
- Web/API 发送短信
- 双 SIM 状态识别和发送前切卡
- 移动数据开关和启动断网保护
- AT 命令调试入口
## 环境要求 ## 环境要求
- Node.js 18 或更高版本 - Node.js >= 18.x
- 可通过串口访问的 4G 模组 - 4G模块通过USB转串口连接到服务器
- 已插入可收发短信的 SIM 卡 - 支持的4G模块:ML307R-DC、SIM7600等AT指令兼容模块
- Linux 下通常需要访问 `/dev/ttyUSB*` 的权限
已主要围绕 ML307A 调试。其他 AT 指令兼容模组可以运行,但双卡、移动数据、URC 卡槽识别等能力取决于模组返回的指令结果。 ## 安装
## 快速启动
```bash ```bash
npm install npm install
```
## 配置
复制配置模板:
```bash
cp config.example.json config.json cp config.example.json config.json
``` ```
编辑 `config.json`,至少确认: `config.json` 必须存在,否则程序会直接退出。最小可运行配置如下:
```json ```json
{ {
"serial": { "serial": {
"path": "/dev/ttyUSB0", "path": "/dev/ttyUSB0",
"baudRate": 115200, "baudRate": 115200,
"autoDetect": true "autoDetect": true,
"probeTimeout": 1200
}, },
"api": { "api": {
"port": 3000, "port": 3000,
@@ -51,136 +50,168 @@ cp config.example.json config.json
} }
``` ```
启动: 完整配置可参考 `config.example.json`。主要配置项如下:
```bash | 配置项 | 是否必填 | 说明 |
npm start |--------|----------|------|
``` | `serial.path` | 是 | 串口路径;Linux 常用 `/dev/ttyUSB0`,Windows 使用 `COM3` 等。`autoDetect=true` 时作为优先探测项,`autoDetect=false` 时作为实际串口。 |
| `serial.baudRate` | 是 | 串口波特率,通常为 `115200`。 |
| `serial.autoDetect` | 否 | 是否自动探测可响应 `AT` 的串口,默认按非 `false` 处理。 |
| `serial.probeTimeout` | 否 | 自动探测单个串口的超时时间,单位毫秒,默认 `1200`。 |
| `api.port` | 是 | API 和 Web 管理台监听端口。 |
| `api.webToken` | 是 | 唯一鉴权凭据;已取消 `admin/admin123` Basic Auth。 |
| `mobileData.cid` | 否 | 移动数据 PDP CID,默认 `1`;有效范围 `1-15`,不要使用 `8`。 |
| `simCards` | 否 | SIM 卡显示手机号,`slot=0` 是 SIM1,`slot=1` 是 SIM2。 |
| `smtp` | 否 | 邮件通知配置;填写 `smtp.server` 后启用。 |
| `adminPhone` | 否 | 管理员手机号;只有该号码发来的短信会执行管理员命令。 |
| `numberBlackList` | 否 | 黑名单号码列表,匹配后忽略短信。 |
| `inbox` | 否 | Web 收件箱持久化和双卡显示策略。 |
| `pushChannels` | 否 | 推送通道列表;通道开启后才要求目标字段。 |
访问管理台: `api.webToken` 可以通过以下方式传入:
```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>` - `Authorization: Bearer <token>`
- `X-Web-Token: <token>` - `X-Web-Token: <token>`
- `?token=<token>` - `?token=<token>`
- Web 管理台登录页 - Web 管理台 token 登录页
## 推送通道 ### 完整配置示例
支持的 `pushChannels[].type`: ```json
{
"serial": {
"path": "/dev/ttyUSB0",
"baudRate": 115200,
"autoDetect": true,
"probeTimeout": 1200
},
"mobileData": {
"cid": 1
},
"simCards": [
{ "slot": 0, "phoneNumber": "+8613800138000" },
{ "slot": 1, "phoneNumber": "+447700900000" }
],
"smtp": {
"server": "smtp.qq.com",
"port": 465,
"user": "your@qq.com",
"pass": "your_auth_code",
"sendTo": "recipient@example.com"
},
"adminPhone": "13800138000",
"numberBlackList": ["10086", "10010"],
"inbox": {
"maxReceivedMessages": 200,
"receivedMessagesFile": "data/received-messages.json",
"partitionBySim": true,
"allowAllSimMessages": false,
"dualSimDefaultScope": "all"
},
"pushChannels": [
{
"enabled": true,
"type": "dingtalk",
"name": "钉钉通知",
"url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
"secret": "SECxxx"
}
],
"api": {
"port": 3000,
"webToken": "change-this-token"
}
}
```
| 类型 | 必填字段 | ### 可选项细节
| --- | --- |
| `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 字符串,支持以下占位符: - `smtp`:不需要邮件时可以删除整个对象,或清空 `server`。启用邮件时需要正确填写 `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` 或 `current`;`current` 表示双卡时默认只看当前 SIM。
- `simCards`:只影响管理台显示,不影响短信收发。代码也兼容旧字段名 `simSlots`、`sims`、`sim`。
- `{sender}` ## 运行
- `{message}`
- `{timestamp}`
`enabled=false` 的通道可以保留空字段;`enabled=true` 时会校验目标字段。 ### 开发模式
```bash
npm run dev
```
## SIM 行为 ### 生产模式
```bash
npm start
```
启动初始化时程序会探测 SIM 状态。运行期间的普通状态刷新不会为了探测卡槽而持续切换 SIM。 ### 使用PM2(推荐)
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup
```
发送短信时使用当前绑定的发送 SIM。Web 管理台的切卡入口放在“发送短信”区域,切换会同时设置模组的 `SWITCHSIM` 和 `BINDSIM`,然后恢复短信直出配置。 ## API接口
短信接收使用 `CNMI` 直出模式。收到短信时,如果模组 URC 携带卡槽信息,会记录为 SIM1 或 SIM2;如果 URC 没有卡槽信息,收件箱会显示为未知 SIM。 ### 查询状态
```bash
GET /api/status
```
如果只想给管理台显示手机号,可配置: ### 发送短信
```bash
POST /api/sms/send
Content-Type: application/json
{
"phone": "13800138000",
"message": "测试短信"
}
```
### 查询收到的短信
```bash
GET /api/sms/received?limit=50
```
双卡双待模式下,默认返回全部 SIM 历史;显式传 `scope=current` 时才按当前卡槽过滤。短信记录会保留 `simSlot`、`simLabel` 与 `simSource`,用于判断收件卡槽。
也可以显式按卡槽查询:
```bash
GET /api/sms/received?limit=50&simSlot=0 # SIM1
GET /api/sms/received?limit=50&simSlot=1 # SIM2
```
直出模式下,新短信主要通过模组 `+CMT` URC 进入收件箱;如果 URC 未携带卡槽信息,会显示为未知 SIM。
### 查询信号强度
```bash
GET /api/modem/signal
```
### 查询模组信息
```bash
GET /api/modem/info
```
### 查询与切换SIM
```bash
GET /api/modem/sim
POST /api/modem/sim/switch
Content-Type: application/json
{
"slot": 1
}
```
当模组返回 `AT+DUALSIM?`、`AT+SWITCHSIM?` 与 `AT+BINDSIM?` 能力时,管理台会显示 SIM1/SIM2。短信接收使用 `CNMI` 直出模式,不为了接收短信切换 `SWITCHSIM`。如果模组上报的 URC 未携带卡槽信息,新短信会记录为未知 SIM。
管理台切卡会同时设置 `AT+SWITCHSIM` 与 `AT+BINDSIM`,随后恢复短信直出配置。发送短信前会提示当前使用的 SIM。
如果希望管理台显示手机号,可在 `config.json` 中配置:
```json ```json
{ {
@@ -191,193 +222,104 @@ pm2 save
} }
``` ```
## HTTP API ### 发送AT命令
所有 `/api/*` 接口都需要 token。下面示例使用 `X-Web-Token`:
```bash ```bash
TOKEN=change-this-token POST /api/modem/at
BASE=http://localhost:3000 Content-Type: application/json
{
"command": "AT+CSQ"
}
``` ```
### 状态和日志 ### 获取日志
```bash ```bash
curl -H "X-Web-Token: $TOKEN" "$BASE/api/status" GET /api/logs
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"
``` ```
### 收件箱 所有API均需要有效 token。可以通过 `Authorization: Bearer <token>`、`X-Web-Token` 请求头或 `token` 查询参数传入。
## 项目结构
```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/ ├── src/
│ ├── index.js # 程序入口 │ ├── index.js # 入口文件
│ ├── modem.js # 串口、AT 指令、SIM 和移动数据控制 │ ├── modem.js # 4G模块通信
│ ├── sms.js # PDU 解析、长短信处理后的转发、收件箱记录 │ ├── sms.js # 短信处理
│ ├── concat.js # 长短信分段合并 │ ├── push.js # 推送通道
│ ├── push.js # 推送通道 │ ├── concat.js # 长短信合并
│ ├── api.js # Web 管理台和 HTTP API │ ├── api.js # REST API
│ └── logger.js # 日志 │ └── logger.js # 日志系统
├── public/ # Web 管理台静态文件 ├── config.json # 配置文件
├── data/ # 收件箱持久化数据 ├── package.json
├── logs/ # 运行日志 └── README.md
├── config.example.json
├── config.json # 本地配置,不应提交
├── docker-compose.yml
├── Dockerfile
└── ecosystem.config.js
``` ```
## 常见问题 ## 推送通道类型
| 类型 | type值 | 配置项 |
|------|--------|--------|
| 钉钉机器人 | `dingtalk` | url, secret(可选) |
| 飞书机器人 | `feishu` | url, secret(可选) |
| Telegram Bot | `telegram` | url(bot token), key1(chat_id) |
| PushPlus | `pushplus` | key1(token) |
| Server酱 | `serverchan` | key1(sendkey) |
| Bark | `bark` | url(bark服务器) |
| POST JSON | `post_json` | url |
| GET请求 | `get` | url |
| 自定义模板 | `custom` | url, customBody |
`pushChannels` 中 `enabled: false` 的通道可以保留空字段;`enabled: true` 时必须配置对应目标:
- `post_json`、`bark`、`get`、`dingtalk`、`custom`、`feishu`:必须有 `url`。
- `telegram`:`url` 填 Bot Token,`key1` 填 Chat ID。
- `pushplus`:`key1` 填 PushPlus Token。
- `serverchan`:`key1` 填 Server酱 SendKey。
- `custom`:`customBody` 如果填写,必须是合法 JSON 字符串,支持 `{sender}`、`{message}`、`{timestamp}` 占位符。
## 管理员命令
通过短信发送命令(仅管理员号码):
### 远程发送短信
```
SMS:目标号码:短信内容
```
示例:`SMS:13800138000:测试消息`
### 重启系统
```
RESET
```
## 长短信处理
- 自动检测长短信(分段数 > 1)
- 使用参考号和发送者号码标识同一条长短信
- 收齐所有分段后自动合并
- 30秒超时保护(未收齐也转发)
## 故障排查
### 串口打不开 ### 串口打不开
Linux 下先确认设备存在:
```bash ```bash
# Linux检查串口设备
ls -l /dev/ttyUSB* ls -l /dev/ttyUSB*
# 添加用户到dialout组
sudo usermod -a -G dialout $USER
``` ```
如果是权限问题,把运行用户加入串口权限组,重新登录后生效: ### 模块不响应
- 检查串口路径和波特率
```bash - 检查USB连接
sudo usermod -a -G dialout "$USER" - 检查SIM卡是否插好
```
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 规则。
### 收不到短信 ### 收不到短信
- 检查AT+CEREG?网络注册状态
- 检查AT+CNMI配置是否正确
- 检查SIM卡余额和服务状态
- 确认 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 MIT
+7
View File
@@ -29,6 +29,13 @@
"pass": "your_auth_code", "pass": "your_auth_code",
"sendTo": "recipient@example.com" "sendTo": "recipient@example.com"
}, },
"_adminPhone_comment": "可选。配置后,只有该号码发来的短信会被当作管理员命令处理,例如SMS:号码:内容、RESET。",
"adminPhone": "13800138000",
"_numberBlackList_comment": "可选。列表中的号码短信会被忽略;支持带+86或不带+86的匹配。",
"numberBlackList": [
"10086",
"10010"
],
"inbox": { "inbox": {
"maxReceivedMessages": 200, "maxReceivedMessages": 200,
"receivedMessagesFile": "data/received-messages.json", "receivedMessagesFile": "data/received-messages.json",
+2 -4
View File
@@ -144,6 +144,7 @@
<span class="sim-slot__state" id="simSlot0State">--</span> <span class="sim-slot__state" id="simSlot0State">--</span>
</div> </div>
<span class="sim-slot__phone" id="simSlot0Phone">--</span> <span class="sim-slot__phone" id="simSlot0Phone">--</span>
<button class="button button--secondary button--compact" type="button" data-sim-switch="0">切到SIM1</button>
</article> </article>
<article class="sim-slot" id="simSlot1"> <article class="sim-slot" id="simSlot1">
@@ -152,6 +153,7 @@
<span class="sim-slot__state" id="simSlot1State">--</span> <span class="sim-slot__state" id="simSlot1State">--</span>
</div> </div>
<span class="sim-slot__phone" id="simSlot1Phone">--</span> <span class="sim-slot__phone" id="simSlot1Phone">--</span>
<button class="button button--secondary button--compact" type="button" data-sim-switch="1">切到SIM2</button>
</article> </article>
</div> </div>
</div> </div>
@@ -225,10 +227,6 @@
<span>当前发送SIM</span> <span>当前发送SIM</span>
<strong id="sendSimLabel">--</strong> <strong id="sendSimLabel">--</strong>
</p> </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>
<form id="smsForm" class="stack-form"> <form id="smsForm" class="stack-form">
<label class="field"> <label class="field">
<span class="field__label">目标号码</span> <span class="field__label">目标号码</span>
+24 -30
View File
@@ -70,10 +70,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
[hidden] {
display: none !important;
}
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-top: 80px; scroll-padding-top: 80px;
@@ -730,13 +726,9 @@ h3 {
gap: var(--space-md); gap: var(--space-md);
} }
.sim-slot-grid.is-single {
grid-template-columns: minmax(0, 1fr);
}
.sim-slot { .sim-slot {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 8px 12px; gap: 8px 12px;
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
@@ -745,14 +737,9 @@ h3 {
padding: 12px 14px; padding: 12px 14px;
} }
.sim-slot.is-single {
grid-template-columns: minmax(0, 1fr);
min-height: 76px;
}
.sim-slot.is-active { .sim-slot.is-active {
border-color: var(--surface-cream-strong); border-color: rgba(93, 184, 114, 0.55);
background: var(--surface-soft); box-shadow: inset 3px 0 0 var(--success);
} }
.sim-slot__topline { .sim-slot__topline {
@@ -782,8 +769,8 @@ h3 {
} }
.sim-slot.is-active .sim-slot__state { .sim-slot.is-active .sim-slot__state {
background: var(--surface-cream-strong); background: rgba(93, 184, 114, 0.16);
color: var(--body-strong); color: #2f7c44;
} }
.sim-slot__phone { .sim-slot__phone {
@@ -944,6 +931,24 @@ h3 {
color: #2f7c44; color: #2f7c44;
} }
.message-item.is-admin {
border-left: 3px solid var(--accent-amber);
}
.message-item.is-admin .message-status {
background: rgba(232, 165, 90, 0.2);
color: #8a5b16;
}
.message-item.is-ignored {
border-left: 3px solid var(--error);
}
.message-item.is-ignored .message-status {
background: rgba(198, 69, 69, 0.12);
color: var(--error);
}
.message-text { .message-text {
margin-top: 10px; margin-top: 10px;
color: var(--body); color: var(--body);
@@ -979,7 +984,7 @@ h3 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin: -4px 0 8px; margin: -4px 0 var(--space-md);
padding: 0; padding: 0;
} }
@@ -997,17 +1002,6 @@ h3 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.send-sim-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 var(--space-md);
}
.send-sim-picker .button {
min-width: 96px;
}
.field { .field {
display: grid; display: grid;
gap: var(--space-xs); gap: var(--space-xs);
+11 -80
View File
@@ -50,7 +50,6 @@ const elements = {
simStatus: document.querySelector('#simStatus'), simStatus: document.querySelector('#simStatus'),
simSlot0: document.querySelector('#simSlot0'), simSlot0: document.querySelector('#simSlot0'),
simSlot1: document.querySelector('#simSlot1'), simSlot1: document.querySelector('#simSlot1'),
simSlotGrid: document.querySelector('.sim-slot-grid'),
simSlot0State: document.querySelector('#simSlot0State'), simSlot0State: document.querySelector('#simSlot0State'),
simSlot1State: document.querySelector('#simSlot1State'), simSlot1State: document.querySelector('#simSlot1State'),
simSlot0Phone: document.querySelector('#simSlot0Phone'), simSlot0Phone: document.querySelector('#simSlot0Phone'),
@@ -254,11 +253,6 @@ function renderSendSimLabel() {
setText(elements.sendSimLabel, getActiveSimLabel()); setText(elements.sendSimLabel, getActiveSimLabel());
} }
function setSimSwitchStatus(message, type = '') {
setStatusMessage(elements.simStatus, message);
setStatusMessage(elements.smsStatus, message, type);
}
function getSimSlot(sim = {}, slotNumber) { function getSimSlot(sim = {}, slotNumber) {
return (sim.slots || []).find(slot => slot.slot === slotNumber) || { return (sim.slots || []).find(slot => slot.slot === slotNumber) || {
slot: slotNumber, slot: slotNumber,
@@ -287,18 +281,7 @@ function renderSimSlot(slot, card, stateNode, phoneNode) {
} }
card.classList.toggle('is-active', Boolean(slot.active)); card.classList.toggle('is-active', Boolean(slot.active));
if (slot.active && slot.present === false) { setText(stateNode, slot.active ? '当前使用' : '未选中');
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); const phoneText = formatSimSlotPhone(slot);
if (phoneNode) { if (phoneNode) {
phoneNode.textContent = phoneText; phoneNode.textContent = phoneText;
@@ -306,81 +289,29 @@ function renderSimSlot(slot, card, stateNode, phoneNode) {
} }
} }
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 = {}) { function renderSimStatus(sim = {}) {
state.currentSim = sim; state.currentSim = sim;
const supported = Boolean(sim.supported && sim.canSwitch); const supported = Boolean(sim.supported && sim.canSwitch);
const activeSlot = getActiveSimSlot(sim); const activeSlot = getActiveSimSlot(sim);
const modeLabel = getSimModeLabel(sim, activeSlot); const modeLabel = supported ? (sim.modeLabel || '双卡') : '单卡/未检测到双卡';
setText(elements.simMode, modeLabel); setText(elements.simMode, modeLabel);
const visibleSlots = getVisibleSimSlots(sim, activeSlot); renderSimSlot(getSimSlot(sim, 0), elements.simSlot0, elements.simSlot0State, elements.simSlot0Phone);
const singleSlot = visibleSlots.length === 1; renderSimSlot(getSimSlot(sim, 1), elements.simSlot1, elements.simSlot1State, elements.simSlot1Phone);
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) => { elements.simSwitchButtons.forEach((button) => {
const slot = Number.parseInt(button.dataset.simSwitch, 10); const slot = Number.parseInt(button.dataset.simSwitch, 10);
const slotInfo = getSimSlot(sim, slot);
const isActive = activeSlot?.slot === slot; const isActive = activeSlot?.slot === slot;
const unavailable = slotInfo.present === false; button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive;
button.hidden = singleSlot; button.textContent = isActive ? '正在使用' : `切到SIM${slot + 1}`;
button.disabled = state.simBusy || Boolean(sim.switching) || !supported || isActive || unavailable;
button.textContent = isActive ? `SIM${slot + 1} 当前` : `切到SIM${slot + 1}`;
}); });
if (state.simBusy || sim.switching) { if (state.simBusy || sim.switching) {
setSimSwitchStatus('正在切换SIM...'); setStatusMessage(elements.simStatus, '正在切换SIM...');
} else if (supported && activeSlot?.present === true) { } else if (supported && activeSlot) {
setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`); setStatusMessage(elements.simStatus, `当前 ${getSimDisplayLabel(activeSlot)}`, 'success');
} else if (supported && activeSlot?.present === false) {
setStatusMessage(elements.simStatus, `${getSimDisplayLabel(activeSlot)} 未就绪,请检查是否插卡`);
} else if (!supported) { } else if (!supported) {
setStatusMessage(elements.simStatus, '双卡不可用'); setStatusMessage(elements.simStatus, '双卡不可用');
} else { } else {
@@ -872,10 +803,10 @@ async function switchSim(slot) {
body: JSON.stringify({ slot: slotNumber }) body: JSON.stringify({ slot: slotNumber })
}); });
renderSimStatus(result.data || {}); renderSimStatus(result.data || {});
setSimSwitchStatus(result.message || `已切换到SIM${slotNumber + 1}`, 'success'); setStatusMessage(elements.simStatus, result.message || `已切换到SIM${slotNumber + 1}`, 'success');
await refreshAll(); await refreshAll();
} catch (err) { } catch (err) {
setSimSwitchStatus(err.message, 'error'); setStatusMessage(elements.simStatus, err.message, 'error');
await refreshSim().catch(() => {}); await refreshSim().catch(() => {});
} finally { } finally {
state.simBusy = false; state.simBusy = false;
+17 -229
View File
@@ -33,7 +33,6 @@ class ModemManager extends EventEmitter {
switchSlot: null, switchSlot: null,
bindSlot: null, bindSlot: null,
switching: false, switching: false,
probing: false,
slots: this.createSimSlots(), slots: this.createSimSlots(),
lastCheckedAt: null, lastCheckedAt: null,
error: '', error: '',
@@ -64,7 +63,6 @@ class ModemManager extends EventEmitter {
slot, slot,
name: `SIM${slot + 1}`, name: `SIM${slot + 1}`,
active: false, active: false,
present: null,
phoneNumber, phoneNumber,
phoneLabel: phoneNumber || `SIM${slot + 1}`, phoneLabel: phoneNumber || `SIM${slot + 1}`,
lastCheckedAt: null lastCheckedAt: null
@@ -540,25 +538,6 @@ class ModemManager extends EventEmitter {
})); }));
} }
updateSimSlotPresence(status, slotNumber, present) {
const slot = this.normalizeSimSlot(slotNumber);
if (slot === null) {
return;
}
status.slots = status.slots.map(item => {
if (item.slot !== slot) {
return item;
}
return {
...item,
present: Boolean(present),
lastCheckedAt: new Date().toISOString()
};
});
}
getBusinessSimSlot(status = this.sim) { getBusinessSimSlot(status = this.sim) {
return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot); return this.normalizeSimSlot(status.bindSlot) ?? this.normalizeSimSlot(status.switchSlot);
} }
@@ -658,27 +637,14 @@ class ModemManager extends EventEmitter {
retryDelay = 2000, retryDelay = 2000,
settleDelay = 3000, settleDelay = 3000,
confirmTimeout = 15000, confirmTimeout = 15000,
confirmInterval = 1000, confirmInterval = 1000
simReadyAttempts = 10,
simReadyDelay = 1000
} = options; } = options;
const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot); const currentSlot = await this.querySwitchSimSlot().catch(() => this.sim.switchSlot);
this.sim.switchSlot = currentSlot; this.sim.switchSlot = currentSlot;
if (currentSlot === targetSlot) { if (currentSlot === targetSlot) {
try { await this.waitSIMReady();
await this.waitSIMReady({
attempts: simReadyAttempts,
delay: simReadyDelay,
label: `SIM${targetSlot + 1}`
});
this.updateSimSlotPresence(this.sim, targetSlot, true);
} catch (err) {
this.updateSimSlotPresence(this.sim, targetSlot, false);
throw err;
}
return targetSlot; return targetSlot;
} }
@@ -708,155 +674,11 @@ class ModemManager extends EventEmitter {
this.sim.switchSlot = targetSlot; this.sim.switchSlot = targetSlot;
this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot); this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? targetSlot);
await this.sleep(settleDelay); await this.sleep(settleDelay);
await this.waitSIMReady();
try {
await this.waitSIMReady({
attempts: simReadyAttempts,
delay: simReadyDelay,
label: `SIM${targetSlot + 1}`
});
this.updateSimSlotPresence(this.sim, targetSlot, true);
} catch (err) {
this.updateSimSlotPresence(this.sim, targetSlot, false);
await this.restoreSwitchSimSlot(currentSlot, `${context}失败后回退`).catch((restoreErr) => {
logger.warn(`${context}失败后回退SIM失败: ${restoreErr.message}`);
});
throw err;
}
return targetSlot; return targetSlot;
} }
async restoreSwitchSimSlot(slotNumber, context = 'SIM回退', timeout = 20000) {
const slot = this.normalizeSimSlot(slotNumber);
if (slot === null) {
return false;
}
logger.warn(`${context}: 恢复到SIM${slot + 1}`);
await this.sendATCommand(`AT+SWITCHSIM=${slot}`, 10000).catch((err) => {
logger.warn(`${context}命令失败: ${err.message}`);
});
const restored = await this.waitForSwitchSimSlot(slot, timeout, 1000);
if (restored) {
this.sim.switchSlot = slot;
this.markActiveSimSlot(this.sim, this.getBusinessSimSlot(this.sim) ?? slot);
return true;
}
logger.warn(`${context}: 未能确认恢复到SIM${slot + 1}`);
return false;
}
async restoreBindSimSlot(slotNumber, context = '业务SIM回退') {
const slot = this.normalizeSimSlot(slotNumber);
if (slot === null) {
return false;
}
await this.sendATWithRetry(`AT+BINDSIM=${slot}`, {
timeout: 5000,
retries: 1,
retryDelay: 500,
label: `${context}到SIM${slot + 1}`
}).catch((err) => {
logger.warn(`${context}到SIM${slot + 1}失败: ${err.message}`);
});
return true;
}
async restoreSimAfterProbe(switchSlot, bindSlot, context = 'SIM探测后恢复') {
const targetSwitchSlot = this.normalizeSimSlot(switchSlot);
const targetBindSlot = this.normalizeSimSlot(bindSlot) ?? targetSwitchSlot;
if (targetSwitchSlot !== null) {
await this.restoreSwitchSimSlot(targetSwitchSlot, context).catch((err) => {
logger.warn(`${context}切回SIM${targetSwitchSlot + 1}失败: ${err.message}`);
});
}
if (targetBindSlot !== null) {
await this.restoreBindSimSlot(targetBindSlot, context);
}
}
async probeInactiveSimSlot(status, slotNumber, originalSlot, originalBindSlot = originalSlot) {
const targetSlot = this.normalizeSimSlot(slotNumber);
const restoreSlot = this.normalizeSimSlot(originalSlot);
const restoreBindSlot = this.normalizeSimSlot(originalBindSlot) ?? restoreSlot;
if (targetSlot === null || restoreSlot === null || targetSlot === restoreSlot) {
return false;
}
logger.info(`探测SIM${targetSlot + 1}是否可用`);
let switched = false;
try {
const resp = await this.sendATCommand(`AT+SWITCHSIM=${targetSlot}`, 10000);
if (!resp.includes('OK')) {
logger.warn(`探测SIM${targetSlot + 1}切换返回非OK: ${this.formatATResponse(resp)}`);
}
switched = await this.waitForSwitchSimSlot(targetSlot, 15000, 1000);
if (!switched) {
this.updateSimSlotPresence(status, targetSlot, false);
return false;
}
await this.sleep(1000);
await this.waitSIMReady({
attempts: 5,
delay: 1000,
label: `SIM${targetSlot + 1}`
});
this.updateSimSlotPresence(status, targetSlot, true);
return true;
} catch (err) {
logger.warn(`探测SIM${targetSlot + 1}失败: ${err.message}`);
this.updateSimSlotPresence(status, targetSlot, false);
return false;
} finally {
if (switched) {
await this.restoreSimAfterProbe(restoreSlot, restoreBindSlot, `探测SIM${targetSlot + 1}后恢复`);
}
}
}
async probeSimSlotPresence(status, currentSlot) {
const originalSlot = this.normalizeSimSlot(currentSlot);
if (!status.supported || !status.canSwitch || originalSlot === null) {
return status;
}
if (this.sim.switching || this.sim.probing) {
return status;
}
const originalBindSlot = this.normalizeSimSlot(status.bindSlot) ?? originalSlot;
this.sim.probing = true;
try {
for (const slot of status.slots) {
if (slot.slot !== originalSlot) {
await this.probeInactiveSimSlot(status, slot.slot, originalSlot, originalBindSlot);
}
}
await this.restoreSimAfterProbe(originalSlot, originalBindSlot, 'SIM探测结束恢复');
const restoredSlot = await this.querySwitchSimSlot().catch(() => originalSlot);
status.switchSlot = this.normalizeSimSlot(restoredSlot) ?? originalSlot;
status.bindSlot = await this.queryBindSimSlot();
this.markActiveSimSlot(status, this.getBusinessSimSlot(status));
return status;
} finally {
this.sim.probing = false;
status.probing = false;
}
}
async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) { async waitForSwitchSimSlot(targetSlot, timeout = 15000, interval = 1000) {
const startedAt = Date.now(); const startedAt = Date.now();
@@ -984,27 +806,12 @@ class ModemManager extends EventEmitter {
} }
} }
isSimReadyResponse(resp) {
return String(resp || '').includes('+CPIN: READY');
}
async isCurrentSimReady(timeout = 2000) {
try {
const resp = await this.sendATCommand('AT+CPIN?', timeout);
return this.isSimReadyResponse(resp);
} catch (err) {
logger.debug(`查询当前SIM就绪状态失败: ${err.message}`);
return false;
}
}
async getSimStatus(options = {}) { async getSimStatus(options = {}) {
return await this.refreshSimStatus(options); return await this.refreshSimStatus(options);
} }
async refreshSimStatus(options = {}) { async refreshSimStatus(options = {}) {
const refreshIdentity = options.refreshIdentity === true; const refreshIdentity = options.refreshIdentity === true;
const probeSlots = options.probeSlots === true;
const status = { const status = {
...this.sim, ...this.sim,
slots: this.sim.slots.map(slot => ({ ...slot })), slots: this.sim.slots.map(slot => ({ ...slot })),
@@ -1032,8 +839,6 @@ class ModemManager extends EventEmitter {
if (switchSlot !== null) { if (switchSlot !== null) {
status.canSwitch = true; status.canSwitch = true;
status.switchSlot = switchSlot; status.switchSlot = switchSlot;
const currentSimReady = await this.isCurrentSimReady();
this.updateSimSlotPresence(status, switchSlot, currentSimReady);
} else { } else {
status.switchSlot = null; status.switchSlot = null;
status.error = '未能读取当前AT SIM卡槽'; status.error = '未能读取当前AT SIM卡槽';
@@ -1047,10 +852,6 @@ class ModemManager extends EventEmitter {
this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber); this.updateSimSlotPhoneNumber(status, switchSlot, phoneNumber);
} }
if (probeSlots && status.canSwitch && switchSlot !== null) {
await this.probeSimSlotPresence(status, switchSlot);
}
status.lastCheckedAt = new Date().toISOString(); status.lastCheckedAt = new Date().toISOString();
this.sim = status; this.sim = status;
return this.getCachedSimStatus(); return this.getCachedSimStatus();
@@ -1095,20 +896,12 @@ class ModemManager extends EventEmitter {
await this.switchSimForIdentityRead(targetSlot, { await this.switchSimForIdentityRead(targetSlot, {
context: `切换SIM${targetSlot + 1}`, context: `切换SIM${targetSlot + 1}`,
purpose: '用于业务绑定', purpose: '用于业务绑定',
retries: 2, retries: 4,
retryDelay: 1000, retryDelay: 3000,
settleDelay: 1000, settleDelay: 3000
confirmTimeout: 5000,
confirmInterval: 1000,
simReadyAttempts: 3,
simReadyDelay: 1000
}); });
} else { } else {
await this.waitSIMReady({ await this.waitSIMReady();
attempts: 3,
delay: 1000,
label: `SIM${targetSlot + 1}`
});
} }
if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) { if (this.normalizeSimSlot(before.bindSlot) !== targetSlot) {
@@ -1960,7 +1753,7 @@ class ModemManager extends EventEmitter {
// 7. 按文档配置短信功能,并启用本项目需要的PDU模式 // 7. 按文档配置短信功能,并启用本项目需要的PDU模式
await this.configureSMS(); await this.configureSMS();
await this.refreshSimStatus({ refreshIdentity: true, probeSlots: true }); await this.refreshSimStatus({ refreshIdentity: true });
logger.info('模组初始化完成'); logger.info('模组初始化完成');
this.emit('ready'); this.emit('ready');
@@ -1969,29 +1762,24 @@ class ModemManager extends EventEmitter {
/** /**
* 等待SIM卡完成初始化 * 等待SIM卡完成初始化
*/ */
async waitSIMReady(options = {}) { async waitSIMReady() {
const attempts = options.attempts ?? 10; for (let attempt = 1; attempt <= 10; attempt++) {
const delay = options.delay ?? 1000;
const timeout = options.timeout ?? 2000;
const label = options.label || 'SIM卡';
for (let attempt = 1; attempt <= attempts; attempt++) {
try { try {
const resp = await this.sendATCommand('AT+CPIN?', timeout); const resp = await this.sendATCommand('AT+CPIN?', 2000);
if (this.isSimReadyResponse(resp)) { if (resp.includes('+CPIN: READY')) {
logger.info(`✓ ${label}已就绪`); logger.info('✓ SIM卡已就绪');
return; return;
} }
logger.warn(`${label}未就绪(${attempt}/${attempts}): ${this.formatATResponse(resp)}`); logger.warn(`SIM卡未就绪(${attempt}/10): ${this.formatATResponse(resp)}`);
} catch (err) { } catch (err) {
logger.warn(`查询${label}状态失败(${attempt}/${attempts}): ${err.message}`); logger.warn(`查询SIM卡状态失败(${attempt}/10): ${err.message}`);
} }
await this.sleep(delay); await this.sleep(1000);
} }
throw new Error(`${label}未就绪,可能未插卡或SIM初始化失败`); throw new Error('SIM卡未就绪');
} }
/** /**
+122 -1
View File
@@ -200,7 +200,7 @@ class SMSProcessor {
} }
/** /**
* 处理短信内容并转发 * 处理短信内容(管理员命令检查和转发)
*/ */
async processSmsContent(sender, text, timestamp, options = {}) { async processSmsContent(sender, text, timestamp, options = {}) {
const simIdentity = options.simIdentity !== undefined const simIdentity = options.simIdentity !== undefined
@@ -217,6 +217,31 @@ class SMSProcessor {
const messageRecord = this.addReceivedMessage(sender, text, timestamp, simIdentity); 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);
@@ -580,6 +605,102 @@ class SMSProcessor {
}; };
} }
/**
* 检查是否在黑名单中
*/
isInBlacklist(sender) {
const blacklist = this.config.numberBlackList || [];
// 检查完整号码
if (blacklist.includes(sender)) {
return true;
}
// 检查去除+86后的号码
if (sender.startsWith('+86')) {
const stripped = sender.substring(3);
if (blacklist.includes(stripped)) {
return true;
}
}
return false;
}
/**
* 检查是否为管理员
*/
isAdmin(sender) {
if (!this.config.adminPhone) {
return false;
}
let senderStr = sender;
let adminStr = this.config.adminPhone;
// 去除+86前缀
if (senderStr.startsWith('+86')) {
senderStr = senderStr.substring(3);
}
if (adminStr.startsWith('+86')) {
adminStr = adminStr.substring(3);
}
return senderStr === adminStr;
}
/**
* 处理管理员命令
*/
async processAdminCommand(sender, command) {
logger.info(`处理管理员命令: ${command}`);
// 处理 SMS:号码:内容 命令
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 sendResult = await this.modem.sendSMS(targetPhone, smsContent);
const success = Boolean(sendResult?.success);
// 发送邮件通知结果
const subject = success ? '短信发送成功' : '短信发送失败';
const body = `管理员命令执行结果:\n命令: ${command}\n发送SIM: ${sendResult?.simLabel || '未知SIM'}\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}`);
}
}
} }
export default SMSProcessor; export default SMSProcessor;