feat: publish dbussmsforward refactor
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# .NET
|
||||
bin/
|
||||
obj/
|
||||
TestResults/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Runtime/config secrets
|
||||
*.log
|
||||
.env
|
||||
appsettings.local.json
|
||||
appsettings.*.local.json
|
||||
run-records/
|
||||
.runtime/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,531 @@
|
||||
# DbusSmsForward Debian 重构状态报告与下一步落地清单
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 基于当前仓库实际状态,给出一份 Debian-first 的真实重构进度报告,并列出后续最小可落地的执行清单,避免再把方向带回 OpenWRT。
|
||||
|
||||
**Architecture:** 当前仓库处于双轨状态:保留 C# Worker 主线,同时新增 Python 重构线。Python 线已经形成可测试的运行时核心,但 Web 管理层和 Debian 交付闭环尚未完成;C# 线则有 Debian 友好的部分配置改造,但还不是完整的 Web 化交付形态。
|
||||
|
||||
**Tech Stack:** Python 3.11、unittest、CLI runtime、ModemManager/DBus 抽象、C# .NET 8 Worker、Systemd-oriented configuration
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
### 1. 当前方向应明确为 Debian-first
|
||||
本项目当前应围绕:
|
||||
- Debian 运行
|
||||
- ModemManager/DBus 短信接收
|
||||
- Web 管理
|
||||
- 不要求在 Debian 目标机上编译源码
|
||||
|
||||
不应再把“下一步推进目标”描述为 OpenWRT 部署。
|
||||
|
||||
### 2. 当前重构不是没进展,但也还没到 Debian Web 成品
|
||||
仓库里的 Python 重构线已经完成了“核心运行层”的大量工作:
|
||||
- 配置模型
|
||||
- 短信来源抽象
|
||||
- 文件源/ModemManager 源
|
||||
- sender 分发
|
||||
- AutmanPush 错误结构化
|
||||
- 轮询、去重、fetch issue 统计
|
||||
- CLI 基本闭环
|
||||
- 自动化测试覆盖
|
||||
|
||||
但 **Debian Web 管理版本身尚未完成**,因为还缺:
|
||||
- HTTP 服务入口
|
||||
- Web API
|
||||
- 前端页面
|
||||
- 配置读写 API
|
||||
- 状态 API
|
||||
- systemd/service 交付文件
|
||||
- Debian 部署说明与目录规范
|
||||
|
||||
### 3. C# 主线仍存在,但并非当前可直接交付的 Debian Web 版本
|
||||
C# 线保留了较多历史功能与 Debian 友好的配置思路,但从当前文件状态看,它仍然是 Worker 应用,不是完整 Web 项目,且存在引用类与当前可见文件状态不完全一致的问题,因此不适合作为“已完成的 Debian Web 版”来对外描述。
|
||||
|
||||
---
|
||||
|
||||
## 二、实际检查到的仓库状态
|
||||
|
||||
仓库路径:`/Users/chick/.Hermes/workspace/DbusSmsForward`
|
||||
|
||||
### 目录结构观察
|
||||
- C# 主线目录:`DbusTest/`
|
||||
- Python 重构目录:`src/dbussmsforward/`
|
||||
- Python 测试目录:`tests/`
|
||||
- README:`README.md`
|
||||
|
||||
### 粗略文件数量
|
||||
- `DbusTest/` 文件数:35
|
||||
- `src/dbussmsforward/` 文件数:14
|
||||
- `tests/` 文件数:9
|
||||
|
||||
这说明仓库现在不是“只剩一个新实现”,而是明确的双轨并存状态。
|
||||
|
||||
---
|
||||
|
||||
## 三、Python 重构线:已完成内容
|
||||
|
||||
### 1. 配置模型已成型
|
||||
已检查文件:
|
||||
- `src/dbussmsforward/config.py`
|
||||
|
||||
已具备:
|
||||
- `DaemonConfig`
|
||||
- `ForwardingConfig`
|
||||
- `AutmanPushConfig`
|
||||
- `SmsSourceConfig`
|
||||
- `NotificationChannels`
|
||||
- `AppConfig`
|
||||
- legacy `appSettings/appsettings` 到新模型的兼容映射
|
||||
|
||||
说明:Python 线已经不是零散脚本,而是具备明确配置边界的可持续实现。
|
||||
|
||||
### 2. 短信源抽象已成型
|
||||
已检查文件:
|
||||
- `src/dbussmsforward/source.py`
|
||||
|
||||
已具备:
|
||||
- `FileSmsSource`
|
||||
- `ModemManagerSmsSource`
|
||||
- `PythonDbusModemManagerProxy`
|
||||
- `FetchIssue`
|
||||
|
||||
说明:
|
||||
- `modemmanager` source 是 Debian-first 能力,不是 OpenWRT 专属。
|
||||
- 当前 Python 线已经具备在 Debian + ModemManager 环境继续推进的正确技术基础。
|
||||
|
||||
### 3. 通知分发核心已成型
|
||||
已检查文件:
|
||||
- `src/dbussmsforward/notifications.py`
|
||||
- `src/dbussmsforward/app.py`
|
||||
|
||||
已具备:
|
||||
- console sender
|
||||
- AutmanPush sender
|
||||
- `DispatchOutcome`
|
||||
- `SenderDispatchResult`
|
||||
- sender 级别失败记录
|
||||
- HTTP/timeout/network/unknown 分类
|
||||
- HTTP 响应片段摘录
|
||||
- partial success 保留
|
||||
- dedupe
|
||||
- 轮询统计
|
||||
- fetch issue 聚合
|
||||
|
||||
### 4. CLI 基本闭环已经存在
|
||||
已检查文件:
|
||||
- `src/dbussmsforward/cli.py`
|
||||
|
||||
当前命令:
|
||||
- `run`
|
||||
- `send`
|
||||
- `validate-config`
|
||||
- `print-sample-config`
|
||||
|
||||
说明:
|
||||
- 这已经满足“核心运行原型”的最低闭环。
|
||||
- 但还不是 Web 管理闭环。
|
||||
|
||||
---
|
||||
|
||||
## 四、Python 重构线:验证状态
|
||||
|
||||
### 1. CLI 测试
|
||||
命令:
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest tests.test_cli -v
|
||||
```
|
||||
结果:10/10 通过
|
||||
|
||||
### 2. 运行时流程测试
|
||||
命令:
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest tests.test_runtime_flow -v
|
||||
```
|
||||
结果:16/16 通过
|
||||
|
||||
### 3. 配置模型测试
|
||||
命令:
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest tests.test_config_model -v
|
||||
```
|
||||
结果:4/4 通过
|
||||
|
||||
### 4. 关于“全量测试”口径的注意事项
|
||||
如果直接运行:
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
会失败,原因不是测试逻辑本身坏了,而是当前仓库还没有把 `PYTHONPATH=src` 或等价安装流程固化到默认测试入口里,导致:
|
||||
- `test_config_model`
|
||||
- `test_runtime_flow`
|
||||
在 discover 模式下导入 `dbussmsforward` 失败
|
||||
|
||||
这说明:
|
||||
- **测试内容本身大体可用**
|
||||
- **但仓库级测试入口还没标准化**
|
||||
|
||||
这属于 Debian 交付前必须补齐的一环。
|
||||
|
||||
---
|
||||
|
||||
## 五、C# 主线:当前真实状态
|
||||
|
||||
### 1. 仍是 Worker,不是 Web 项目
|
||||
已检查文件:
|
||||
- `DbusTest/DbusSmsForward.csproj`
|
||||
|
||||
当前项目 SDK:
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
```
|
||||
|
||||
说明:
|
||||
- 这不是 `Microsoft.NET.Sdk.Web`
|
||||
- 当前 C# 主线不能被称为“已完成 Web 化”
|
||||
|
||||
### 2. 有 Debian 友好的配置痕迹
|
||||
已检查文件:
|
||||
- `DbusTest/Helper/ConfigHelper.cs`
|
||||
|
||||
确认存在:
|
||||
- `/etc/dbussmsforward/appsettings.json` 的优先加载路径
|
||||
- `DBUSSMSFORWARD_CONFIG_PATH` 环境变量支持
|
||||
- `SaveOptions` / `LoadOptions`
|
||||
- 旧配置与新配置转换
|
||||
|
||||
说明:
|
||||
- C# 线并非完全无效,已经有面向 Debian 服务运行的配置设计。
|
||||
- 但这只是“服务化/配置化能力”,不是完整 Web 管理版。
|
||||
|
||||
### 3. Program.cs 依赖的若干类当前未直接看到实现文件
|
||||
在 `DbusTest/Program.cs` 中可见引用:
|
||||
- `SampleConfigFactory`
|
||||
- `LegacyConfigurationBridge`
|
||||
- `CommandLineConfigurationApplier`
|
||||
- `ConfigurationValidator`
|
||||
|
||||
但本轮目录巡检中未直接发现对应文件,说明至少有以下一种情况:
|
||||
- 文件缺失
|
||||
- 尚未提交完整
|
||||
- 放在当前未显式识别的位置
|
||||
- 或重构过程中断过
|
||||
|
||||
因此,**当前不应把 C# 主线描述为“已稳定可构建的 Debian Web 版”。**
|
||||
|
||||
### 4. README 仍是旧交付叙事
|
||||
已检查文件:
|
||||
- `README.md`
|
||||
|
||||
当前 README 仍主要描述:
|
||||
- 直接运行旧二进制
|
||||
- `-fE/-fP/-fW/...` 旧参数模式
|
||||
- Native AOT 交叉编译
|
||||
|
||||
没有覆盖:
|
||||
- Python 重构线的运行方式
|
||||
- Debian-first Web 管理目标
|
||||
- 统一部署方式
|
||||
- systemd 落地方式
|
||||
|
||||
这意味着:
|
||||
- 仓库文档叙事仍停留在旧时代
|
||||
- 这会持续误导后续判断与交付方向
|
||||
|
||||
---
|
||||
|
||||
## 六、当前最准确的项目分层判断
|
||||
|
||||
### 已完成层
|
||||
1. Python 核心配置模型
|
||||
2. Python 短信源抽象(含 ModemManager)
|
||||
3. Python 通知分发运行时
|
||||
4. Python CLI 基本闭环
|
||||
5. 单元/流程级测试覆盖的大部分核心行为
|
||||
|
||||
### 半完成层
|
||||
1. C# Debian 服务化配置思路
|
||||
2. legacy -> 新配置兼容桥接思路
|
||||
3. 多通知渠道的旧实现保留
|
||||
|
||||
### 明显未完成层
|
||||
1. Debian Web API
|
||||
2. Debian Web 前端
|
||||
3. Web 管理配置读写
|
||||
4. 服务状态查看接口
|
||||
5. 消息查看/发送 API
|
||||
6. systemd/service 交付物
|
||||
7. Debian 安装/升级/目录规范
|
||||
8. 统一测试入口
|
||||
9. README/部署文档重写
|
||||
|
||||
---
|
||||
|
||||
## 七、建议的方向收敛
|
||||
|
||||
### 建议主路线
|
||||
**以 Python 线作为 Debian-first 主实现继续推进。**
|
||||
|
||||
原因:
|
||||
1. Python 线目前已经具备可测试、可迭代的核心运行时
|
||||
2. 已有 ModemManager 抽象,技术方向与 Debian 目标一致
|
||||
3. 当前 Web 化缺口更适合作为 Python 层增量补齐
|
||||
4. C# 线当前更适合做“参考/兼容来源”,不适合作为当前主交付叙事
|
||||
|
||||
### C# 线建议定位
|
||||
短期内将 C# 线定义为:
|
||||
- 历史主线
|
||||
- 兼容配置与行为参考来源
|
||||
- 功能映射对照对象
|
||||
|
||||
而不是当前 Debian Web 交付主线。
|
||||
|
||||
---
|
||||
|
||||
## 八、下一步落地清单(按优先级排序)
|
||||
|
||||
下面这份清单按“最小可落地 Debian Web 版”来排,不再发散。
|
||||
|
||||
### 阶段 0:先把工程入口和叙事统一(高优先级,半天内可做)
|
||||
|
||||
#### Task 0.1:补统一测试入口
|
||||
**Objective:** 让仓库级测试命令不依赖手工写 `PYTHONPATH=src`
|
||||
|
||||
**Files:**
|
||||
- Create: `pyproject.toml` 或 `pytest.ini` / `tox.ini`(择一)
|
||||
- Optionally modify: `README.md`
|
||||
|
||||
**Expected outcome:**
|
||||
以下命令之一可直接工作:
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
或
|
||||
```bash
|
||||
python3 -m pytest -q
|
||||
```
|
||||
|
||||
#### Task 0.2:补 Python 运行入口说明
|
||||
**Objective:** 把当前 Python 线的实际运行方式写清楚
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Must include:**
|
||||
- Python CLI 启动方式
|
||||
- `PYTHONPATH`/安装方式
|
||||
- `validate-config` / `run` / `send`
|
||||
- file source 与 modemmanager source 的示例配置
|
||||
|
||||
#### Task 0.3:明确仓库主线状态说明
|
||||
**Objective:** 在 README 顶部写明当前仓库是 C# 历史主线 + Python Debian 重构线并存
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
**Must include:**
|
||||
- 当前推荐继续投入的是 Python Debian-first 线
|
||||
- C# 线主要用于兼容参考,不宣称已完成 Web 化
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:补最小 Debian Web API(最高业务优先级)
|
||||
|
||||
#### Task 1.1:新增 Web 服务入口模块
|
||||
**Objective:** 在 Python 线增加最小 HTTP 服务,而不破坏 CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/dbussmsforward/web.py`
|
||||
- Modify: `src/dbussmsforward/cli.py`
|
||||
- Modify: `src/dbussmsforward/config.py`
|
||||
|
||||
**API baseline:**
|
||||
- `GET /api/health`
|
||||
- `GET /api/system/status`
|
||||
- `GET /api/settings`
|
||||
- `POST /api/settings`
|
||||
- `POST /api/sms/send`
|
||||
|
||||
#### Task 1.2:补 Web 层测试
|
||||
**Files:**
|
||||
- Create: `tests/test_web.py`
|
||||
|
||||
**Validation:**
|
||||
- 健康检查返回 200
|
||||
- 设置读取/写入生效
|
||||
- send API 能触发 dispatcher
|
||||
- 错误配置返回结构化 4xx
|
||||
|
||||
#### Task 1.3:定义配置文件持久化位置
|
||||
**Objective:** 对齐 Debian 运行约定
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/dbussmsforward/cli.py`
|
||||
- Modify: `src/dbussmsforward/web.py`
|
||||
- Create: `appsettings.example.json`
|
||||
|
||||
**Recommended path order:**
|
||||
1. `--config`
|
||||
2. `DBUSSMSFORWARD_CONFIG_PATH`
|
||||
3. `/etc/dbussmsforward/appsettings.json`
|
||||
4. 项目内默认示例路径
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:补最小前端管理页(次高优先级)
|
||||
|
||||
#### Task 2.1:加纯静态前端
|
||||
**Files:**
|
||||
- Create: `wwwroot/index.html`
|
||||
- Create: `wwwroot/app.js`
|
||||
- Create: `wwwroot/app.css`
|
||||
|
||||
**UI minimum scope:**
|
||||
- 当前运行状态
|
||||
- 当前配置查看
|
||||
- 基础配置编辑保存
|
||||
- 手工发送短信/测试通知
|
||||
|
||||
#### Task 2.2:把前端挂到 Web 服务
|
||||
**Files:**
|
||||
- Modify: `src/dbussmsforward/web.py`
|
||||
|
||||
**Validation:**
|
||||
- 浏览器可直接打开管理页
|
||||
- 前端能调用 `settings` / `status` / `send`
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:补 Debian 部署闭环
|
||||
|
||||
#### Task 3.1:新增 systemd service 文件
|
||||
**Files:**
|
||||
- Create: `deploy/dbussmsforward.service`
|
||||
|
||||
**Must include:**
|
||||
- `Environment=DBUSSMSFORWARD_CONFIG_PATH=/etc/dbussmsforward/appsettings.json`
|
||||
- `WorkingDirectory=/opt/dbussmsforward`
|
||||
- `ExecStart=` 指向 Python 启动方式或打包后的启动方式
|
||||
- `Restart=always`
|
||||
|
||||
#### Task 3.2:新增 Debian 部署说明
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Optionally create: `docs/debian-deploy.md`
|
||||
|
||||
**Must include:**
|
||||
- 依赖安装
|
||||
- 目录布局
|
||||
- 配置文件位置
|
||||
- systemd 安装与启动
|
||||
- 日志查看方式
|
||||
- 升级步骤
|
||||
|
||||
#### Task 3.3:明确“不在 Debian 目标机编译”的交付方式
|
||||
**推荐选项二选一:**
|
||||
1. Python wheel / source bundle + venv 安装
|
||||
2. 打包好的独立运行目录(含依赖)
|
||||
|
||||
**建议优先:** Python venv + requirements/pip install 方案,先求稳。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:补运维与可观察性
|
||||
|
||||
#### Task 4.1:统一日志输出格式
|
||||
**Files:**
|
||||
- Modify: `src/dbussmsforward/cli.py`
|
||||
- Modify: `src/dbussmsforward/app.py`
|
||||
- Modify: `src/dbussmsforward/web.py`
|
||||
|
||||
**Need:**
|
||||
- 标准 INFO/WARN/ERROR
|
||||
- sender failure / fetch issue 结构化输出
|
||||
- Web API 错误统一 JSON
|
||||
|
||||
#### Task 4.2:补状态页指标
|
||||
**API suggested:**
|
||||
- 最近一次 poll 时间
|
||||
- 最近 dispatch failures 数量
|
||||
- 最近 fetch issues
|
||||
- 启用的通知渠道
|
||||
- 当前 source kind
|
||||
|
||||
#### Task 4.3:补消息历史持久化(可选)
|
||||
这项不是第一阶段必须,但如果要做 Web 查看“最近短信”,就需要:
|
||||
- SQLite 或轻量 JSONL 存储
|
||||
- 最近 N 条消息与失败记录查询
|
||||
|
||||
建议放到 Web API 稳定之后再做。
|
||||
|
||||
---
|
||||
|
||||
## 九、推荐执行顺序(最务实版本)
|
||||
|
||||
### 最小 4 步路线
|
||||
1. **统一测试入口 + README 重写**
|
||||
2. **补 Python Web API 最小闭环**
|
||||
3. **补静态前端最小页面**
|
||||
4. **补 systemd + Debian 部署说明**
|
||||
|
||||
这样完成后,项目才能第一次比较诚实地对外说:
|
||||
|
||||
> 已具备 Debian-first 的可运行 Web 管理版本雏形。
|
||||
|
||||
---
|
||||
|
||||
## 十、风险与注意事项
|
||||
|
||||
### 风险 1:双轨并存会继续制造误判
|
||||
如果不尽快在 README 和目录说明里明确:
|
||||
- 哪条线是当前主线
|
||||
- 哪条线只是历史参考
|
||||
|
||||
后续任何人都可能再次把方向带偏。
|
||||
|
||||
### 风险 2:测试入口不统一会导致“看起来坏了”
|
||||
目前 `unittest discover` 直接跑会误报导入失败,这会让人误以为 Python 重构质量不稳定。
|
||||
|
||||
### 风险 3:如果先补太多高级功能,会拖慢最小交付
|
||||
比如:
|
||||
- 历史消息数据库
|
||||
- 权限系统
|
||||
- 多用户管理
|
||||
- 完整前端框架重构
|
||||
|
||||
这些都不该排在 Debian 最小可交付之前。
|
||||
|
||||
---
|
||||
|
||||
## 十一、最终建议
|
||||
|
||||
### 推荐的下一动作
|
||||
直接进入:
|
||||
|
||||
**Phase 0 + Phase 1**
|
||||
- 统一测试入口
|
||||
- 重写 README 的 Debian-first 叙事
|
||||
- 增加最小 Web API
|
||||
|
||||
这是最短路径,也最能避免继续空转。
|
||||
|
||||
### 不建议的动作
|
||||
- 继续把目标描述为 OpenWRT 部署
|
||||
- 先做复杂前端重构
|
||||
- 先追求完整数据库历史功能
|
||||
- 继续把 C# Worker 当成当前 Web 主交付线
|
||||
|
||||
---
|
||||
|
||||
## 十二、执行准备说明
|
||||
|
||||
如果下一步进入实现,建议采用以下节奏:
|
||||
|
||||
1. 先修测试入口与 README
|
||||
2. 再加 `web.py` 和 `tests/test_web.py`
|
||||
3. API 稳定后再加 `wwwroot/`
|
||||
4. 最后补 `deploy/dbussmsforward.service`
|
||||
|
||||
这样每一步都可验证,也不会再跑偏。
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.33530.505
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DbusSmsForward", "DbusTest\DbusSmsForward.csproj", "{FDF431F9-11F0-4EEC-AE1A-72A875087986}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{FDF431F9-11F0-4EEC-AE1A-72A875087986}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FDF431F9-11F0-4EEC-AE1A-72A875087986}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FDF431F9-11F0-4EEC-AE1A-72A875087986}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FDF431F9-11F0-4EEC-AE1A-72A875087986}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {98BDE03E-0B50-4652-8111-3B1076B339A2}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>DbusSmsForward</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.14.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="ProcessUserChoise/**/*.cs" />
|
||||
<Compile Remove="SendMethod/**/*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="appsettings.example.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,208 @@
|
||||
using DbusSmsForward.SettingModel;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DbusSmsForward.Helper;
|
||||
|
||||
public static class ConfigHelper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(SourceGenerationContext.Default.Options)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static string DefaultSettingsFileName => Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
|
||||
public static string ResolveConfigPath(IConfiguration? configuration = null)
|
||||
{
|
||||
string[] candidates =
|
||||
[
|
||||
configuration?["DBUSSMSFORWARD_CONFIG_PATH"] ?? string.Empty,
|
||||
Environment.GetEnvironmentVariable("DBUSSMSFORWARD_CONFIG_PATH") ?? string.Empty,
|
||||
"/etc/dbussmsforward/appsettings.json",
|
||||
DefaultSettingsFileName
|
||||
];
|
||||
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) && File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.Last(static c => !string.IsNullOrWhiteSpace(c));
|
||||
}
|
||||
|
||||
public static DbusSmsForwardOptions LoadOptions(IConfiguration? configuration = null)
|
||||
{
|
||||
DbusSmsForwardOptions options = new();
|
||||
string configPath = ResolveConfigPath(configuration);
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
string json = File.ReadAllText(configPath);
|
||||
options = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.DbusSmsForwardOptions) ?? new DbusSmsForwardOptions();
|
||||
}
|
||||
|
||||
if (configuration is not null)
|
||||
{
|
||||
configuration.GetSection("DbusSmsForward").Bind(options);
|
||||
configuration.GetSection("Daemon").Bind(options.Daemon);
|
||||
configuration.GetSection("Modem").Bind(options.Modem);
|
||||
configuration.GetSection("Forwarding").Bind(options.Forwarding);
|
||||
configuration.GetSection("Notifications").Bind(options.Notifications);
|
||||
configuration.GetSection("LegacyCompatibility").Bind(options.LegacyCompatibility);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public static void SaveOptions(DbusSmsForwardOptions options, string? path = null)
|
||||
{
|
||||
string target = path ?? ResolveConfigPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target) ?? AppContext.BaseDirectory);
|
||||
string updatedJson = JsonSerializer.Serialize(options, SourceGenerationContext.Default.DbusSmsForwardOptions);
|
||||
File.WriteAllText(target, updatedJson);
|
||||
}
|
||||
|
||||
public static void SaveLegacySettings(appsettingsModel result, string? path = null)
|
||||
{
|
||||
string target = path ?? DefaultSettingsFileName;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target) ?? AppContext.BaseDirectory);
|
||||
string updatedJson = JsonSerializer.Serialize(result, SourceGenerationContext.Default.appsettingsModel);
|
||||
File.WriteAllText(target, updatedJson);
|
||||
}
|
||||
|
||||
public static void GetSettings(ref appsettingsModel result)
|
||||
{
|
||||
string settingsFileName = ResolveConfigPath();
|
||||
if (!File.Exists(settingsFileName))
|
||||
{
|
||||
result ??= new appsettingsModel();
|
||||
return;
|
||||
}
|
||||
|
||||
string config = File.ReadAllText(settingsFileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (LooksLikeNewConfig(config))
|
||||
{
|
||||
DbusSmsForwardOptions options = JsonSerializer.Deserialize(config, SourceGenerationContext.Default.DbusSmsForwardOptions) ?? new DbusSmsForwardOptions();
|
||||
result = ConvertToLegacy(options);
|
||||
return;
|
||||
}
|
||||
|
||||
result = JsonSerializer.Deserialize(config, SourceGenerationContext.Default.appsettingsModel) ?? new appsettingsModel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
result ??= new appsettingsModel();
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateSettings(ref appsettingsModel result)
|
||||
{
|
||||
SaveLegacySettings(result);
|
||||
}
|
||||
|
||||
public static bool JudgeIsForwardIgnore(uint storage)
|
||||
{
|
||||
appsettingsModel result = new();
|
||||
GetSettings(ref result);
|
||||
string forwardStorageType = result.appSettings.ForwardIgnoreStorageType?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return (forwardStorageType, storage) switch
|
||||
{
|
||||
("unknown", 0) => true,
|
||||
("sm", 1) => true,
|
||||
("me", 2) => true,
|
||||
("mt", 3) => true,
|
||||
("sr", 4) => true,
|
||||
("bm", 5) => true,
|
||||
("ta", 6) => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetDeviceHostName()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Dns.GetHostName();
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
return Environment.MachineName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string ResolveDeviceName(DbusSmsForwardOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Forwarding.DeviceName))
|
||||
{
|
||||
return options.Forwarding.DeviceName;
|
||||
}
|
||||
|
||||
return GetDeviceHostName();
|
||||
}
|
||||
|
||||
public static appsettingsModel ConvertToLegacy(DbusSmsForwardOptions options)
|
||||
{
|
||||
return new appsettingsModel
|
||||
{
|
||||
appSettings = new LegacyAppSettings
|
||||
{
|
||||
EmailConfig = options.Notifications.Email,
|
||||
PushPlusConfig = options.Notifications.PushPlus,
|
||||
DingTalkConfig = options.Notifications.DingTalk,
|
||||
BarkConfig = options.Notifications.Bark,
|
||||
WeComApplicationConfig = options.Notifications.WeComApplication,
|
||||
TGBotConfig = options.Notifications.TelegramBot,
|
||||
ShellConfig = options.Notifications.Shell,
|
||||
SmsCodeKey = options.Forwarding.SmsCodeKey,
|
||||
ForwardIgnoreStorageType = options.Forwarding.IgnoreStorageType,
|
||||
DeviceName = options.Forwarding.DeviceName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static DbusSmsForwardOptions ConvertFromLegacy(appsettingsModel legacy)
|
||||
{
|
||||
return new DbusSmsForwardOptions
|
||||
{
|
||||
Forwarding = new ForwardingConfig
|
||||
{
|
||||
DeviceName = legacy.appSettings.DeviceName,
|
||||
SmsCodeKey = legacy.appSettings.SmsCodeKey,
|
||||
IgnoreStorageType = legacy.appSettings.ForwardIgnoreStorageType
|
||||
},
|
||||
Notifications = new NotificationChannels
|
||||
{
|
||||
Email = legacy.appSettings.EmailConfig,
|
||||
PushPlus = legacy.appSettings.PushPlusConfig,
|
||||
DingTalk = legacy.appSettings.DingTalkConfig,
|
||||
Bark = legacy.appSettings.BarkConfig,
|
||||
WeComApplication = legacy.appSettings.WeComApplicationConfig,
|
||||
TelegramBot = legacy.appSettings.TGBotConfig,
|
||||
Shell = legacy.appSettings.ShellConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static bool LooksLikeNewConfig(string json)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
JsonElement root = document.RootElement;
|
||||
return root.TryGetProperty("Daemon", out _) || root.TryGetProperty("Notifications", out _) || root.TryGetProperty("Forwarding", out _);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.Helper;
|
||||
|
||||
public static class HttpHelper
|
||||
{
|
||||
public static string HttpGet(string url)
|
||||
=> HttpGetAsync(url).GetAwaiter().GetResult();
|
||||
|
||||
public static async Task<string> HttpGetAsync(string url, bool skipTlsValidation = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using HttpClient client = CreateClient(skipTlsValidation);
|
||||
using HttpResponseMessage response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public static string Post(string url, JsonObject obj)
|
||||
=> PostAsync(url, obj).GetAwaiter().GetResult();
|
||||
|
||||
public static async Task<string> PostAsync(string url, JsonObject obj, bool skipTlsValidation = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using HttpClient client = CreateClient(skipTlsValidation);
|
||||
using StringContent content = new(obj.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await client.PostAsync(url, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(bool skipTlsValidation)
|
||||
{
|
||||
HttpClientHandler handler = new();
|
||||
if (skipTlsValidation)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DbusSmsForward.Models;
|
||||
|
||||
public sealed class AppCommand
|
||||
{
|
||||
public string Name { get; init; } = "run";
|
||||
public bool PrintSampleConfig { get; init; }
|
||||
public bool ValidateConfig { get; init; }
|
||||
public string PhoneNumber { get; init; } = string.Empty;
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string[] EnabledChannels { get; init; } = [];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,836 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using ModemManager1.DBus;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Tmds.DBus.Protocol;
|
||||
|
||||
namespace DbusSmsForward.ModemHelper
|
||||
{
|
||||
public class ModemManagerHelper
|
||||
{
|
||||
private readonly Lock _modemObjectPathListLockObject = new();
|
||||
public Dictionary<ObjectPath, Dictionary<string, Dictionary<string, VariantValue>>> _modemObjectPathList = new Dictionary<ObjectPath, Dictionary<string, Dictionary<string, VariantValue>>>();
|
||||
public string _modemObjectPathNowUse = "";
|
||||
public string qmiPathNowUse = "";
|
||||
public string _modemManagerObjectPath = "/org/freedesktop/ModemManager1";
|
||||
public string? systemBusAddress = Address.System;
|
||||
public string baseService = "org.freedesktop.ModemManager1";
|
||||
public List<Action<SmsContentModel, string, string>> smsSendMethodList = new List<Action<SmsContentModel, string, string>>();
|
||||
public string deviceUidNowUse = "";
|
||||
private readonly Lock _subscribeSmsPathListLockObject = new();
|
||||
public Dictionary<string,bool> SubscribeSmsPathList=new Dictionary<string, bool>();
|
||||
|
||||
public ModemManagerHelper()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(systemBusAddress))
|
||||
{
|
||||
InitLoadModemObjectPathList();
|
||||
WatchModems();
|
||||
}
|
||||
}
|
||||
|
||||
public void InitLoadModemObjectPathList()
|
||||
{
|
||||
lock (_modemObjectPathListLockObject)
|
||||
{
|
||||
_modemObjectPathList = GetModemsPathList().Result;
|
||||
}
|
||||
if (_modemObjectPathList.Count == 1)
|
||||
{
|
||||
SetNowUsedModemPath(_modemObjectPathList.First().Key);
|
||||
}
|
||||
if (_modemObjectPathList.Count == 0)
|
||||
{
|
||||
SetNowUsedModemPath(null);
|
||||
}
|
||||
foreach (var item in _modemObjectPathList)
|
||||
{
|
||||
SubscribeSms(item.Key);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSendMethodList(List<Action<SmsContentModel, string, string>> SendMethodList)
|
||||
{
|
||||
smsSendMethodList = SendMethodList;
|
||||
}
|
||||
|
||||
public void SetNowUsedModemPath(string modemObjectPathNowUse)
|
||||
{
|
||||
_modemObjectPathNowUse = modemObjectPathNowUse;
|
||||
if (string.IsNullOrEmpty(_modemObjectPathNowUse))
|
||||
{
|
||||
qmiPathNowUse = "";
|
||||
deviceUidNowUse = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
qmiPathNowUse = GetQMIDevice().Result;
|
||||
deviceUidNowUse = GetDevice().Result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void WatchModems()
|
||||
{
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var objectManager = service.CreateObjectManager(_modemManagerObjectPath);
|
||||
//Console.WriteLine($"Subscribing for modem RemovedChanges");
|
||||
await objectManager.WatchInterfacesRemovedAsync(
|
||||
async (Exception? ex, (ObjectPath ObjectPath, string[] Interfaces) change) =>
|
||||
{
|
||||
if (ex is null)
|
||||
{
|
||||
//Console.WriteLine("modem has been removed,modem path:" + change.ObjectPath.ToString());
|
||||
InitLoadModemObjectPathList();
|
||||
if (!string.IsNullOrEmpty(_modemObjectPathNowUse))
|
||||
{
|
||||
DisableAndEnableModem().Wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var task = tcs.Task;
|
||||
await task;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
});
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var objectManager = service.CreateObjectManager(_modemManagerObjectPath);
|
||||
//Console.WriteLine($"Subscribing for modem AddedChanges");
|
||||
await objectManager.WatchInterfacesAddedAsync(
|
||||
async (Exception? ex, (ObjectPath ObjectPath, Dictionary<string, Dictionary<string, VariantValue>> InterfacesAndProperties) change) =>
|
||||
{
|
||||
if (ex is null)
|
||||
{
|
||||
//Console.WriteLine("new modem added,modem path:" + change.ObjectPath.ToString());
|
||||
InitLoadModemObjectPathList();
|
||||
if (!string.IsNullOrEmpty(_modemObjectPathNowUse))
|
||||
{
|
||||
DisableAndEnableModem().Wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var task = tcs.Task;
|
||||
await task;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public void SubscribeSms(string path)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((!SubscribeSmsPathList.ContainsKey(path) || (SubscribeSmsPathList.ContainsKey(path) && !SubscribeSmsPathList[path]))&& _modemObjectPathList.ContainsKey(path))
|
||||
{
|
||||
if (SubscribeSmsPathList.ContainsKey(path))
|
||||
{
|
||||
lock (_subscribeSmsPathListLockObject)
|
||||
{
|
||||
SubscribeSmsPathList[path] = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (_subscribeSmsPathListLockObject)
|
||||
{
|
||||
SubscribeSmsPathList.Add(path, true);
|
||||
}
|
||||
}
|
||||
//SetMsgDefaultStorageToME(path);
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var messaging = service.CreateMessaging(path);
|
||||
//Console.WriteLine($"add subscribe sms for modem path:" + path);
|
||||
await messaging.WatchAddedAsync(
|
||||
async (Exception? ex, (ObjectPath Path, bool Received) change) =>
|
||||
{
|
||||
if (ex is null)
|
||||
{
|
||||
if (change.Received)
|
||||
{
|
||||
try
|
||||
{
|
||||
var smsPath = change.Path;
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var sms = service.CreateSms(smsPath);
|
||||
var smsState = await sms.GetStateAsync();
|
||||
if (smsState == 2 || smsState == 3)
|
||||
{
|
||||
uint Storage = await sms.GetStorageAsync();
|
||||
if (!ConfigHelper.JudgeIsForwardIgnore(Storage))
|
||||
{
|
||||
string telNum = await sms.GetNumberAsync();
|
||||
//string smsDate = (await sms.GetTimestampAsync()).Replace("T", " ").Replace("+08:00", " ");
|
||||
|
||||
string smsdateOrg = await sms.GetTimestampAsync();
|
||||
string smsDate = string.Empty;
|
||||
try
|
||||
{
|
||||
DateTimeOffset dto = DateTimeOffset.Parse(smsdateOrg, null, DateTimeStyles.RoundtripKind);
|
||||
|
||||
// 转换为本地时间
|
||||
DateTime localTime = dto.LocalDateTime;
|
||||
smsDate = localTime.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
catch (Exception ex1)
|
||||
{
|
||||
Console.WriteLine("smsdate转换失败:\n" + ex1.Message);
|
||||
smsDate = smsdateOrg;
|
||||
}
|
||||
int tryCount = 0;
|
||||
do
|
||||
{
|
||||
smsState = await sms.GetStateAsync();
|
||||
Thread.Sleep(100);
|
||||
} while (smsState == 2 && tryCount < 300);
|
||||
|
||||
if (smsState == 3)
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string DeviceName = result.appSettings.DeviceName;
|
||||
result = null;
|
||||
if (string.IsNullOrEmpty(DeviceName) || DeviceName== "*Host*Name*")
|
||||
{
|
||||
DeviceName= ConfigHelper.GetDeviceHostName();
|
||||
}
|
||||
|
||||
string smsContent = await sms.GetTextAsync();
|
||||
SmsContentModel smsmodel = new SmsContentModel();
|
||||
smsmodel.TelNumber = telNum;
|
||||
smsmodel.SmsDate = smsDate;
|
||||
smsmodel.SmsContent = smsContent;
|
||||
string body = "发信电话:" + telNum + "\n" + "时间:" + smsDate + "\n" + "转发设备:" + DeviceName + "\n" + "短信内容:" + smsContent;
|
||||
Console.WriteLine("smspath " + smsPath);
|
||||
if (smsSendMethodList.Count() > 0)
|
||||
{
|
||||
//Console.WriteLine("转发方法数量"+smsSendMethodList.Count());
|
||||
foreach (var action in smsSendMethodList)
|
||||
{
|
||||
action.Invoke(smsmodel, body, DeviceName);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("smspath:" + change.Path + " 接收超时,转发取消");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex2)
|
||||
{
|
||||
Console.WriteLine("GetSmsError" + ex2.Message);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
});
|
||||
Task task = Task.Run(() =>
|
||||
{
|
||||
var modem = service.CreateModem(path);
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
var Manufacturer = modem.GetManufacturerAsync().Result;
|
||||
Manufacturer = null;
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
} while (SubscribeSmsPathList.ContainsKey(path) && SubscribeSmsPathList[path] && _modemObjectPathList.ContainsKey(path));
|
||||
if (SubscribeSmsPathList.ContainsKey(path))
|
||||
{
|
||||
lock (_subscribeSmsPathListLockObject)
|
||||
{
|
||||
SubscribeSmsPathList[path] = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
await task;
|
||||
//Console.WriteLine($"remove subscribe sms for modem path:" + path);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
Console.WriteLine($"remove subscribe sms for modem path:" + path);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async Task<bool> SendSms(string telNum, string smsContent)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = new Connection(Address.System))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var messaging = service.CreateMessaging(_modemObjectPathNowUse);
|
||||
var sendsmsPath = await messaging.CreateAsync(new Dictionary<string, VariantValue> { { "text", smsContent }, { "number", telNum } });
|
||||
var sms = service.CreateSms(sendsmsPath);
|
||||
await sms.SendAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Dictionary<ObjectPath, Dictionary<string, Dictionary<string, VariantValue>>>> GetModemsPathList()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanDevices();
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var objectManager = service.CreateObjectManager(_modemManagerObjectPath);
|
||||
return await objectManager.GetManagedObjectsAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
return new Dictionary<ObjectPath, Dictionary<string, Dictionary<string, VariantValue>>>();
|
||||
}
|
||||
|
||||
public async Task ScanDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modemManager = service.CreateModemManager1(_modemManagerObjectPath);
|
||||
await modemManager.ScanDevicesAsync();
|
||||
modemManager = null;
|
||||
service = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!(ex.Message.IndexOf("unsupported") >-1))
|
||||
{
|
||||
Console.WriteLine("ScanDevices失败" + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetManufacturer(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var Manufacturer = await modem.GetManufacturerAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return Manufacturer;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> JudgeCanGetStatus(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateSimple(actUsePath);
|
||||
var status = await modem.GetStatusAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Console.WriteLine(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisableAndEnableModem(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
if (!string.IsNullOrEmpty(actUsePath))
|
||||
{
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
try
|
||||
{
|
||||
await modem.EnableAsync(false);
|
||||
Thread.Sleep(200);
|
||||
await modem.EnableAsync(true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("DisableAndEnableModem:actUsePath为空");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InhibitModemDevice(string uid, bool inhibit)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine("device:" + uid);
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modemManager = service.CreateModemManager1(_modemManagerObjectPath);
|
||||
await modemManager.InhibitDeviceAsync(uid, inhibit);
|
||||
modemManager = null;
|
||||
service = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("InhibitModemDevice失败" + ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetDevice(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var device = await modem.GetDeviceAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return device;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetQMIDevice(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var Ports = await modem.GetPortsAsync();
|
||||
foreach (var port in Ports)
|
||||
{
|
||||
if (port.Item2 == 6)
|
||||
{
|
||||
return "/dev/" + port.Item1;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> GetModel(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var Model = await modem.GetModelAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return Model;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
public async Task<string> GetRevision(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var Revision = await modem.GetRevisionAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return Revision;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
public async Task<string> GetSignalQuality(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var SignalQuality = await modem.GetSignalQualityAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return SignalQuality.Item1.ToString();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
public async Task<string[]> GetOwnNumbers(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var OwnNumbers = await modem.GetOwnNumbersAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return OwnNumbers;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<uint?> GetPrimarySimSlot(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var PrimarySimSlot = await modem.GetPrimarySimSlotAsync();
|
||||
return PrimarySimSlot;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetICCID(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem = service.CreateModem(actUsePath);
|
||||
var simpath = await modem.GetSimAsync();
|
||||
var sim = service.CreateSim(simpath);
|
||||
var iccid = await sim.GetSimIdentifierAsync();
|
||||
modem = null;
|
||||
service = null;
|
||||
return iccid;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetImei(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem3gpp = service.CreateModem3gpp(actUsePath);
|
||||
var Imei = await modem3gpp.GetImeiAsync();
|
||||
modem3gpp = null;
|
||||
service = null;
|
||||
return Imei;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
public async Task<string> GetOperatorCode(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem3gpp = service.CreateModem3gpp(actUsePath);
|
||||
var OperatorCode = await modem3gpp.GetOperatorCodeAsync();
|
||||
modem3gpp = null;
|
||||
service = null;
|
||||
return OperatorCode;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
public async Task<string> GetOperatorName(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string actUsePath = path == null ? _modemObjectPathNowUse : path;
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var modem3gpp = service.CreateModem3gpp(actUsePath);
|
||||
var OperatorName = await modem3gpp.GetOperatorNameAsync();
|
||||
modem3gpp = null;
|
||||
service = null;
|
||||
return OperatorName;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetMsgDefaultStorageToME(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = new Connection(systemBusAddress))
|
||||
{
|
||||
await connection.ConnectAsync();
|
||||
var service = new ModemManager1Service(connection, baseService);
|
||||
var messaging = service.CreateMessaging(path);
|
||||
await messaging.SetDefaultStorageAsync(2);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
public bool RestartModem()
|
||||
{
|
||||
string qmiPath = GetQMIDevice().Result;
|
||||
uint? simslot = GetPrimarySimSlot().Result;
|
||||
string usedDevice = deviceUidNowUse;
|
||||
if (simslot.HasValue)
|
||||
{
|
||||
if (simslot.Value == 0)
|
||||
{
|
||||
simslot += 1;
|
||||
}
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "qmicli",
|
||||
Arguments = @$"-d {qmiPath} -p --uim-sim-power-off={simslot}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using (Process process = Process.Start(startInfo))
|
||||
{
|
||||
if (process != null)
|
||||
{
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
string error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Console.WriteLine(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Failed to exec qmicli");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ProcessStartInfo startInfo2 = new ProcessStartInfo
|
||||
{
|
||||
FileName = "qmicli",
|
||||
Arguments = @$"-d {qmiPath} -p --uim-sim-power-on={simslot}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
using (Process process = Process.Start(startInfo2))
|
||||
{
|
||||
if (process != null)
|
||||
{
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
string error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Console.WriteLine(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Failed to exec qmicli");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (JudgeCanGetStatus().Result)
|
||||
{
|
||||
DisableAndEnableModem().Wait();
|
||||
}
|
||||
Thread.Sleep(200);
|
||||
if (JudgeCanGetStatus().Result)
|
||||
{
|
||||
InhibitModemDevice(usedDevice, true).Wait();
|
||||
Thread.Sleep(200);
|
||||
InhibitModemDevice(usedDevice, false).Wait();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class AutmanPushNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("autmanpush")
|
||||
{
|
||||
private readonly AutmanPushConfig _config = options.Notifications.AutmanPush;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.ApiUrl);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
JsonObject payload = new()
|
||||
{
|
||||
["title"] = BuildTitle(sms.TelNumber, string.Empty),
|
||||
["content"] = body,
|
||||
["deviceName"] = deviceName,
|
||||
["phoneNumber"] = sms.TelNumber
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_config.AccessToken))
|
||||
{
|
||||
payload["accessToken"] = _config.AccessToken;
|
||||
}
|
||||
|
||||
await HttpHelper.PostAsync(_config.ApiUrl, payload, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Web;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class BarkNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("bark")
|
||||
{
|
||||
private readonly BarkConfig _config = options.Notifications.Bark;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.ServerUrl) && !string.IsNullOrWhiteSpace(_config.BarkKey);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
SmsCodeModel code = NotificationContentBuilder.ExtractCode(options, sms);
|
||||
string url = $"{_config.ServerUrl.TrimEnd('/')}/{_config.BarkKey}/{HttpUtility.UrlEncode(body)}?group={HttpUtility.UrlEncode(sms.TelNumber)}&title={HttpUtility.UrlEncode(BuildTitle(sms.TelNumber, code.CodeValue))}";
|
||||
if (!string.IsNullOrWhiteSpace(code.CodeValue))
|
||||
{
|
||||
url += $"&autoCopy=1©={HttpUtility.UrlEncode(code.CodeValue)}";
|
||||
}
|
||||
|
||||
await HttpHelper.HttpGetAsync(url, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Web;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class DingTalkNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("dingtalk")
|
||||
{
|
||||
private readonly DingTalkConfig _config = options.Notifications.DingTalk;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.AccessToken);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
string url = $"{_config.Endpoint}?access_token={HttpUtility.UrlEncode(_config.AccessToken)}";
|
||||
if (!string.IsNullOrWhiteSpace(_config.Secret))
|
||||
{
|
||||
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string sign = AddSign(timestamp, _config.Secret);
|
||||
url += $"×tamp={timestamp}&sign={sign}";
|
||||
}
|
||||
|
||||
JsonObject payload = new()
|
||||
{
|
||||
["msgtype"] = "text",
|
||||
["text"] = new JsonObject
|
||||
{
|
||||
["content"] = body
|
||||
}
|
||||
};
|
||||
|
||||
await HttpHelper.PostAsync(url, payload, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
|
||||
private static string AddSign(long timestamp, string secret)
|
||||
{
|
||||
string stringToSign = $"{timestamp}\n{secret}";
|
||||
using HMACSHA256 hmacsha256 = new(Encoding.UTF8.GetBytes(secret));
|
||||
byte[] hashmessage = hmacsha256.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
|
||||
return HttpUtility.UrlEncode(Convert.ToBase64String(hashmessage), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using MailKit.Net.Smtp;
|
||||
using MimeKit;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class EmailNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("email")
|
||||
{
|
||||
private readonly EmailConfig _config = options.Notifications.Email;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.SmtpHost) && !string.IsNullOrWhiteSpace(_config.SendEmail) && !string.IsNullOrWhiteSpace(_config.ReceiveEmail);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
SmsCodeModel code = NotificationContentBuilder.ExtractCode(options, sms);
|
||||
MimeMessage message = new();
|
||||
message.From.Add(new MailboxAddress("DbusSmsForward", _config.SendEmail));
|
||||
message.To.Add(new MailboxAddress(string.Empty, _config.ReceiveEmail));
|
||||
message.Subject = BuildTitle(sms.TelNumber, code.CodeValue);
|
||||
message.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
using SmtpClient client = new();
|
||||
await client.ConnectAsync(_config.SmtpHost, _config.SmtpPort, _config.EnableSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(_config.SendEmail, _config.EmailKey, cancellationToken);
|
||||
await client.SendAsync(message, cancellationToken);
|
||||
await client.DisconnectAsync(true, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using DbusSmsForward.SMSModel;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public interface INotificationSender
|
||||
{
|
||||
string Name { get; }
|
||||
bool IsEnabled { get; }
|
||||
Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using DbusSmsForward.SMSModel;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public abstract class NotificationSenderBase(string name) : INotificationSender
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public abstract bool IsEnabled { get; }
|
||||
public abstract Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken);
|
||||
|
||||
protected static string BuildTitle(string telNumber, string codeValue)
|
||||
=> string.IsNullOrWhiteSpace(codeValue) ? $"短信转发 {telNumber}" : $"{codeValue} 短信转发 {telNumber}";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Notifications;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class PushPlusNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("pushplus")
|
||||
{
|
||||
private readonly PushPlusConfig _config = options.Notifications.PushPlus;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.Token);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
SmsCodeModel code = NotificationContentBuilder.ExtractCode(options, sms);
|
||||
JsonObject payload = new()
|
||||
{
|
||||
["token"] = _config.Token,
|
||||
["title"] = BuildTitle(sms.TelNumber, code.CodeValue),
|
||||
["content"] = body,
|
||||
["topic"] = string.Empty
|
||||
};
|
||||
|
||||
await HttpHelper.PostAsync(_config.Endpoint, payload, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class ShellNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("shell")
|
||||
{
|
||||
private readonly ShellConfig _config = options.Notifications.Shell;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.ShellPath);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = _config.ShellPath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
foreach (string arg in new[]
|
||||
{
|
||||
sms.TelNumber,
|
||||
sms.SmsDate,
|
||||
sms.SmsContent,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
deviceName
|
||||
})
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg ?? string.Empty);
|
||||
}
|
||||
|
||||
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start shell process.");
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(() =>
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(true); } catch { }
|
||||
});
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string error = await process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
throw new InvalidOperationException($"Shell sender exited with code {process.ExitCode}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Web;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class TelegramNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("telegram")
|
||||
{
|
||||
private readonly TelegramBotConfig _config = options.Notifications.TelegramBot;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.BotToken) && !string.IsNullOrWhiteSpace(_config.ChatId);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
string apiBase = _config.EnableCustomApi && !string.IsNullOrWhiteSpace(_config.CustomApi)
|
||||
? _config.CustomApi.TrimEnd('/')
|
||||
: "https://api.telegram.org";
|
||||
|
||||
string text = body;
|
||||
string url = $"{apiBase}/bot{_config.BotToken}/sendMessage?chat_id={HttpUtility.UrlEncode(_config.ChatId)}&text={HttpUtility.UrlEncode(text)}";
|
||||
await HttpHelper.HttpGetAsync(url, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.Notifications;
|
||||
|
||||
public sealed class WeComApplicationNotificationSender(DbusSmsForwardOptions options) : NotificationSenderBase("wecom")
|
||||
{
|
||||
private readonly WeComApplicationConfig _config = options.Notifications.WeComApplication;
|
||||
|
||||
public override bool IsEnabled => _config.Enabled && !string.IsNullOrWhiteSpace(_config.CorpId) && !string.IsNullOrWhiteSpace(_config.AgentId) && !string.IsNullOrWhiteSpace(_config.ApplicationSecret);
|
||||
|
||||
public override async Task SendAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
string tokenUrl = $"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={Uri.EscapeDataString(_config.CorpId)}&corpsecret={Uri.EscapeDataString(_config.ApplicationSecret)}";
|
||||
string tokenResponse = await HttpHelper.HttpGetAsync(tokenUrl, _config.SkipTlsValidation, cancellationToken);
|
||||
JsonObject tokenJson = JsonNode.Parse(tokenResponse)?.AsObject() ?? throw new InvalidOperationException("Invalid WeCom token response.");
|
||||
string accessToken = tokenJson["access_token"]?.ToString() ?? throw new InvalidOperationException("WeCom access_token missing.");
|
||||
|
||||
JsonObject payload = new()
|
||||
{
|
||||
["touser"] = "@all",
|
||||
["msgtype"] = "text",
|
||||
["agentid"] = _config.AgentId,
|
||||
["text"] = new JsonObject
|
||||
{
|
||||
["content"] = body
|
||||
},
|
||||
["safe"] = 0
|
||||
};
|
||||
|
||||
string sendUrl = $"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={Uri.EscapeDataString(accessToken)}";
|
||||
await HttpHelper.PostAsync(sendUrl, payload, _config.SkipTlsValidation, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DbusSmsForward.ProcessSmsContent
|
||||
{
|
||||
public static class GetSmsContentCode
|
||||
{
|
||||
public static string GetSmsCodeStr(string smscontent)
|
||||
{
|
||||
SmsCodeModel smsCodeModel = new SmsCodeModel();
|
||||
smsCodeModel = GetSmsCodeModel(smscontent);
|
||||
if (smsCodeModel.HasCode)
|
||||
{
|
||||
return smsCodeModel.CodeFrom + smsCodeModel.CodeValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
public static SmsCodeModel GetSmsCodeModel(string smscontent)
|
||||
{
|
||||
SmsCodeModel smsCodeModel = new SmsCodeModel();
|
||||
string newsmscontent=string.Empty;
|
||||
|
||||
if (JudgeSmsContentHasCode(smscontent,out newsmscontent))
|
||||
{
|
||||
string smscode= GetCode(newsmscontent).Trim();
|
||||
if (string.IsNullOrEmpty(smscode))
|
||||
{
|
||||
smsCodeModel.HasCode = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
smsCodeModel.HasCode = true;
|
||||
smsCodeModel.CodeValue = smscode;
|
||||
smsCodeModel.CodeFrom = GetCodeSmsFrom(smscontent);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
smsCodeModel.HasCode = false;
|
||||
}
|
||||
return smsCodeModel;
|
||||
}
|
||||
public static bool JudgeSmsContentHasCode(string smscontent,out string newsmscontent)
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string codeKeyStr = result.appSettings.SmsCodeKey;
|
||||
result = null;
|
||||
string[] flagStrList = {};
|
||||
try
|
||||
{
|
||||
flagStrList = codeKeyStr.Split("±");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("配置文件中自定义验证码关键词格式有误,已使用默认关键词“验证码”");
|
||||
flagStrList.Append("验证码");
|
||||
}
|
||||
|
||||
foreach (string flag in flagStrList)
|
||||
{
|
||||
if (smscontent.IndexOf(flag) > -1)
|
||||
{
|
||||
newsmscontent = smscontent.Replace(flag, " "+flag+" ");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
newsmscontent=string.Empty;
|
||||
return false;
|
||||
}
|
||||
public static string GetCode(string smscontent)
|
||||
{
|
||||
string pattern = @"\b[A-Za-z0-9]{4,7}\b";
|
||||
MatchCollection SmsCodeMatches = Regex.Matches(smscontent, pattern);
|
||||
if (SmsCodeMatches.Count()> 1)
|
||||
{
|
||||
int maxDigits = 0;
|
||||
string maxDigitsString = "";
|
||||
foreach (Match match in SmsCodeMatches)
|
||||
{
|
||||
string currentString = match.Value;
|
||||
int digitCount = CountDigits(currentString);
|
||||
if (digitCount > maxDigits)
|
||||
{
|
||||
maxDigits = digitCount;
|
||||
maxDigitsString = currentString;
|
||||
}
|
||||
}
|
||||
return maxDigitsString;
|
||||
}
|
||||
else if (SmsCodeMatches.Count()==1)
|
||||
{
|
||||
return SmsCodeMatches[0].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
public static string GetCodeSmsFrom(string smscontent)
|
||||
{
|
||||
string pattern = @"^\【(.*?)\】";
|
||||
Match match = Regex.Match(smscontent, pattern);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
string pattern1 = @"【([^【】]+)】$";
|
||||
Match match1 = Regex.Match(smscontent, pattern1);
|
||||
if (match1.Success)
|
||||
{
|
||||
return match1.Value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
public static int CountDigits(string input)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (char c in input)
|
||||
{
|
||||
if (char.IsDigit(c))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using DbusSmsForward.SendMethod;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DbusSmsForward.ProcessUserChoise
|
||||
{
|
||||
public static class ProcessChoise
|
||||
{
|
||||
public static string onStartGuide(string chooseOption)
|
||||
{
|
||||
if (string.IsNullOrEmpty(chooseOption))
|
||||
{
|
||||
Console.WriteLine("请选择运行模式:1为短信转发模式,2为发短信模式");
|
||||
chooseOption = Console.ReadLine();
|
||||
}
|
||||
if (chooseOption == "1" || chooseOption == "2")
|
||||
{
|
||||
return chooseOption;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("请输入1或2");
|
||||
return onStartGuide("");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static List<Action<SmsContentModel, string, string>> sendMethodGuide(List<string> chooseOptions)
|
||||
{
|
||||
if(chooseOptions.Count() == 0)
|
||||
{
|
||||
Console.WriteLine("请选择转发渠道:1.邮箱转发,2.pushplus转发,3.企业微信转发,4.TG机器人转发,5.钉钉转发,6.Bark转发,7.自定义shell转发,同时转发多渠道请以空格分割编号(举例:1 2 3 5)");
|
||||
chooseOptions.Add(Console.ReadLine());
|
||||
return sendMethodGuide(chooseOptions);
|
||||
}
|
||||
else if (chooseOptions.Count() >= 1)
|
||||
{
|
||||
|
||||
if (chooseOptions.Count() == 1 && chooseOptions[0].IndexOf(" ") > -1)
|
||||
{
|
||||
string chooseOption = chooseOptions[0];
|
||||
List<string> newChooseOptions = chooseOption.Split(" ").ToList();
|
||||
return sendMethodGuide(newChooseOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (JudgeChooseIsValid(chooseOptions,true))
|
||||
{
|
||||
List<Action<SmsContentModel, string, string>> actions = new List<Action<SmsContentModel, string, string>>();
|
||||
foreach (var chooseOption in chooseOptions)
|
||||
{
|
||||
if (chooseOption == "1")
|
||||
{
|
||||
SendByEmail.SetupEmailInfo();
|
||||
actions.Add(SendByEmail.SendSms);
|
||||
}
|
||||
if (chooseOption == "2")
|
||||
{
|
||||
SendByPushPlus.SetupPushPlusInfo();
|
||||
actions.Add(SendByPushPlus.SendSms);
|
||||
}
|
||||
if (chooseOption == "3")
|
||||
{
|
||||
SendByWeComApplication.SetupWeComInfo();
|
||||
actions.Add(SendByWeComApplication.SendSms);
|
||||
}
|
||||
if (chooseOption == "4")
|
||||
{
|
||||
SendByTelegramBot.SetupTGBotInfo();
|
||||
actions.Add(SendByTelegramBot.SendSms);
|
||||
}
|
||||
if (chooseOption == "5")
|
||||
{
|
||||
SendByDingTalkBot.SetupDingtalkBotMsg();
|
||||
actions.Add(SendByDingTalkBot.SendSms);
|
||||
}
|
||||
if (chooseOption == "6")
|
||||
{
|
||||
SendByBark.SetupBarkInfo();
|
||||
actions.Add(SendByBark.SendSms);
|
||||
}
|
||||
if (chooseOption == "7")
|
||||
{
|
||||
SendByShell.SetupCustomShellInfo();
|
||||
actions.Add(SendByShell.SendSms);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("请输入1或2或3或4或5或6或7");
|
||||
return sendMethodGuide(new List<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new List<Action<SmsContentModel, string, string>>();
|
||||
}
|
||||
public static bool JudgeChooseIsValid(List<string> chooseOptions,bool isCheckAll=false)
|
||||
{
|
||||
if (isCheckAll)
|
||||
{
|
||||
return chooseOptions.Where(a => a == "1" || a == "2" || a == "3" || a == "4" || a == "5" || a == "6" || a == "7").Count() == chooseOptions.Count();
|
||||
}
|
||||
else
|
||||
{
|
||||
return chooseOptions.Where(a => a == "1" || a == "2" || a == "3" || a == "4" || a == "5" || a == "6" || a == "7").Count() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Models;
|
||||
using DbusSmsForward.ModemHelper;
|
||||
using DbusSmsForward.Notifications;
|
||||
using DbusSmsForward.Services;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json;
|
||||
|
||||
AppCommand command = CommandLineParser.Parse(args);
|
||||
|
||||
if (command.PrintSampleConfig)
|
||||
{
|
||||
DbusSmsForwardOptions sample = SampleConfigFactory.Create();
|
||||
Console.WriteLine(JsonSerializer.Serialize(sample, SourceGenerationContext.Default.DbusSmsForwardOptions));
|
||||
return;
|
||||
}
|
||||
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false);
|
||||
builder.Services.Configure<DbusSmsForwardOptions>(builder.Configuration);
|
||||
builder.Services.AddSingleton(sp =>
|
||||
{
|
||||
DbusSmsForwardOptions options = new();
|
||||
builder.Configuration.Bind(options);
|
||||
LegacyConfigurationBridge.Apply(builder.Configuration, options);
|
||||
CommandLineConfigurationApplier.Apply(command, options);
|
||||
return options;
|
||||
});
|
||||
builder.Services.AddSingleton<ModemManagerHelper>();
|
||||
builder.Services.AddSingleton<NotificationDispatcher>();
|
||||
builder.Services.AddSingleton<INotificationSender, EmailNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, PushPlusNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, BarkNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, TelegramNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, DingTalkNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, WeComApplicationNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, ShellNotificationSender>();
|
||||
builder.Services.AddSingleton<INotificationSender, AutmanPushNotificationSender>();
|
||||
builder.Services.AddHostedService<SmsForwardWorker>();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole();
|
||||
|
||||
using IHost host = builder.Build();
|
||||
DbusSmsForwardOptions resolvedOptions = host.Services.GetRequiredService<DbusSmsForwardOptions>();
|
||||
|
||||
if (command.ValidateConfig || resolvedOptions.Daemon.ValidateOnStartup)
|
||||
{
|
||||
ConfigurationValidator.Validate(resolvedOptions, command);
|
||||
}
|
||||
|
||||
if (string.Equals(command.Name, "send", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await SendSmsAsync(host.Services, command);
|
||||
return;
|
||||
}
|
||||
|
||||
await host.RunAsync();
|
||||
|
||||
static async Task SendSmsAsync(IServiceProvider services, AppCommand command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.PhoneNumber) || string.IsNullOrWhiteSpace(command.Message))
|
||||
{
|
||||
throw new InvalidOperationException("send 模式需要 --to 和 --text 参数。 ");
|
||||
}
|
||||
|
||||
ModemManagerHelper modemManagerHelper = services.GetRequiredService<ModemManagerHelper>();
|
||||
bool success = await modemManagerHelper.SendSms(command.PhoneNumber, command.Message);
|
||||
Console.WriteLine(success ? "短信已发送" : "短信发送失败");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DbusSmsForward.SMSModel
|
||||
{
|
||||
public class SmsCodeModel
|
||||
{
|
||||
public bool HasCode { get; set; }=false;
|
||||
public string CodeFrom { get; set; }=string.Empty;
|
||||
public string CodeValue { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DbusSmsForward.SMSModel;
|
||||
|
||||
public sealed class SmsContentModel
|
||||
{
|
||||
public string TelNumber { get; set; } = string.Empty;
|
||||
public string SmsContent { get; set; } = string.Empty;
|
||||
public string SmsDate { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public static class SendByBark
|
||||
{
|
||||
public static void SetupBarkInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string BarkUrl = result.appSettings.BarkConfig.BarkUrl;
|
||||
string BrakKey = result.appSettings.BarkConfig.BarkKey;
|
||||
|
||||
if (string.IsNullOrEmpty(BarkUrl) && string.IsNullOrEmpty(BrakKey))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入Bark服务器地址:");
|
||||
BarkUrl = Console.ReadLine().Trim();
|
||||
result.appSettings.BarkConfig.BarkUrl = BarkUrl;
|
||||
Console.WriteLine("首次运行请输入Bark推送key");
|
||||
BrakKey = Console.ReadLine().Trim();
|
||||
result.appSettings.BarkConfig.BarkKey = BrakKey;
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string BarkUrl = result.appSettings.BarkConfig.BarkUrl;
|
||||
string BrakKey = result.appSettings.BarkConfig.BarkKey;
|
||||
result = null;
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
|
||||
string url = BarkUrl + "/" + BrakKey + "/";
|
||||
url += System.Web.HttpUtility.UrlEncode(body);
|
||||
url += "?group=" + smsmodel.TelNumber + "&title="+ (string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + " ") + "短信转发" + smsmodel.TelNumber+(string.IsNullOrEmpty(codeResult.CodeValue) ?"":"&autoCopy=1©="+ codeResult.CodeValue);
|
||||
string msgresult = HttpHelper.HttpGet(url);
|
||||
//JsonObject jsonObjresult = JObject.Parse(msgresult);
|
||||
var jsonObjresult = JsonSerializer.Deserialize(msgresult, SourceGenerationContext.Default.JsonObject);
|
||||
string status = jsonObjresult["code"].ToString();
|
||||
if (status == "200")
|
||||
{
|
||||
Console.WriteLine("Bark转发成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(jsonObjresult["code"].ToString());
|
||||
Console.WriteLine(jsonObjresult["message"].ToString());
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByBarkError:\n"+ex);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Web;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DbusSmsForward.SettingModel;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public class SendByDingTalkBot
|
||||
{
|
||||
private const string DING_TALK_BOT_URL = "https://oapi.dingtalk.com/robot/send?access_token=";
|
||||
|
||||
public static void SetupDingtalkBotMsg()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string DingTalkAccessToken = result.appSettings.DingTalkConfig.DingTalkAccessToken;
|
||||
string DingTalkSecret = result.appSettings.DingTalkConfig.DingTalkSecret;
|
||||
|
||||
if (string.IsNullOrEmpty(DingTalkAccessToken) && string.IsNullOrEmpty(DingTalkSecret))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入钉钉机器人AccessToken:");
|
||||
DingTalkAccessToken = Console.ReadLine().Trim();
|
||||
result.appSettings.DingTalkConfig.DingTalkAccessToken = DingTalkAccessToken;
|
||||
Console.WriteLine("请输入钉钉机器人加签secret:");
|
||||
DingTalkSecret = Console.ReadLine().Trim();
|
||||
result.appSettings.DingTalkConfig.DingTalkSecret = DingTalkSecret;
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string dingTalkAccessToken = result.appSettings.DingTalkConfig.DingTalkAccessToken;
|
||||
string dingTalkSecret = result.appSettings.DingTalkConfig.DingTalkSecret;
|
||||
result = null;
|
||||
string url = DING_TALK_BOT_URL + dingTalkAccessToken;
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
|
||||
long timestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
string sign = addSign(timestamp, dingTalkSecret);
|
||||
url += $"×tamp={timestamp}&sign={sign}";
|
||||
|
||||
JsonObject msgContent = new()
|
||||
{
|
||||
{ "content", (string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + "\n") + "短信转发\n" + body }
|
||||
};
|
||||
|
||||
JsonObject msgObj = new()
|
||||
{
|
||||
{ "msgtype", "text" },
|
||||
{ "text", msgContent }
|
||||
};
|
||||
|
||||
string resultResp = HttpHelper.Post(url, msgObj);
|
||||
//JObject jsonObjresult = JObject.Parse(resultResp);
|
||||
JsonObject jsonObjresult = JsonSerializer.Deserialize(resultResp, SourceGenerationContext.Default.JsonObject);
|
||||
string errcode1 = jsonObjresult["errcode"].ToString();
|
||||
string errmsg1 = jsonObjresult["errmsg"].ToString();
|
||||
if (errcode1 == "0" && errmsg1 == "ok")
|
||||
{
|
||||
Console.WriteLine("钉钉转发成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(errmsg1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByDingTalkBotError:\n" + ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static string addSign(long timestamp ,string secret)
|
||||
{
|
||||
string secret1 = secret;
|
||||
string stringToSign = timestamp + "\n" + secret1;
|
||||
var encoding = new ASCIIEncoding();
|
||||
byte[] keyByte = encoding.GetBytes(secret1);
|
||||
byte[] messageBytes = encoding.GetBytes(stringToSign);
|
||||
using (var hmacsha256 = new HMACSHA256(keyByte))
|
||||
{
|
||||
byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
|
||||
return HttpUtility.UrlEncode(Convert.ToBase64String(hashmessage), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
public static string Base64Encrypt(string input, Encoding encode)
|
||||
{
|
||||
return Convert.ToBase64String(encode.GetBytes(input));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using DbusSmsForward.SMSModel;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using MimeKit;
|
||||
using MailKit.Net.Smtp;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public static class SendByEmail
|
||||
{
|
||||
public static void SetupEmailInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string smtpHost = result.appSettings.EmailConfig.smtpHost;
|
||||
string smtpPort = result.appSettings.EmailConfig.smtpPort;
|
||||
string emailKey = result.appSettings.EmailConfig.emailKey;
|
||||
string sendEmail = result.appSettings.EmailConfig.sendEmail;
|
||||
string reciveEmail = result.appSettings.EmailConfig.reciveEmail;
|
||||
if (string.IsNullOrEmpty(smtpHost) && string.IsNullOrEmpty(smtpPort) && string.IsNullOrEmpty(emailKey) && string.IsNullOrEmpty(sendEmail) && string.IsNullOrEmpty(reciveEmail))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入邮箱转发相关配置信息\n请输入smtp地址:");
|
||||
smtpHost = Console.ReadLine().Trim();
|
||||
result.appSettings.EmailConfig.smtpHost = smtpHost;
|
||||
|
||||
Console.WriteLine("请输入smtp端口:");
|
||||
smtpPort = Console.ReadLine().Trim();
|
||||
result.appSettings.EmailConfig.smtpPort = smtpPort;
|
||||
string sslEnableInput = string.Empty;
|
||||
do
|
||||
{
|
||||
Console.WriteLine("是否需要启用ssl(1.启用 2.不启用)");
|
||||
sslEnableInput = Console.ReadLine().Trim();
|
||||
} while (!(sslEnableInput == "1" || sslEnableInput == "2"));
|
||||
if (sslEnableInput == "1")
|
||||
{
|
||||
result.appSettings.EmailConfig.enableSSL = "true";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.appSettings.EmailConfig.enableSSL = "false";
|
||||
}
|
||||
Console.WriteLine("请输入邮箱密钥:");
|
||||
emailKey = Console.ReadLine().Trim();
|
||||
result.appSettings.EmailConfig.emailKey = emailKey;
|
||||
|
||||
Console.WriteLine("请输入发件邮箱:");
|
||||
sendEmail = Console.ReadLine().Trim();
|
||||
result.appSettings.EmailConfig.sendEmail = sendEmail;
|
||||
|
||||
Console.WriteLine("请输入收件邮箱:");
|
||||
reciveEmail = Console.ReadLine().Trim();
|
||||
result.appSettings.EmailConfig.reciveEmail = reciveEmail;
|
||||
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string smtpHost = result.appSettings.EmailConfig.smtpHost;
|
||||
string smtpPort = result.appSettings.EmailConfig.smtpPort;
|
||||
bool enableSSL = Convert.ToBoolean(result.appSettings.EmailConfig.enableSSL);
|
||||
string emailKey = result.appSettings.EmailConfig.emailKey;
|
||||
string sendEmail = result.appSettings.EmailConfig.sendEmail;
|
||||
string reciveEmail = result.appSettings.EmailConfig.reciveEmail;
|
||||
result = null;
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("SMSForwad", sendEmail));
|
||||
message.To.Add(new MailboxAddress("", reciveEmail));
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
|
||||
message.Subject = (string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + " ") + "短信转发" + smsmodel.TelNumber;
|
||||
message.Body = new TextPart("plain")
|
||||
{
|
||||
Text = body
|
||||
};
|
||||
using (var client = new SmtpClient())
|
||||
{
|
||||
client.Connect(smtpHost, Convert.ToInt32(smtpPort), enableSSL);
|
||||
client.Authenticate(sendEmail, emailKey);
|
||||
client.Send(message);
|
||||
client.Disconnect(true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByEmailError:\n" + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public static class SendByPushPlus
|
||||
{
|
||||
public static void SetupPushPlusInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string pushPlusToken = result.appSettings.PushPlusConfig.pushPlusToken;
|
||||
if (string.IsNullOrEmpty(pushPlusToken))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入PushPlusToken:");
|
||||
pushPlusToken = Console.ReadLine().Trim();
|
||||
result.appSettings.PushPlusConfig.pushPlusToken = pushPlusToken;
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string pushPlusToken = result.appSettings.PushPlusConfig.pushPlusToken;
|
||||
result = null;
|
||||
string pushPlusUrl = "http://www.pushplus.plus/send/";
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.Add("token", pushPlusToken);
|
||||
obj.Add("title", (string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + " ") + "短信转发" + smsmodel.TelNumber);
|
||||
obj.Add("content", body);
|
||||
obj.Add("topic", "");
|
||||
string msgresult = HttpHelper.Post(pushPlusUrl, obj);
|
||||
//JObject jsonObjresult = JObject.Parse(msgresult);
|
||||
JsonObject jsonObjresult = JsonSerializer.Deserialize(msgresult, SourceGenerationContext.Default.JsonObject);
|
||||
string code = jsonObjresult["code"].ToString();
|
||||
string errmsg = jsonObjresult["msg"].ToString();
|
||||
if (code == "200")
|
||||
{
|
||||
Console.WriteLine("pushplus转发成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(errmsg);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SetupPushPlusInfoError:\n" + ex);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Xml;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public static class SendByShell
|
||||
{
|
||||
public static void SetupCustomShellInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string shellPath = result.appSettings.ShellConfig.ShellPath;
|
||||
if (string.IsNullOrEmpty(shellPath))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入自定义shell脚本路径:");
|
||||
shellPath = Console.ReadLine().Trim();
|
||||
result.appSettings.ShellConfig.ShellPath = shellPath;
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string shellPath = result.appSettings.ShellConfig.ShellPath;
|
||||
result = null;
|
||||
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
string[] arguments =
|
||||
{
|
||||
string.IsNullOrEmpty(smsmodel.TelNumber)?"":smsmodel.TelNumber,
|
||||
string.IsNullOrEmpty(smsmodel.SmsDate)?"":smsmodel.SmsDate,
|
||||
string.IsNullOrEmpty(smsmodel.SmsContent)?"":smsmodel.SmsContent,
|
||||
string.IsNullOrEmpty(codeResult.CodeValue)?"":codeResult.CodeValue,
|
||||
string.IsNullOrEmpty(codeResult.CodeFrom)?"":codeResult.CodeFrom,
|
||||
string.IsNullOrEmpty(devicename)?"":devicename,
|
||||
};
|
||||
|
||||
ProcessStartInfo startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = shellPath,
|
||||
UseShellExecute = false, // 不直接使用操作系统shell启动
|
||||
CreateNoWindow = true, // 不创建新窗口
|
||||
RedirectStandardOutput = true, // 可以重定向输出
|
||||
RedirectStandardError = true // 可以重定向错误
|
||||
};
|
||||
foreach (var arg in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
// 启动进程
|
||||
using (Process process = new Process())
|
||||
{
|
||||
process.StartInfo = startInfo;
|
||||
|
||||
// 可以捕获输出(如果需要)
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Console.WriteLine($"输出: {e.Data}");
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(e.Data))
|
||||
Console.WriteLine($"错误: {e.Data}");
|
||||
};
|
||||
|
||||
bool started = process.Start();
|
||||
|
||||
if (started)
|
||||
{
|
||||
// 开始异步读取输出
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
// 等待进程完成(可以设置超时时间)
|
||||
bool exited = process.WaitForExit(30000); // 30秒超时
|
||||
|
||||
if (exited)
|
||||
{
|
||||
int exitCode = process.ExitCode;
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
Console.WriteLine("\nShell调用成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"\nShell调用失败,退出代码: {exitCode}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("\nShell调用超时");
|
||||
process.Kill(); // 强制终止进程
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("\n无法启动Shell进程");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByShellError:\n" + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public static class SendByTelegramBot
|
||||
{
|
||||
public static void SetupTGBotInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string TGBotToken = result.appSettings.TGBotConfig.TGBotToken;
|
||||
string TGBotChatID = result.appSettings.TGBotConfig.TGBotChatID;
|
||||
string IsEnableCustomTGBotApi = result.appSettings.TGBotConfig.IsEnableCustomTGBotApi;
|
||||
string CustomTGBotApi = result.appSettings.TGBotConfig.CustomTGBotApi;
|
||||
|
||||
if (string.IsNullOrEmpty(TGBotToken) || string.IsNullOrEmpty(TGBotChatID) || string.IsNullOrEmpty(IsEnableCustomTGBotApi))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入TG机器人Token:");
|
||||
TGBotToken = Console.ReadLine().Trim();
|
||||
result.appSettings.TGBotConfig.TGBotToken = TGBotToken;
|
||||
Console.WriteLine("请输入机器人要转发到的ChatId");
|
||||
TGBotChatID = Console.ReadLine().Trim();
|
||||
result.appSettings.TGBotConfig.TGBotChatID = TGBotChatID;
|
||||
|
||||
string customApiEnableInput=string.Empty;
|
||||
do
|
||||
{
|
||||
Console.WriteLine("是否需要使用自定义api(1.使用 2.不使用)");
|
||||
customApiEnableInput= Console.ReadLine().Trim();
|
||||
} while (!(customApiEnableInput=="1"|| customApiEnableInput == "2"));
|
||||
if(customApiEnableInput=="1")
|
||||
{
|
||||
IsEnableCustomTGBotApi = "true";
|
||||
result.appSettings.TGBotConfig.IsEnableCustomTGBotApi = IsEnableCustomTGBotApi;
|
||||
Console.WriteLine("请输入机器人自定义api(格式https://xxx.abc.com)");
|
||||
CustomTGBotApi= Console.ReadLine().Trim();
|
||||
result.appSettings.TGBotConfig.CustomTGBotApi = CustomTGBotApi;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsEnableCustomTGBotApi = "false";
|
||||
result.appSettings.TGBotConfig.IsEnableCustomTGBotApi = IsEnableCustomTGBotApi;
|
||||
}
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string TGBotToken = result.appSettings.TGBotConfig.TGBotToken;
|
||||
string TGBotChatID = result.appSettings.TGBotConfig.TGBotChatID;
|
||||
string IsEnableCustomTGBotApi = result.appSettings.TGBotConfig.IsEnableCustomTGBotApi;
|
||||
string CustomTGBotApi = result.appSettings.TGBotConfig.CustomTGBotApi;
|
||||
result = null;
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
string url = "";
|
||||
if (IsEnableCustomTGBotApi == "true")
|
||||
{
|
||||
url = CustomTGBotApi;
|
||||
}
|
||||
else
|
||||
{
|
||||
url = "https://api.telegram.org";
|
||||
}
|
||||
url += "/bot" + TGBotToken + "/sendMessage?chat_id=" + TGBotChatID + "&text=";
|
||||
url += System.Web.HttpUtility.UrlEncode((string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + "\n") + "短信转发\n" + body);
|
||||
string msgresult = HttpHelper.HttpGet(url);
|
||||
JsonObject jsonObjresult = JsonSerializer.Deserialize(msgresult, SourceGenerationContext.Default.JsonObject);
|
||||
string status = jsonObjresult["ok"].ToString();
|
||||
if (status.ToLower() == "true")
|
||||
{
|
||||
Console.WriteLine("TGBot转发成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(jsonObjresult["error_code"].ToString());
|
||||
Console.WriteLine(jsonObjresult["description"].ToString());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByTelegramBotError:\n" + ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SettingModel;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DbusSmsForward.SendMethod
|
||||
{
|
||||
public class SendByWeComApplication
|
||||
{
|
||||
public static void SetupWeComInfo()
|
||||
{
|
||||
appsettingsModel result = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref result);
|
||||
string corpid = result.appSettings.WeComApplicationConfig.WeChatQYID;
|
||||
string appsecret = result.appSettings.WeComApplicationConfig.WeChatQYApplicationSecret;
|
||||
string appid = result.appSettings.WeComApplicationConfig.WeChatQYApplicationID;
|
||||
|
||||
if (string.IsNullOrEmpty(corpid) && string.IsNullOrEmpty(appsecret) && string.IsNullOrEmpty(appid))
|
||||
{
|
||||
Console.WriteLine("首次运行请输入企业ID:");
|
||||
corpid = Console.ReadLine().Trim();
|
||||
result.appSettings.WeComApplicationConfig.WeChatQYID = corpid;
|
||||
|
||||
Console.WriteLine("请输入自建应用ID:");
|
||||
appid = Console.ReadLine().Trim();
|
||||
result.appSettings.WeComApplicationConfig.WeChatQYApplicationID = appid;
|
||||
|
||||
Console.WriteLine("请输入自建应用密钥:");
|
||||
appsecret = Console.ReadLine().Trim();
|
||||
result.appSettings.WeComApplicationConfig.WeChatQYApplicationSecret = appsecret;
|
||||
|
||||
ConfigHelper.UpdateSettings(ref result);
|
||||
}
|
||||
result = null;
|
||||
|
||||
}
|
||||
|
||||
public static void SendSms(SmsContentModel smsmodel, string body, string devicename)
|
||||
{
|
||||
try
|
||||
{
|
||||
appsettingsModel configResult = new appsettingsModel();
|
||||
ConfigHelper.GetSettings(ref configResult);
|
||||
string corpid = configResult.appSettings.WeComApplicationConfig.WeChatQYID;
|
||||
string corpsecret = configResult.appSettings.WeComApplicationConfig.WeChatQYApplicationSecret;
|
||||
string agentid = configResult.appSettings.WeComApplicationConfig.WeChatQYApplicationID;
|
||||
configResult = null;
|
||||
|
||||
string url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpid + "&corpsecret=" + corpsecret;
|
||||
string result = HttpHelper.HttpGet(url);
|
||||
//JsonObject jsonObj = JsonObject.Parse(result);
|
||||
JsonObject jsonObj = JsonSerializer.Deserialize(result, SourceGenerationContext.Default.JsonObject);
|
||||
string errcode = jsonObj["errcode"].ToString();
|
||||
string errmsg = jsonObj["errmsg"].ToString();
|
||||
//string SmsCodeStr = GetSmsContentCode.GetSmsCodeStr(smsmodel.SmsContent);
|
||||
SmsCodeModel codeResult = GetSmsContentCode.GetSmsCodeModel(smsmodel.SmsContent);
|
||||
|
||||
if (errcode == "0" && errmsg == "ok")
|
||||
{
|
||||
string access_token = jsonObj["access_token"].ToString();
|
||||
JsonObject obj = new JsonObject();
|
||||
JsonObject obj1 = new JsonObject();
|
||||
obj.Add("touser", "@all");
|
||||
obj.Add("toparty", "");
|
||||
obj.Add("totag", "");
|
||||
obj.Add("msgtype", "text");
|
||||
obj.Add("agentid", agentid);
|
||||
obj1.Add("content", (string.IsNullOrEmpty(codeResult.CodeValue) ? "" : codeResult.CodeValue + "\n") + "短信转发\n" + body);
|
||||
obj.Add("text", obj1);
|
||||
obj.Add("safe", 0);
|
||||
obj.Add("enable_id_trans", 0);
|
||||
obj.Add("enable_duplicate_check", 0);
|
||||
obj.Add("duplicate_check_interval", 1800);
|
||||
string msgurl = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + access_token;
|
||||
string msgresult = HttpHelper.Post(msgurl, obj);
|
||||
//JsonObject jsonObjresult = JsonObject.Parse(msgresult);
|
||||
//JsonObject jsonObjresult = JsonSerializer.Deserialize<JsonObject>(msgresult);
|
||||
JsonObject jsonObjresult = JsonSerializer.Deserialize(msgresult, SourceGenerationContext.Default.JsonObject);
|
||||
string errcode1 = jsonObjresult["errcode"].ToString();
|
||||
string errmsg1 = jsonObjresult["errmsg"].ToString();
|
||||
if (errcode1 == "0" && errmsg1 == "ok")
|
||||
{
|
||||
Console.WriteLine("企业微信转发成功");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(errmsg1);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(errmsg);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SendByWeComApplicationError:\n" + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using DbusSmsForward.Helper;
|
||||
using DbusSmsForward.Models;
|
||||
|
||||
namespace DbusSmsForward.Services;
|
||||
|
||||
public static class CommandLineParser
|
||||
{
|
||||
public static AppCommand Parse(string[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
return new AppCommand { Name = "run" };
|
||||
}
|
||||
|
||||
List<string> enabledChannels = [];
|
||||
string commandName = "run";
|
||||
string phoneNumber = string.Empty;
|
||||
string message = string.Empty;
|
||||
bool validateConfig = false;
|
||||
bool printSampleConfig = false;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
string arg = args[i];
|
||||
switch (arg)
|
||||
{
|
||||
case "run":
|
||||
case "send":
|
||||
case "validate-config":
|
||||
case "print-sample-config":
|
||||
commandName = arg;
|
||||
break;
|
||||
case "--to":
|
||||
if (i + 1 < args.Length) phoneNumber = args[++i];
|
||||
break;
|
||||
case "--text":
|
||||
if (i + 1 < args.Length) message = args[++i];
|
||||
break;
|
||||
case "-fE":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("email");
|
||||
break;
|
||||
case "-fP":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("pushplus");
|
||||
break;
|
||||
case "-fW":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("wecom");
|
||||
break;
|
||||
case "-fT":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("telegram");
|
||||
break;
|
||||
case "-fD":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("dingtalk");
|
||||
break;
|
||||
case "-fB":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("bark");
|
||||
break;
|
||||
case "-fS":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("shell");
|
||||
break;
|
||||
case "-fA":
|
||||
commandName = "run";
|
||||
enabledChannels.Add("autmanpush");
|
||||
break;
|
||||
case "-sS":
|
||||
commandName = "send";
|
||||
break;
|
||||
case "--validate-config":
|
||||
validateConfig = true;
|
||||
break;
|
||||
case "--print-sample-config":
|
||||
printSampleConfig = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new AppCommand
|
||||
{
|
||||
Name = commandName,
|
||||
PhoneNumber = phoneNumber,
|
||||
Message = message,
|
||||
EnabledChannels = enabledChannels.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
ValidateConfig = validateConfig || commandName == "validate-config",
|
||||
PrintSampleConfig = printSampleConfig || commandName == "print-sample-config"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using DbusSmsForward.ProcessSmsContent;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using DbusSmsForward.SettingModel;
|
||||
|
||||
namespace DbusSmsForward.Services;
|
||||
|
||||
public static class NotificationContentBuilder
|
||||
{
|
||||
public static string BuildBody(SmsContentModel sms, string deviceName)
|
||||
=> $"发信电话:{sms.TelNumber}\n时间:{sms.SmsDate}\n转发设备:{deviceName}\n短信内容:{sms.SmsContent}";
|
||||
|
||||
public static SmsCodeModel ExtractCode(DbusSmsForwardOptions options, SmsContentModel sms)
|
||||
{
|
||||
if (!options.Forwarding.EnableSmsCodeExtraction)
|
||||
{
|
||||
return new SmsCodeModel();
|
||||
}
|
||||
|
||||
return GetSmsContentCode.GetSmsCodeModel(sms.SmsContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using DbusSmsForward.Notifications;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DbusSmsForward.Services;
|
||||
|
||||
public sealed class NotificationDispatcher(IEnumerable<INotificationSender> senders, ILogger<NotificationDispatcher> logger)
|
||||
{
|
||||
private readonly IReadOnlyList<INotificationSender> _senders = senders.Where(static s => s.IsEnabled).ToList();
|
||||
|
||||
public async Task DispatchAsync(SmsContentModel sms, string body, string deviceName, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (INotificationSender sender in _senders)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sender.SendAsync(sms, body, deviceName, cancellationToken);
|
||||
logger.LogInformation("Notification sender {Sender} completed for {PhoneNumber}", sender.Name, sms.TelNumber);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Notification sender {Sender} failed for {PhoneNumber}", sender.Name, sms.TelNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DbusSmsForward.ModemHelper;
|
||||
using DbusSmsForward.SMSModel;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DbusSmsForward.Services;
|
||||
|
||||
public sealed class SmsForwardWorker(ModemManagerHelper modemManagerHelper, NotificationDispatcher dispatcher, ILogger<SmsForwardWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("SmsForwardWorker started.");
|
||||
modemManagerHelper.SetSendMethodList(
|
||||
[
|
||||
(SmsContentModel sms, string body, string deviceName) =>
|
||||
{
|
||||
dispatcher.DispatchAsync(sms, body, deviceName, stoppingToken).GetAwaiter().GetResult();
|
||||
}
|
||||
]);
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using DbusSmsForward.SettingModel;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DbusSmsForward;
|
||||
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = true)]
|
||||
[JsonSerializable(typeof(JsonObject))]
|
||||
[JsonSerializable(typeof(DbusSmsForwardOptions))]
|
||||
[JsonSerializable(typeof(LegacyAppsettingsModel))]
|
||||
[JsonSerializable(typeof(appsettingsModel))]
|
||||
internal partial class SourceGenerationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"appSettings": {
|
||||
"EmailConfig": {
|
||||
"smtpHost": "",
|
||||
"smtpPort": "",
|
||||
"enableSSL": "",
|
||||
"emailKey": "",
|
||||
"sendEmail": "",
|
||||
"reciveEmail": ""
|
||||
},
|
||||
"PushPlusConfig": {
|
||||
"pushPlusToken": ""
|
||||
},
|
||||
"DingTalkConfig": {
|
||||
"DingTalkAccessToken": "",
|
||||
"DingTalkSecret": ""
|
||||
},
|
||||
"BarkConfig": {
|
||||
"BarkUrl": "",
|
||||
"BarkKey": ""
|
||||
},
|
||||
"WeComApplicationConfig": {
|
||||
"WeChatQYID": "",
|
||||
"WeChatQYApplicationID": "",
|
||||
"WeChatQYApplicationSecret": ""
|
||||
},
|
||||
"TGBotConfig": {
|
||||
"IsEnableCustomTGBotApi": "",
|
||||
"CustomTGBotApi": "",
|
||||
"TGBotToken": "",
|
||||
"TGBotChatID": ""
|
||||
},
|
||||
"ShellConfig": {
|
||||
"ShellPath": ""
|
||||
},
|
||||
"SmsCodeKey": "验证码±verification±code±인증±代码±随机码",
|
||||
"ForwardIgnoreStorageType": "sm",
|
||||
"DeviceName": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Tmds.DBus.Protocol;
|
||||
|
||||
namespace DbusSmsForward.SettingModel;
|
||||
|
||||
public sealed class appsettingsModel
|
||||
{
|
||||
public LegacyAppSettings appSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LegacyAppsettingsModel
|
||||
{
|
||||
public LegacyAppSettings appSettings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LegacyAppSettings
|
||||
{
|
||||
public EmailConfig EmailConfig { get; set; } = new();
|
||||
public PushPlusConfig PushPlusConfig { get; set; } = new();
|
||||
public DingTalkConfig DingTalkConfig { get; set; } = new();
|
||||
public BarkConfig BarkConfig { get; set; } = new();
|
||||
public WeComApplicationConfig WeComApplicationConfig { get; set; } = new();
|
||||
public TelegramBotConfig TGBotConfig { get; set; } = new();
|
||||
public ShellConfig ShellConfig { get; set; } = new();
|
||||
public string SmsCodeKey { get; set; } = "验证码±verification±code±认证±代码±随机码";
|
||||
public string ForwardIgnoreStorageType { get; set; } = "sm";
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DbusSmsForwardOptions
|
||||
{
|
||||
public DaemonConfig Daemon { get; set; } = new();
|
||||
public ModemConfig Modem { get; set; } = new();
|
||||
public ForwardingConfig Forwarding { get; set; } = new();
|
||||
public NotificationChannels Notifications { get; set; } = new();
|
||||
public LegacyCompatibilityConfig LegacyCompatibility { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DaemonConfig
|
||||
{
|
||||
public string Mode { get; set; } = "forward";
|
||||
public bool ValidateOnStartup { get; set; } = true;
|
||||
public bool UseSystemd { get; set; } = true;
|
||||
public string LogLevel { get; set; } = "Information";
|
||||
}
|
||||
|
||||
public sealed class ModemConfig
|
||||
{
|
||||
public string SystemBusAddress { get; set; } = Address.System ?? string.Empty;
|
||||
public string ServiceName { get; set; } = "org.freedesktop.ModemManager1";
|
||||
public string ManagerObjectPath { get; set; } = "/org/freedesktop/ModemManager1";
|
||||
public bool ScanDevicesOnStartup { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class ForwardingConfig
|
||||
{
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
public string SmsCodeKey { get; set; } = "验证码±verification±code±认证±代码±随机码";
|
||||
public string IgnoreStorageType { get; set; } = "sm";
|
||||
public bool EnableSmsCodeExtraction { get; set; } = true;
|
||||
public string[] EnabledChannels { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class NotificationChannels
|
||||
{
|
||||
public EmailConfig Email { get; set; } = new();
|
||||
public PushPlusConfig PushPlus { get; set; } = new();
|
||||
public DingTalkConfig DingTalk { get; set; } = new();
|
||||
public BarkConfig Bark { get; set; } = new();
|
||||
public WeComApplicationConfig WeComApplication { get; set; } = new();
|
||||
public TelegramBotConfig TelegramBot { get; set; } = new();
|
||||
public ShellConfig Shell { get; set; } = new();
|
||||
public AutmanPushConfig AutmanPush { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class LegacyCompatibilityConfig
|
||||
{
|
||||
public bool EnableLegacyCliFlags { get; set; } = true;
|
||||
public bool WriteLegacyAppSettings { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ChannelConfigBase
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public int TimeoutSeconds { get; set; } = 15;
|
||||
public bool SkipTlsValidation { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EmailConfig : ChannelConfigBase
|
||||
{
|
||||
public string SmtpHost { get; set; } = string.Empty;
|
||||
public int SmtpPort { get; set; } = 465;
|
||||
public bool EnableSsl { get; set; } = true;
|
||||
public string EmailKey { get; set; } = string.Empty;
|
||||
public string SendEmail { get; set; } = string.Empty;
|
||||
public string ReceiveEmail { get; set; } = string.Empty;
|
||||
|
||||
public string smtpHost { get => SmtpHost; set => SmtpHost = value; }
|
||||
public string smtpPort { get => SmtpPort.ToString(); set => SmtpPort = int.TryParse(value, out int parsed) ? parsed : 465; }
|
||||
public string enableSSL { get => EnableSsl.ToString().ToLowerInvariant(); set => EnableSsl = bool.TryParse(value, out bool parsed) && parsed; }
|
||||
public string emailKey { get => EmailKey; set => EmailKey = value; }
|
||||
public string sendEmail { get => SendEmail; set => SendEmail = value; }
|
||||
public string reciveEmail { get => ReceiveEmail; set => ReceiveEmail = value; }
|
||||
}
|
||||
|
||||
public sealed class PushPlusConfig : ChannelConfigBase
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string Endpoint { get; set; } = "https://www.pushplus.plus/send";
|
||||
|
||||
public string pushPlusToken { get => Token; set => Token = value; }
|
||||
}
|
||||
|
||||
public sealed class DingTalkConfig : ChannelConfigBase
|
||||
{
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
public string Endpoint { get; set; } = "https://oapi.dingtalk.com/robot/send";
|
||||
|
||||
public string DingTalkAccessToken { get => AccessToken; set => AccessToken = value; }
|
||||
public string DingTalkSecret { get => Secret; set => Secret = value; }
|
||||
}
|
||||
|
||||
public sealed class BarkConfig : ChannelConfigBase
|
||||
{
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
public string BarkKey { get; set; } = string.Empty;
|
||||
|
||||
public string BarkUrl { get => ServerUrl; set => ServerUrl = value; }
|
||||
}
|
||||
|
||||
public sealed class WeComApplicationConfig : ChannelConfigBase
|
||||
{
|
||||
public string CorpId { get; set; } = string.Empty;
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
public string ApplicationSecret { get; set; } = string.Empty;
|
||||
|
||||
public string WeChatQYID { get => CorpId; set => CorpId = value; }
|
||||
public string WeChatQYApplicationID { get => AgentId; set => AgentId = value; }
|
||||
public string WeChatQYApplicationSecret { get => ApplicationSecret; set => ApplicationSecret = value; }
|
||||
}
|
||||
|
||||
public sealed class TelegramBotConfig : ChannelConfigBase
|
||||
{
|
||||
public bool EnableCustomApi { get; set; }
|
||||
public string CustomApi { get; set; } = string.Empty;
|
||||
public string BotToken { get; set; } = string.Empty;
|
||||
public string ChatId { get; set; } = string.Empty;
|
||||
|
||||
public string IsEnableCustomTGBotApi { get => EnableCustomApi.ToString().ToLowerInvariant(); set => EnableCustomApi = bool.TryParse(value, out bool parsed) && parsed; }
|
||||
public string CustomTGBotApi { get => CustomApi; set => CustomApi = value; }
|
||||
public string TGBotToken { get => BotToken; set => BotToken = value; }
|
||||
public string TGBotChatID { get => ChatId; set => ChatId = value; }
|
||||
}
|
||||
|
||||
public sealed class ShellConfig : ChannelConfigBase
|
||||
{
|
||||
public string ShellPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class AutmanPushConfig : ChannelConfigBase
|
||||
{
|
||||
public string ApiUrl { get; set; } = string.Empty;
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string Method { get; set; } = "POST";
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM mcr.microsoft.com/dotnet-buildtools/prereqs:cbl-mariner-2.0-cross-arm64
|
||||
COPY --from=mcr.microsoft.com/dotnet/sdk:10.0.101-azurelinux3.0-amd64 /usr/share/dotnet /usr/share/dotnet
|
||||
RUN if [ -e /usr/bin/dotnet ]; then rm /usr/bin/dotnet; fi
|
||||
RUN ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet
|
||||
ENV DOTNET_NOLOGO=true
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net10.0-cross-arm64-musl
|
||||
COPY --from=mcr.microsoft.com/dotnet/sdk:10.0.101-azurelinux3.0-amd64 /usr/share/dotnet /usr/share/dotnet
|
||||
RUN if [ -e /usr/bin/dotnet ]; then rm /usr/bin/dotnet; fi
|
||||
RUN ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet
|
||||
ENV DOTNET_NOLOGO=true
|
||||
@@ -0,0 +1,230 @@
|
||||
# DbusSmsForward
|
||||
|
||||
DbusSmsForward 的重构仓库目前已经具备一套可运行的 **Python 版本主链路**:
|
||||
|
||||
- 配置加载与兼容旧版 `appSettings`
|
||||
- 文件短信源轮询
|
||||
- ModemManager 短信收取/发送抽象
|
||||
- 多通知通道分发(含 AutmanPush)
|
||||
- Web 控制台(运行总览 / 最近事件 / 配置中心 / 短信测试台)
|
||||
- CLI 运行、配置校验、环境自检、手工发送、Web 服务启动
|
||||
- `env-check` 支持 text/json 两种输出,便于人看和自动化集成
|
||||
- 单元测试与集成测试
|
||||
|
||||
仓库里仍保留原始 .NET 版本源码,便于对照迁移;当前建议优先使用 `src/` 下的 Python 实现。
|
||||
|
||||
---
|
||||
|
||||
## 当前重构状态
|
||||
|
||||
已完成的 Python 能力:
|
||||
|
||||
- `validate-config`:配置校验
|
||||
- `env-check`:环境自检(检查 Python / 配置 / DBus / ModemManager 依赖)
|
||||
- `run`:轮询短信源并分发通知
|
||||
- `send`:手工触发通知分发;`--real-sms` 走真实短信发送
|
||||
- `serve-web`:启动 Web 控制台
|
||||
- Web API:
|
||||
- `GET /api/health`
|
||||
- `GET /api/system/status`
|
||||
- `GET /api/dashboard`
|
||||
- `GET /api/events`
|
||||
- `GET/POST /api/settings`
|
||||
- `POST /api/sms/send`
|
||||
|
||||
已覆盖的验证:
|
||||
|
||||
- 47 个自动化测试全部通过
|
||||
- `appsettings.python.json` 配置校验通过
|
||||
- 文件短信源手工跑通
|
||||
|
||||
---
|
||||
|
||||
## 目录说明
|
||||
|
||||
```text
|
||||
src/dbussmsforward/ Python 重构主实现
|
||||
tests/ 自动化测试
|
||||
wwwroot/ Web 控制台静态资源
|
||||
DbusTest/ 原始 .NET 实现(迁移参考)
|
||||
appsettings.python.json Python 示例配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行要求
|
||||
|
||||
- Python 3.11+
|
||||
- 当前项目采用 `src/` 布局,直接运行时请带上 `PYTHONPATH=src`
|
||||
|
||||
如果要接真实 ModemManager / DBus:
|
||||
|
||||
- 目标机器需要可访问系统 DBus
|
||||
- 需要存在 `org.freedesktop.ModemManager1`
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1)校验配置
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli validate-config --config appsettings.python.json
|
||||
```
|
||||
|
||||
### 2)环境自检
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli env-check --config appsettings.python.json
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli env-check --config appsettings.python.json --format json
|
||||
```
|
||||
|
||||
- 文件短信源场景:检查配置文件、Python 运行时、通知通道、短信文件路径
|
||||
- ModemManager 场景:额外检查 `dbus_next`、`dbus-send` / `dbus-monitor` / `mmcli`、systemd 或 OpenWRT init 脚本提示
|
||||
- 返回码:`0` 表示全部通过,`2` 表示存在缺项,适合部署脚本或 CI 直接判定
|
||||
- `--format json`:输出 `results` + `summary`,适合安装器、CI、健康检查脚本消费
|
||||
|
||||
### 3)跑文件短信源示例
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli run --config appsettings.python.json
|
||||
```
|
||||
|
||||
### 3)手工发送一条测试短信到通知链路
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli send --config appsettings.python.json --to 10086 --text '测试短信'
|
||||
```
|
||||
|
||||
### 4)真实调用 ModemManager 发送短信
|
||||
|
||||
前提:配置中的 `sms_source.kind` 为 `modemmanager`。
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli send --config appsettings.python.json --to 10086 --text '测试短信' --real-sms
|
||||
```
|
||||
|
||||
### 5)启动 Web 控制台
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli serve-web --config appsettings.python.json --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
打开:`http://127.0.0.1:8080`
|
||||
|
||||
---
|
||||
|
||||
## 配置示例
|
||||
|
||||
项目内置了 `appsettings.python.json`,包含:
|
||||
|
||||
- `forwarding.device_name`
|
||||
- `notifications.console`
|
||||
- `notifications.autman_push`
|
||||
- `sms_source.kind=file`
|
||||
- `sms_source.path=tests/fixtures/messages.json`
|
||||
|
||||
示例里默认启用了 console,AutmanPush 默认关闭,适合本地先验证主链路。
|
||||
|
||||
---
|
||||
|
||||
## Web 控制台功能
|
||||
|
||||
当前 Web 页面的核心能力:
|
||||
|
||||
- 运行总览:服务状态、监听地址、设备名、短信源、启用通道
|
||||
- 最近事件:最近短信转发/发送结果,显示成功失败与通道
|
||||
- 配置中心:在线修改配置并落盘
|
||||
- 短信测试台:直接从网页发起手工测试
|
||||
- 操作结果面板:展示最近一次操作返回
|
||||
|
||||
适合 Debian/OpenWRT 场景下作为单机维护后台。
|
||||
|
||||
---
|
||||
|
||||
## 开发者工作流
|
||||
|
||||
### 跑完整测试
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
### 常用单命令回归
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli validate-config --config appsettings.python.json
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli run --config appsettings.python.json
|
||||
```
|
||||
|
||||
### 可编辑安装
|
||||
|
||||
```bash
|
||||
python3 -m pip install -e .
|
||||
```
|
||||
|
||||
安装后可直接使用:
|
||||
|
||||
```bash
|
||||
dbussmsforward validate-config --config appsettings.python.json
|
||||
dbussmsforward serve-web --config appsettings.python.json --port 8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
Python 版本配置加载器兼容两类结构:
|
||||
|
||||
1. 新结构:`daemon` / `forwarding` / `notifications` / `sms_source`
|
||||
2. 旧结构:`.NET appSettings` 包装格式
|
||||
|
||||
其中 Autman Push 相关配置需包含:
|
||||
|
||||
- `ApiUrl` / `api_url`
|
||||
- `AccessToken` / `access_token`
|
||||
- `Method` / `method`
|
||||
|
||||
---
|
||||
|
||||
## 部署
|
||||
|
||||
本仓库已经补了部署模板,见:
|
||||
|
||||
- `docs/deployment.md`
|
||||
- `deploy/common/appsettings.deploy.json`
|
||||
- `deploy/debian/dbussmsforward.service`
|
||||
- `deploy/debian/install.sh`
|
||||
- `deploy/openwrt/dbussmsforward.init`
|
||||
- `deploy/openwrt/install.sh`
|
||||
|
||||
支持两类部署方式:
|
||||
|
||||
1. **Debian + systemd**
|
||||
2. **OpenWRT + procd**
|
||||
|
||||
服务文件默认使用:
|
||||
|
||||
- 配置路径:`/etc/dbussmsforward/appsettings.json`
|
||||
- 程序目录:`/opt/dbussmsforward`
|
||||
- 运行模式:`python3 -m dbussmsforward.cli run --iterations 0`
|
||||
|
||||
`--iterations 0` 表示持续轮询,适合作为守护进程运行。
|
||||
|
||||
---
|
||||
|
||||
## 后续建议
|
||||
|
||||
虽然主链路、文档和基础部署模板已经齐了,但如果继续往正式交付推进,可以按这个顺序继续:
|
||||
|
||||
1. 增加真实 Debian / OpenWRT 机器上的联调记录
|
||||
2. 补充打包发布流程(wheel / sdist / release artifact)
|
||||
3. 基于真实机器反馈继续扩展环境自检项(例如联网探测、服务状态细查)
|
||||
4. 再决定是否补 Web 登录、权限和更完整的运维页面
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
1. [ModemManager API document](https://www.freedesktop.org/software/ModemManager/api/latest/)
|
||||
2. [Tmds.DBus](https://github.com/tmds/Tmds.DBus)
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# 接受命令行参数
|
||||
telnum=$1
|
||||
smsdate=$2
|
||||
smscontent=$3
|
||||
smscode=$4
|
||||
smscodefrom=$5
|
||||
devicename=$6
|
||||
|
||||
pushtitle='短信转发 '$telnum
|
||||
pushcontent='发信电话:'$telnum'\n时间:'$smsdate'\n转发设备:'$devicename'\n短信内容:'$smscontent
|
||||
|
||||
if [ -n "$smscode" ]; then
|
||||
pushtitle=$smscodefrom$smscode' '$pushtitle
|
||||
fi
|
||||
|
||||
curl --location 'http://www.pushplus.plus/send/' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"token":"你的pushplustoken","title":"'"$pushtitle"'","content":"'"$pushcontent"'"}'
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"forwarding": {
|
||||
"device_name": "demo-router"
|
||||
},
|
||||
"notifications": {
|
||||
"console": {
|
||||
"enabled": true
|
||||
},
|
||||
"autman_push": {
|
||||
"enabled": false,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "replace-with-token",
|
||||
"method": "POST"
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "tests/fixtures/messages.json",
|
||||
"delete_after_read": false,
|
||||
"poll_interval_seconds": 5,
|
||||
"modem_object_path": "auto",
|
||||
"system_bus_address": "org.freedesktop.ModemManager1",
|
||||
"modemmanager_service": "org.freedesktop.ModemManager1",
|
||||
"storage_blacklist": [
|
||||
"sm"
|
||||
],
|
||||
"state_ready_values": [
|
||||
3
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
# DbusSmsForward Debian 深度重构设计
|
||||
|
||||
## 现状判断
|
||||
当前公开仓库并不是 OpenWRT 专用实现,实际上已经围绕 Debian + ModemManager + DBus 工作。但它离“可在 Debian 上长期稳定使用”的目标还有明显差距,主要是工程结构过于原始。
|
||||
|
||||
## 当前架构主要问题
|
||||
1. **纯交互式入口**
|
||||
- `Program.cs` 通过 `Console.ReadLine()` 完成模式选择、渠道选择、首次配置。
|
||||
- 这导致它无法无交互运行,不适合 systemd、daemon、自启动、容器化或远程运维。
|
||||
|
||||
2. **配置模型不适合服务部署**
|
||||
- `ConfigHelper` 只读写 `AppContext.BaseDirectory/appsettings.json`。
|
||||
- 这意味着配置和程序强绑定,无法自然放到 `/etc/dbussmsforward/`、`~/.config/` 或通过环境变量注入。
|
||||
- 也没有配置校验、默认值归一化、密钥与普通配置分层。
|
||||
|
||||
3. **发送渠道实现高度耦合**
|
||||
- `ProcessChoise` 既处理交互,又决定启用哪些发送器,又触发各发送器自己的“首次输入配置流程”。
|
||||
- 每个 `SendBy*` 类都直接读配置、拼接 HTTP、同步阻塞调用、直接向控制台打印结果。
|
||||
- 不利于扩展新渠道,也无法做统一重试、超时、日志与错误处理。
|
||||
|
||||
4. **HTTP/证书处理有风险**
|
||||
- `HttpHelper` 全局使用 `DangerousAcceptAnyServerCertificateValidator`。
|
||||
- 这在 Debian 长期运行场景下非常危险,等于默认跳过 TLS 校验。
|
||||
|
||||
5. **同步阻塞 + 弱错误处理**
|
||||
- 大量 `.Result` 阻塞异步调用。
|
||||
- 错误处理基本是 `catch(Exception ex) Console.WriteLine(...)`,缺乏分级日志和可观测性。
|
||||
|
||||
6. **项目目标平台过窄**
|
||||
- `DbusSmsForward.csproj` 固定 `RuntimeIdentifiers=linux-musl-arm64`
|
||||
- 启用 `PublishAot=true`
|
||||
- `TargetFramework=net10.0`
|
||||
- 这对 Debian x64/arm64 通用部署非常不友好,也加大本地构建门槛。
|
||||
|
||||
7. **缺少 daemon/service 概念**
|
||||
- 没有 `DaemonConfig` 这种运行期控制配置。
|
||||
- 没有 systemd unit、工作目录规范、日志规范、停止信号处理、启动健康检查。
|
||||
|
||||
8. **历史记忆中的配置未在公开仓库中体现**
|
||||
- 本地历史记忆提到 `AutmanPushConfig` 与 `DaemonConfig`。
|
||||
- 当前公开仓库未包含这两项,说明需要在本次 Debian 重构中主动引入统一的新配置结构。
|
||||
|
||||
## Debian 化重构目标
|
||||
### 核心目标
|
||||
将项目改造成:
|
||||
- 非交互式配置驱动
|
||||
- 可作为 systemd 服务运行
|
||||
- 支持 Debian x64 / arm64
|
||||
- 保留现有发送能力
|
||||
- 增加统一配置模型与运行控制能力
|
||||
- 为未来新增 Autman Push / Webhook / 更多通知渠道预留扩展点
|
||||
|
||||
## 建议的新架构
|
||||
### 1. 宿主层改为 Generic Host
|
||||
用 .NET Generic Host 替代当前顶层裸 `Program.cs`:
|
||||
- `Host.CreateApplicationBuilder(args)`
|
||||
- `IHostedService` / `BackgroundService`
|
||||
- `ILogger<T>`
|
||||
- `IOptions<T>` / 配置绑定
|
||||
- `ConsoleLifetime` / `Systemd` 集成
|
||||
|
||||
建议拆成以下角色:
|
||||
- `SmsForwardWorker`:监听 ModemManager 短信事件并派发
|
||||
- `SmsSendService`:主动发送短信
|
||||
- `ModemManagerClient`:封装 DBus/ModemManager 访问
|
||||
- `NotificationDispatcher`:统一调度多个通知器
|
||||
- `INotificationSender`:每种发送渠道一个实现
|
||||
|
||||
### 2. 配置体系重构
|
||||
建议引入新的配置根模型,例如:
|
||||
- `DaemonConfig`
|
||||
- `ModemConfig`
|
||||
- `ForwardingConfig`
|
||||
- `NotificationTargets`
|
||||
- `AutmanPushConfig`
|
||||
- `WebhookConfig`
|
||||
- `TelegramConfig`
|
||||
- `EmailConfig`
|
||||
- `ShellConfig`
|
||||
|
||||
并支持以下优先级:
|
||||
1. `/etc/dbussmsforward/appsettings.json`
|
||||
2. `./appsettings.json`
|
||||
3. 环境变量(如 `DBUSSMSFORWARD__...`)
|
||||
4. 命令行参数
|
||||
|
||||
### 3. 命令模式替代交互模式
|
||||
建议把交互式入口改成命令式:
|
||||
- `dbussmsforward run`
|
||||
- `dbussmsforward send --to 138... --text "..."`
|
||||
- `dbussmsforward validate-config`
|
||||
- `dbussmsforward print-sample-config`
|
||||
|
||||
保留旧参数 `-fE/-fP/.../-sS` 作为兼容层,但内部只转换成新命令,不再触发交互式流程。
|
||||
|
||||
### 4. 发送渠道抽象
|
||||
定义统一接口:
|
||||
- `INotificationSender`
|
||||
- `string Name`
|
||||
- `Task SendAsync(SmsMessage sms, CancellationToken ct)`
|
||||
|
||||
每个渠道单独实现:
|
||||
- `EmailNotificationSender`
|
||||
- `PushPlusNotificationSender`
|
||||
- `TelegramNotificationSender`
|
||||
- `DingTalkNotificationSender`
|
||||
- `BarkNotificationSender`
|
||||
- `WeComNotificationSender`
|
||||
- `ShellNotificationSender`
|
||||
- `AutmanPushNotificationSender`(新增)
|
||||
|
||||
由 `NotificationDispatcher` 统一完成:
|
||||
- 启用判断
|
||||
- 超时控制
|
||||
- 错误隔离
|
||||
- 日志记录
|
||||
- 可选并发发送
|
||||
|
||||
### 5. 安全与可靠性修复
|
||||
- 默认启用 TLS 证书校验
|
||||
- 仅在配置显式允许时跳过证书校验
|
||||
- 用 `HttpClientFactory` 替换手写同步 `HttpClient`
|
||||
- 所有外部调用改为 async/await
|
||||
- 增加超时、重试(至少对 HTTP 类通知)
|
||||
- 用结构化日志替代 `Console.WriteLine`
|
||||
|
||||
### 6. 构建与发布策略调整
|
||||
建议不要默认强绑 NativeAOT。
|
||||
更适合 Debian 的策略:
|
||||
- 默认:`net8.0` 或 `net9.0` framework-dependent / self-contained
|
||||
- 可选:额外保留 AOT 发布 profile
|
||||
- RIDs 至少支持:
|
||||
- `linux-x64`
|
||||
- `linux-arm64`
|
||||
|
||||
这样更利于 Debian VPS/小主机/工控盒子直接部署。
|
||||
|
||||
## 建议分阶段实施
|
||||
### Phase A:最小可用 Debian 服务化
|
||||
- 改造配置加载
|
||||
- 去掉首次运行交互依赖
|
||||
- 引入 run/send/validate-config 命令
|
||||
- 统一日志
|
||||
- 补 systemd unit 示例
|
||||
|
||||
### Phase B:通知通道抽象化
|
||||
- 拆 `SendBy*` 为 sender 实现
|
||||
- 引入 dispatcher
|
||||
- 补 `AutmanPushConfig`
|
||||
- 允许多渠道并行转发
|
||||
|
||||
### Phase C:兼容与增强
|
||||
- 保留旧 CLI 参数兼容
|
||||
- 支持环境变量覆盖
|
||||
- 支持跳过证书校验的显式开关
|
||||
- 输出 Debian 部署文档
|
||||
|
||||
## 本次实际重构建议
|
||||
如果现在开始直接动代码,我建议这样做:
|
||||
1. 新建 `Configuration/`、`Services/`、`Notifications/`、`Models/` 目录
|
||||
2. 引入新的配置类,其中补上:
|
||||
- `DaemonConfig`
|
||||
- `AutmanPushConfig`
|
||||
3. 重写 `Program.cs` 为 host + command 模式
|
||||
4. 保留现有 `ModemManagerHelper`,但包一层服务接口
|
||||
5. 逐步把 `SendBy*` 改为异步 sender
|
||||
6. 补 `appsettings.example.json`
|
||||
7. 补 `dbussmsforward.service`
|
||||
8. 最后整理 README 的 Debian 使用方式
|
||||
|
||||
## 结论
|
||||
这次重构的重点不是“把 OpenWRT 代码移植到 Debian”,而是:
|
||||
**把一个能在 Debian 上跑的个人脚本式 .NET 工具,重构成一个适合 Debian 长期托管运行的服务程序。**
|
||||
|
||||
下一步应进入 **t3:实施重构、补齐配置/服务化/兼容逻辑**。
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"daemon": {
|
||||
"mode": "forward",
|
||||
"validate_on_startup": true,
|
||||
"use_systemd": true,
|
||||
"log_level": "INFO",
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080
|
||||
},
|
||||
"forwarding": {
|
||||
"device_name": "router-01",
|
||||
"sms_code_key": "验证码±verification±code±认证±代码±随机码",
|
||||
"ignore_storage_type": "sm",
|
||||
"enable_sms_code_extraction": true,
|
||||
"enabled_channels": ["autman_push"]
|
||||
},
|
||||
"notifications": {
|
||||
"console": {
|
||||
"enabled": false
|
||||
},
|
||||
"autman_push": {
|
||||
"enabled": true,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "REPLACE_ME",
|
||||
"method": "POST"
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"path": "",
|
||||
"delete_after_read": false,
|
||||
"poll_interval_seconds": 5,
|
||||
"system_bus_address": "unix:path=/run/dbus/system_bus_socket",
|
||||
"modemmanager_service": "org.freedesktop.ModemManager1",
|
||||
"modem_object_path": "auto",
|
||||
"storage_blacklist": ["sm"],
|
||||
"state_ready_values": [3]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=DbusSmsForward Python SMS forwarding daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/dbussmsforward
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONPATH=/opt/dbussmsforward/src
|
||||
Environment=DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket
|
||||
ExecStart=/usr/bin/env python3 -m dbussmsforward.cli run --config /etc/dbussmsforward/appsettings.json --iterations 0
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_NAME="dbussmsforward"
|
||||
ROOT_DIR="${ROOT_DIR:-/}"
|
||||
APP_DIR="${ROOT_DIR%/}/opt/dbussmsforward"
|
||||
CONFIG_DIR="${ROOT_DIR%/}/etc/dbussmsforward"
|
||||
SYSTEMD_UNIT="${ROOT_DIR%/}/etc/systemd/system/${APP_NAME}.service"
|
||||
CONFIG_FILE="$CONFIG_DIR/appsettings.json"
|
||||
ENV_REPORT="$CONFIG_DIR/install-env-check.json"
|
||||
SKIP_SERVICE_ENABLE="${SKIP_SERVICE_ENABLE:-0}"
|
||||
|
||||
mkdir -p "$APP_DIR" "$CONFIG_DIR" "$(dirname "$SYSTEMD_UNIT")"
|
||||
cp -R src wwwroot pyproject.toml README.md "$APP_DIR/"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
cp deploy/common/appsettings.deploy.json "$CONFIG_FILE"
|
||||
fi
|
||||
cp deploy/debian/dbussmsforward.service "$SYSTEMD_UNIT"
|
||||
|
||||
if ! PYTHONPATH="$APP_DIR/src" python3 -m dbussmsforward.cli env-check --config "$CONFIG_FILE" --format json > "$ENV_REPORT"; then
|
||||
echo "Environment check failed. Report saved to $ENV_REPORT" >&2
|
||||
cat "$ENV_REPORT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SKIP_SERVICE_ENABLE" != "1" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$APP_NAME"
|
||||
fi
|
||||
|
||||
echo "Installed to $APP_DIR"
|
||||
echo "Config: $CONFIG_FILE"
|
||||
echo "Env check report: $ENV_REPORT"
|
||||
echo "Start with: systemctl start $APP_NAME"
|
||||
echo "Logs with: journalctl -u $APP_NAME -f"
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
USE_PROCD=1
|
||||
START=95
|
||||
STOP=10
|
||||
|
||||
NAME="dbussmsforward"
|
||||
APP_DIR="/opt/dbussmsforward"
|
||||
CONFIG_FILE="/etc/dbussmsforward/appsettings.json"
|
||||
PYTHON_BIN="/usr/bin/python3"
|
||||
MODULE="dbussmsforward.cli"
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$PYTHON_BIN" -m "$MODULE" run --config "$CONFIG_FILE" --iterations 0
|
||||
procd_set_param env PYTHONUNBUFFERED=1
|
||||
procd_set_param env DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/dbus/system_bus_socket
|
||||
procd_set_param env PYTHONPATH="$APP_DIR/src"
|
||||
procd_set_param env HOME=/root
|
||||
procd_set_param working_dir "$APP_DIR"
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param file "$CONFIG_FILE"
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "$NAME"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
stop
|
||||
start
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_NAME="dbussmsforward"
|
||||
ROOT_DIR="${ROOT_DIR:-/}"
|
||||
APP_DIR="${ROOT_DIR%/}/opt/dbussmsforward"
|
||||
CONFIG_DIR="${ROOT_DIR%/}/etc/dbussmsforward"
|
||||
INIT_SCRIPT="${ROOT_DIR%/}/etc/init.d/${APP_NAME}"
|
||||
CONFIG_FILE="$CONFIG_DIR/appsettings.json"
|
||||
ENV_REPORT="$CONFIG_DIR/install-env-check.json"
|
||||
SKIP_SERVICE_ENABLE="${SKIP_SERVICE_ENABLE:-0}"
|
||||
|
||||
mkdir -p "$APP_DIR" "$CONFIG_DIR" "$(dirname "$INIT_SCRIPT")"
|
||||
cp -R src wwwroot pyproject.toml README.md "$APP_DIR/"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
cp deploy/common/appsettings.deploy.json "$CONFIG_FILE"
|
||||
fi
|
||||
cp deploy/openwrt/dbussmsforward.init "$INIT_SCRIPT"
|
||||
chmod +x "$INIT_SCRIPT"
|
||||
|
||||
if ! PYTHONPATH="$APP_DIR/src" python3 -m dbussmsforward.cli env-check --config "$CONFIG_FILE" --format json > "$ENV_REPORT"; then
|
||||
echo "Environment check failed. Report saved to $ENV_REPORT" >&2
|
||||
cat "$ENV_REPORT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SKIP_SERVICE_ENABLE" != "1" ]; then
|
||||
"$INIT_SCRIPT" enable
|
||||
fi
|
||||
|
||||
echo "Installed to $APP_DIR"
|
||||
echo "Config: $CONFIG_FILE"
|
||||
echo "Env check report: $ENV_REPORT"
|
||||
echo "Start with: $INIT_SCRIPT start"
|
||||
echo "Logs with: logread -e ${APP_NAME}"
|
||||
@@ -0,0 +1,371 @@
|
||||
# DbusSmsForward Debian Web 版实施计划
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 把当前 Debian 版 DbusSmsForward 改造成“带 Web 管理界面、可直接在 Debian 上运行、默认不要求本地编译”的版本。
|
||||
|
||||
**Architecture:** 保留现有 Generic Host + Worker 的短信转发核心,补一层 ASP.NET Core Minimal API 与静态 `wwwroot` 页面;运行形态改为 `dotnet DbusSmsForward.dll web` 或发布后的自包含目录直接运行。通过配置文件和环境变量驱动,不再依赖交互式初始化。
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core Minimal API, Generic Host, Static Files, JSON config, systemd-friendly hosting.
|
||||
|
||||
---
|
||||
|
||||
## 0. 当前仓库真实状态(已核实)
|
||||
|
||||
**已存在:**
|
||||
- `DbusTest/Program.cs`:已经是 Generic Host 风格入口。
|
||||
- `DbusTest/appsettingsModel.cs`:已引入 `DbusSmsForwardOptions` / `DaemonConfig` / `AutmanPushConfig`。
|
||||
- `DbusTest/Services/SmsForwardWorker.cs`:已有后台转发 worker。
|
||||
- `DbusTest/Notifications/*`:已拆分通知发送器。
|
||||
- `DbusTest/Helper/ConfigHelper.cs`:已支持 `/etc/dbussmsforward/appsettings.json` 优先加载。
|
||||
|
||||
**缺失:**
|
||||
- `wwwroot/` 不存在。
|
||||
- 没有任何 Web API。
|
||||
- `DbusSmsForward.csproj` 仍是 `Microsoft.NET.Sdk.Worker`,不是 Web SDK。
|
||||
- `Program.cs` 里引用了若干当前仓库中不存在的类型:
|
||||
- `SampleConfigFactory`
|
||||
- `LegacyConfigurationBridge`
|
||||
- `CommandLineConfigurationApplier`
|
||||
- `ConfigurationValidator`
|
||||
- 本机当前 `dotnet` 命令不存在,无法本地编译验证;因此本阶段要优先完成“代码落地 + 文档化运行路径”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标运行方式
|
||||
|
||||
### 目标 A:Debian 上直接运行(不在目标机编译)
|
||||
|
||||
推荐两种:
|
||||
|
||||
1. **Framework-dependent**
|
||||
- 目标机只安装 .NET 8 runtime / aspnetcore runtime
|
||||
- 直接运行:
|
||||
- `dotnet DbusSmsForward.dll web --urls http://0.0.0.0:8080`
|
||||
|
||||
2. **Self-contained 发布包**
|
||||
- 预先在别处发布好 Linux x64 / arm64 包
|
||||
- 目标机无需装 dotnet
|
||||
- 直接运行发布目录中的可执行文件
|
||||
|
||||
**注意:** 当前用户要求“最好不需要编译,直接运行在 Debian 上”,因此仓库要优先支持 **运行已发布产物**,而不是要求 Debian 机器安装 SDK 编译源码。
|
||||
|
||||
---
|
||||
|
||||
## 2. 代码实施任务
|
||||
|
||||
### Task 1: 切换项目为 Web 宿主
|
||||
|
||||
**Objective:** 让项目具备 Minimal API + Static Files 能力。
|
||||
|
||||
**Files:**
|
||||
- Modify: `DbusTest/DbusSmsForward.csproj`
|
||||
|
||||
**Step 1: 修改 SDK**
|
||||
|
||||
把:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
```
|
||||
|
||||
**Step 2: 保留现有依赖,继续复制配置文件**
|
||||
|
||||
保留现有 `PackageReference` 与 `appsettings*.json` 输出策略。
|
||||
|
||||
**Step 3: 增加 Web 静态资源复制规则(如需要)**
|
||||
|
||||
确认 `wwwroot/**` 默认由 Web SDK 处理。
|
||||
|
||||
**Verification:**
|
||||
- 代码层面确认 csproj 已切到 Web SDK。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 补齐缺失的配置与兼容辅助类
|
||||
|
||||
**Objective:** 解决 `Program.cs` 当前引用缺失导致的不可编译问题。
|
||||
|
||||
**Files:**
|
||||
- Create: `DbusTest/Helper/SampleConfigFactory.cs`
|
||||
- Create: `DbusTest/Helper/LegacyConfigurationBridge.cs`
|
||||
- Create: `DbusTest/Helper/CommandLineConfigurationApplier.cs`
|
||||
- Create: `DbusTest/Helper/ConfigurationValidator.cs`
|
||||
|
||||
**Step 1: `SampleConfigFactory`**
|
||||
- 返回一个最小可用的 `DbusSmsForwardOptions` 示例对象。
|
||||
- 含 `Daemon/Modem/Forwarding/Notifications/LegacyCompatibility` 基本结构。
|
||||
|
||||
**Step 2: `LegacyConfigurationBridge`**
|
||||
- 从旧格式 `appSettings` 节点读取值,映射到新 `DbusSmsForwardOptions`。
|
||||
- 至少映射:
|
||||
- `DeviceName`
|
||||
- `SmsCodeKey`
|
||||
- `ForwardIgnoreStorageType`
|
||||
- Email / PushPlus / DingTalk / Bark / WeCom / TG / Shell
|
||||
|
||||
**Step 3: `CommandLineConfigurationApplier`**
|
||||
- 把 `AppCommand.EnabledChannels` 写入 `options.Forwarding.EnabledChannels`
|
||||
- 如果旧 flags 指定了渠道,则同步将对应 `options.Notifications.*.Enabled = true`
|
||||
|
||||
**Step 4: `ConfigurationValidator`**
|
||||
- 做最小校验:
|
||||
- `Daemon.Mode` 合法值:`forward` / `web` / `both`
|
||||
- 若启用 email,必须有 host / sender / receiver
|
||||
- 若启用 autmanpush,必须有 ApiUrl
|
||||
- 校验失败抛 `InvalidOperationException`
|
||||
|
||||
**Verification:**
|
||||
- `Program.cs` 的缺失引用都能对应到真实文件。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 扩展命令模型支持 web/both 与 HTTP 端口
|
||||
|
||||
**Objective:** 让启动命令能表达 Web 模式。
|
||||
|
||||
**Files:**
|
||||
- Modify: `DbusTest/Models/AppCommand.cs`
|
||||
- Modify: `DbusTest/Services/CommandLineParser.cs`
|
||||
|
||||
**Step 1: 扩展 `AppCommand` 字段**
|
||||
|
||||
新增:
|
||||
- `bool WebMode`
|
||||
- `bool ForwardMode`
|
||||
- `string Urls`
|
||||
- `int Port`
|
||||
- `string ConfigPath`
|
||||
|
||||
**Step 2: 解析新参数**
|
||||
|
||||
新增支持:
|
||||
- `web`
|
||||
- `both`
|
||||
- `--urls http://0.0.0.0:8080`
|
||||
- `--port 8080`
|
||||
- `--config /etc/dbussmsforward/appsettings.json`
|
||||
|
||||
**Step 3: 默认策略**
|
||||
- `run` = 仅 forward
|
||||
- `web` = 仅 web
|
||||
- `both` = forward + web
|
||||
- 当只传 `--urls` / `--port` 时,默认仍按 `web` 理解更合理;若保持兼容性,也可要求显式 `web`。
|
||||
|
||||
**Verification:**
|
||||
- 参数设计能清晰表达三种启动形态。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 建立 Web API 服务层
|
||||
|
||||
**Objective:** 把 Web 层依赖从 `Program.cs` 抽出来,避免一团乱。
|
||||
|
||||
**Files:**
|
||||
- Create: `DbusTest/Services/SystemStatusService.cs`
|
||||
- Create: `DbusTest/Services/SmsWebService.cs`
|
||||
- Create: `DbusTest/Web/WebDtos.cs`
|
||||
|
||||
**Step 1: `SystemStatusService`**
|
||||
- 复用 `ModemManagerHelper` 暴露:
|
||||
- `GetStatusAsync()`
|
||||
- 返回 modem path / qmi path / device / manufacturer / model / revision / operator / signal
|
||||
|
||||
**Step 2: `SmsWebService`**
|
||||
- 封装:
|
||||
- `SendAsync(phone, content)`
|
||||
- `GetMessagesAsync(limit, phoneFilter, keyword, sort)`
|
||||
- `GetSettings()`
|
||||
- `SaveSettings()`
|
||||
- 若 `ModemManagerHelper` 尚无历史短信读取能力,则先补最小方法;至少支持列表读取。
|
||||
|
||||
**Step 3: DTO**
|
||||
- 请求与响应 DTO:
|
||||
- `SendSmsRequest`
|
||||
- `SettingsResponse`
|
||||
- `SmsMessageItem`
|
||||
- `ApiEnvelope<T>`
|
||||
|
||||
**Verification:**
|
||||
- Web API 调用不直接散落访问底层 helper。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 在 `ModemManagerHelper` 中补历史短信列表接口
|
||||
|
||||
**Objective:** 支持 Web 历史短信页。
|
||||
|
||||
**Files:**
|
||||
- Modify: `DbusTest/ModemManagerHelper.cs`
|
||||
- Create: `DbusTest/Web/SmsMessageRecord.cs`(如 DTO 不想放 helper 内)
|
||||
|
||||
**Step 1: 新增异步方法**
|
||||
|
||||
建议新增:
|
||||
|
||||
```csharp
|
||||
Task<IReadOnlyList<SmsMessageRecord>> ListMessagesAsync(CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
**Step 2: 实现逻辑**
|
||||
- 使用现有 `CreateMessaging(_modemObjectPathNowUse)`
|
||||
- 枚举 DBus message path
|
||||
- 读取:
|
||||
- Number
|
||||
- Text
|
||||
- Timestamp
|
||||
- Storage
|
||||
- State
|
||||
- 做时间转换与空值兜底
|
||||
|
||||
**Step 3: 不破坏现有 forward 逻辑**
|
||||
- 保持现有短信监听逻辑不变
|
||||
- 新增方法只做读取
|
||||
|
||||
**Verification:**
|
||||
- 代码上已经有明确的历史短信读取入口。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 重写 `Program.cs` 为“单进程双模式入口”
|
||||
|
||||
**Objective:** 同一个程序既能跑后台转发,也能跑 Web。
|
||||
|
||||
**Files:**
|
||||
- Modify: `DbusTest/Program.cs`
|
||||
|
||||
**Step 1: 统一宿主构建**
|
||||
- 注册:
|
||||
- `ModemManagerHelper`
|
||||
- `NotificationDispatcher`
|
||||
- 各通知 sender
|
||||
- `SystemStatusService`
|
||||
- `SmsWebService`
|
||||
- 仅在 forward 模式下注册 `SmsForwardWorker`
|
||||
|
||||
**Step 2: Web 模式构建**
|
||||
- 使用 `WebApplication.CreateBuilder(args)` 或在现有 host 上兼容 Web host
|
||||
- 开启:
|
||||
- `UseDefaultFiles()`
|
||||
- `UseStaticFiles()`
|
||||
|
||||
**Step 3: Minimal API 路由**
|
||||
- `GET /api/system/status`
|
||||
- `GET /api/sms/messages`
|
||||
- `POST /api/sms/send`
|
||||
- `GET /api/settings`
|
||||
- `POST /api/settings`
|
||||
- `GET /api/health`
|
||||
|
||||
**Step 4: 启动逻辑**
|
||||
- `send`:一次性发短信后退出
|
||||
- `run`:只跑后台 worker
|
||||
- `web`:只跑 Web
|
||||
- `both`:Web + worker 同时运行
|
||||
|
||||
**Verification:**
|
||||
- `Program.cs` 不再只会 `host.RunAsync()` 一条路。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 增加最小可用 Web UI
|
||||
|
||||
**Objective:** 让 BOSS 在浏览器里直接看效果。
|
||||
|
||||
**Files:**
|
||||
- Create: `DbusTest/wwwroot/index.html`
|
||||
- Create: `DbusTest/wwwroot/app.js`
|
||||
- Create: `DbusTest/wwwroot/app.css`
|
||||
|
||||
**Step 1: 页面结构**
|
||||
- 菜单:
|
||||
- 仪表盘
|
||||
- 历史短信
|
||||
- 发送短信
|
||||
- 系统配置
|
||||
|
||||
**Step 2: 页面能力**
|
||||
- 仪表盘:加载 `/api/system/status`
|
||||
- 历史短信:加载 `/api/sms/messages`
|
||||
- 发送短信:调用 `/api/sms/send`
|
||||
- 配置:读写 `/api/settings`
|
||||
|
||||
**Step 3: 保持纯静态实现**
|
||||
- 原生 HTML/CSS/JS
|
||||
- 不引入前端构建工具
|
||||
- 保证部署简单
|
||||
|
||||
**Verification:**
|
||||
- 浏览器打开根路径 `/` 能直接进入管理页。
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 提供 Debian 直接运行所需文件
|
||||
|
||||
**Objective:** 让“直接运行在 Debian 上”真正落地。
|
||||
|
||||
**Files:**
|
||||
- Create: `DbusTest/appsettings.example.json`
|
||||
- Create: `DbusTest/dbussmsforward.service`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Step 1: 示例配置**
|
||||
- 提供新结构 JSON 样例
|
||||
- 默认 `Daemon.Mode = "both"`
|
||||
- 默认 `urls` 示例写到 README,不强绑进配置
|
||||
|
||||
**Step 2: systemd unit**
|
||||
- 示例:
|
||||
- `WorkingDirectory=/opt/dbussmsforward`
|
||||
- `ExecStart=/usr/bin/dotnet /opt/dbussmsforward/DbusSmsForward.dll both --urls http://0.0.0.0:8080`
|
||||
- `Environment=DBUSSMSFORWARD_CONFIG_PATH=/etc/dbussmsforward/appsettings.json`
|
||||
|
||||
**Step 3: README 更新重点**
|
||||
- 明确区分:
|
||||
- 运行源码需要 SDK
|
||||
- 运行发布产物只需 runtime 或无需 runtime(self-contained)
|
||||
- 给出 Debian 直接运行命令
|
||||
- 给出 systemd 安装步骤
|
||||
|
||||
**Verification:**
|
||||
- 文档能支撑“别人拿到发布包就在 Debian 跑起来”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本次实施优先级
|
||||
|
||||
1. 先修复仓库当前“缺失类导致不可编译”的问题
|
||||
2. 再补 Web API 骨架
|
||||
3. 再补 `wwwroot` 管理页
|
||||
4. 最后补 README / example config / systemd
|
||||
|
||||
---
|
||||
|
||||
## 4. 运行验收标准
|
||||
|
||||
满足以下即视为达到目标:
|
||||
|
||||
- 可用命令:
|
||||
- `dotnet DbusSmsForward.dll run`
|
||||
- `dotnet DbusSmsForward.dll web --urls http://0.0.0.0:8080`
|
||||
- `dotnet DbusSmsForward.dll both --urls http://0.0.0.0:8080`
|
||||
- `dotnet DbusSmsForward.dll send --to 13800138000 --text "测试短信"`
|
||||
- 根路径 `/` 可打开 Web UI
|
||||
- `/api/health` 返回成功
|
||||
- `/api/system/status` 返回 modem 状态
|
||||
- `/api/sms/messages` 返回历史短信列表
|
||||
- `/api/settings` 可读写配置
|
||||
- 提供 Debian 的 systemd unit 示例
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前阻塞说明
|
||||
|
||||
- 本机没有 `dotnet`,所以暂时不能做本地编译/运行验证。
|
||||
- 但仓库代码改造、文件补齐、运行文档和 Debian 部署入口可以现在直接完成。
|
||||
@@ -0,0 +1,172 @@
|
||||
# Deployment Guide
|
||||
|
||||
This document covers the Python refactor deployment layout for Debian/systemd and OpenWRT/procd.
|
||||
|
||||
## Runtime assumptions
|
||||
|
||||
- Python 3.11+ is available on the target machine.
|
||||
- The project is deployed as source code under `/opt/dbussmsforward`.
|
||||
- Runtime config lives at `/etc/dbussmsforward/appsettings.json`.
|
||||
- For real modem integration, `sms_source.kind` should be `modemmanager` and the host must expose the system D-Bus socket.
|
||||
|
||||
Recommended target layout:
|
||||
|
||||
```text
|
||||
/opt/dbussmsforward/
|
||||
src/
|
||||
wwwroot/
|
||||
pyproject.toml
|
||||
README.md
|
||||
/etc/dbussmsforward/
|
||||
appsettings.json
|
||||
```
|
||||
|
||||
A starter production-like config is provided at `deploy/common/appsettings.deploy.json`.
|
||||
|
||||
---
|
||||
|
||||
## Debian / systemd
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip dbus modemmanager
|
||||
sudo systemctl enable --now dbus ModemManager
|
||||
```
|
||||
|
||||
### Copy project files
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/dbussmsforward /etc/dbussmsforward
|
||||
sudo cp -R src wwwroot pyproject.toml README.md /opt/dbussmsforward/
|
||||
sudo cp deploy/common/appsettings.deploy.json /etc/dbussmsforward/appsettings.json
|
||||
sudo cp deploy/debian/dbussmsforward.service /etc/systemd/system/dbussmsforward.service
|
||||
```
|
||||
|
||||
### Start service
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now dbussmsforward
|
||||
sudo systemctl status dbussmsforward
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
journalctl -u dbussmsforward -f
|
||||
```
|
||||
|
||||
### One-shot installer
|
||||
|
||||
```bash
|
||||
sudo sh deploy/debian/install.sh
|
||||
```
|
||||
|
||||
The installer now runs `env-check --format json` before enabling the service.
|
||||
If the check fails, installation stops and writes a machine-readable report to:
|
||||
|
||||
```text
|
||||
/etc/dbussmsforward/install-env-check.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenWRT / procd
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg install python3 python3-light dbus modemmanager
|
||||
/etc/init.d/dbus enable && /etc/init.d/dbus start
|
||||
/etc/init.d/modemmanager enable && /etc/init.d/modemmanager start
|
||||
```
|
||||
|
||||
> Depending on firmware, you may need extra Python packages or to bundle a venv/site-packages tree yourself.
|
||||
|
||||
### Copy project files
|
||||
|
||||
```sh
|
||||
mkdir -p /opt/dbussmsforward /etc/dbussmsforward
|
||||
cp -R src wwwroot pyproject.toml README.md /opt/dbussmsforward/
|
||||
cp deploy/common/appsettings.deploy.json /etc/dbussmsforward/appsettings.json
|
||||
cp deploy/openwrt/dbussmsforward.init /etc/init.d/dbussmsforward
|
||||
chmod +x /etc/init.d/dbussmsforward
|
||||
```
|
||||
|
||||
### Start service
|
||||
|
||||
```sh
|
||||
/etc/init.d/dbussmsforward enable
|
||||
/etc/init.d/dbussmsforward start
|
||||
/etc/init.d/dbussmsforward status
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```sh
|
||||
logread -e dbussmsforward
|
||||
```
|
||||
|
||||
### One-shot installer
|
||||
|
||||
```sh
|
||||
sh deploy/openwrt/install.sh
|
||||
```
|
||||
|
||||
The installer now runs `env-check --format json` before enabling the init script.
|
||||
If the check fails, installation stops and writes a machine-readable report to:
|
||||
|
||||
```text
|
||||
/etc/dbussmsforward/install-env-check.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config notes
|
||||
|
||||
The deploy template defaults to:
|
||||
|
||||
- `forwarding.enabled_channels = ["autman_push"]`
|
||||
- `notifications.autman_push.enabled = true`
|
||||
- `sms_source.kind = "modemmanager"`
|
||||
- `sms_source.modem_object_path = "auto"`
|
||||
|
||||
You should replace:
|
||||
|
||||
- `forwarding.device_name`
|
||||
- `notifications.autman_push.api_url`
|
||||
- `notifications.autman_push.access_token`
|
||||
|
||||
If you want a safer smoke test before connecting to ModemManager, switch to:
|
||||
|
||||
```json
|
||||
"notifications": { "console": { "enabled": true } },
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "tests/fixtures/messages.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
Before enabling the service permanently:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli validate-config --config deploy/common/appsettings.deploy.json
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli env-check --config deploy/common/appsettings.deploy.json --format json
|
||||
```
|
||||
|
||||
The JSON output contains a `results` array plus a `summary` object, making it suitable for CI, installers, and deployment health probes.
|
||||
|
||||
For a local foreground smoke test:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m dbussmsforward.cli run --config /etc/dbussmsforward/appsettings.json --iterations 1
|
||||
```
|
||||
|
||||
For actual daemon/service mode, the service files use `--iterations 0`, which means keep polling forever.
|
||||
@@ -0,0 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "dbussmsforward-py"
|
||||
version = "0.1.0"
|
||||
description = "Python refactor and Web console for DbusSmsForward"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "Hermes Agent" }
|
||||
]
|
||||
keywords = ["sms", "modemmanager", "dbus", "forwarder", "openwrt", "debian"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
dbussmsforward = "dbussmsforward.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Python refactor of DbusSmsForward."""
|
||||
|
||||
from .config import AppConfig, load_config_dict
|
||||
|
||||
__all__ = ["AppConfig", "load_config_dict"]
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from time import sleep
|
||||
from typing import Callable
|
||||
|
||||
from .config import AppConfig
|
||||
from .notifications import DispatchOutcome, HttpOpener, NotificationDispatcher, SenderDispatchResult, build_dispatcher
|
||||
from .sms import NotificationMessage, SmsMessage
|
||||
from .source import BusFactory, FetchIssue, SmsSource, SmsSender, build_sms_sender, build_sms_source
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Runtime:
|
||||
source: SmsSource
|
||||
dispatcher: NotificationDispatcher
|
||||
sms_sender: SmsSender
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DispatchFailureDetail:
|
||||
sender: str
|
||||
phone_number: str
|
||||
received_at: str
|
||||
error: str
|
||||
error_category: str | None = None
|
||||
status_code: int | None = None
|
||||
reason: str | None = None
|
||||
response_excerpt: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ForwardLoopResult:
|
||||
polls: int = 0
|
||||
fetched: int = 0
|
||||
dispatched: int = 0
|
||||
dispatch_failures: int = 0
|
||||
duplicates_skipped: int = 0
|
||||
fetch_issue_count: int = 0
|
||||
delivered_messages: list[NotificationMessage] = field(default_factory=list)
|
||||
failure_details: list[DispatchFailureDetail] = field(default_factory=list)
|
||||
fetch_issue_details: list[FetchIssue] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_runtime(
|
||||
config: AppConfig,
|
||||
http_opener: HttpOpener | None = None,
|
||||
bus_factory: BusFactory | None = None,
|
||||
) -> Runtime:
|
||||
return Runtime(
|
||||
source=build_sms_source(config, bus_factory=bus_factory),
|
||||
dispatcher=build_dispatcher(config, http_opener=http_opener),
|
||||
sms_sender=build_sms_sender(config, bus_factory=bus_factory),
|
||||
)
|
||||
|
||||
|
||||
def run_forward_loop(
|
||||
runtime: Runtime,
|
||||
*,
|
||||
max_iterations: int = 1,
|
||||
sleep_seconds: int = 0,
|
||||
sleep_func: Callable[[float], None] = sleep,
|
||||
max_seen_messages: int = 1000,
|
||||
) -> ForwardLoopResult:
|
||||
seen_keys: set[tuple[str, str, str]] = set()
|
||||
seen_order: deque[tuple[str, str, str]] = deque()
|
||||
result = ForwardLoopResult()
|
||||
iterations = int(max_iterations)
|
||||
infinite = iterations <= 0
|
||||
index = 0
|
||||
|
||||
while infinite or index < iterations:
|
||||
result.polls += 1
|
||||
fetched_messages = list(runtime.source.fetch())
|
||||
fetch_issues = list(getattr(runtime.source, "last_fetch_issues", []))
|
||||
if fetch_issues:
|
||||
result.fetch_issue_count += len(fetch_issues)
|
||||
result.fetch_issue_details.extend(fetch_issues)
|
||||
for sms in fetched_messages:
|
||||
result.fetched += 1
|
||||
key = _sms_key(sms)
|
||||
if key in seen_keys:
|
||||
result.duplicates_skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
outcome = runtime.dispatcher.dispatch(sms)
|
||||
except Exception:
|
||||
result.dispatch_failures += 1
|
||||
continue
|
||||
|
||||
if outcome.success_count == 0:
|
||||
result.dispatch_failures += max(1, len(outcome.failures))
|
||||
result.failure_details.extend(_build_failure_details(sms, outcome.failures))
|
||||
continue
|
||||
|
||||
seen_keys.add(key)
|
||||
seen_order.append(key)
|
||||
while len(seen_order) > max(1, int(max_seen_messages)):
|
||||
expired = seen_order.popleft()
|
||||
seen_keys.discard(expired)
|
||||
|
||||
result.dispatched += 1
|
||||
result.delivered_messages.extend(outcome.messages)
|
||||
if outcome.failures:
|
||||
result.dispatch_failures += len(outcome.failures)
|
||||
result.failure_details.extend(_build_failure_details(sms, outcome.failures))
|
||||
|
||||
index += 1
|
||||
if sleep_seconds > 0 and (infinite or index < iterations):
|
||||
sleep_func(sleep_seconds)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _sms_key(sms: SmsMessage) -> tuple[str, str, str]:
|
||||
return (sms.phone_number, sms.content, sms.received_at)
|
||||
|
||||
|
||||
def _build_failure_details(sms: SmsMessage, failures: list[SenderDispatchResult]) -> list[DispatchFailureDetail]:
|
||||
return [
|
||||
DispatchFailureDetail(
|
||||
sender=failure.sender,
|
||||
phone_number=sms.phone_number,
|
||||
received_at=sms.received_at,
|
||||
error=failure.error or "unknown error",
|
||||
error_category=failure.error_category,
|
||||
status_code=failure.status_code,
|
||||
reason=failure.reason,
|
||||
response_excerpt=failure.response_excerpt,
|
||||
)
|
||||
for failure in failures
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .app import build_runtime, run_forward_loop
|
||||
from .config import AppConfig, dump_config_dict, load_config_dict
|
||||
from .env_check import format_check_results, format_check_results_json, run_environment_check, summarize_results
|
||||
from .sms import SmsMessage
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog="dbussmsforward")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["run", "send", "validate-config", "print-sample-config", "serve-web", "env-check"],
|
||||
)
|
||||
parser.add_argument("--config", default="appsettings.json")
|
||||
parser.add_argument("--to")
|
||||
parser.add_argument("--text")
|
||||
parser.add_argument("--real-sms", action="store_true")
|
||||
parser.add_argument("--iterations", type=int, default=1)
|
||||
parser.add_argument("--dedupe-history", type=int, default=1000)
|
||||
parser.add_argument("--host")
|
||||
parser.add_argument("--port", type=int)
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config_file(path: str) -> AppConfig:
|
||||
config_path = Path(path)
|
||||
if not config_path.exists():
|
||||
return load_config_dict({})
|
||||
return load_config_dict(json.loads(config_path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def resolve_config_path(path: str) -> Path:
|
||||
return Path(path).expanduser().resolve()
|
||||
|
||||
|
||||
def validate_config(config: AppConfig) -> None:
|
||||
errors: list[str] = []
|
||||
|
||||
if config.notifications.autman_push.enabled:
|
||||
if not config.notifications.autman_push.api_url.strip():
|
||||
errors.append("notifications.autman_push.api_url is required when AutmanPush is enabled")
|
||||
if not config.notifications.autman_push.access_token.strip():
|
||||
errors.append("notifications.autman_push.access_token is required when AutmanPush is enabled")
|
||||
method = (config.notifications.autman_push.method or "POST").strip().upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
errors.append("notifications.autman_push.method must be GET or POST")
|
||||
|
||||
source_kind = config.sms_source.kind
|
||||
if source_kind == "file" and not config.sms_source.path.strip():
|
||||
errors.append("sms_source.path is required when sms_source.kind='file'")
|
||||
if config.sms_source.poll_interval_seconds < 0:
|
||||
errors.append("sms_source.poll_interval_seconds must be >= 0")
|
||||
if source_kind == "modemmanager":
|
||||
modem_path = config.sms_source.modem_object_path.strip()
|
||||
if not modem_path:
|
||||
errors.append("sms_source.modem_object_path is required when sms_source.kind='modemmanager'")
|
||||
if not config.sms_source.state_ready_values:
|
||||
errors.append("sms_source.state_ready_values must not be empty when sms_source.kind='modemmanager'")
|
||||
|
||||
if errors:
|
||||
raise ValueError("; ".join(errors))
|
||||
|
||||
|
||||
def emit_messages(messages: list) -> int:
|
||||
for message in messages:
|
||||
print(f"[{message.channel}] {message.title}")
|
||||
print(message.body)
|
||||
return 0
|
||||
|
||||
|
||||
def emit_failure_details(failure_details: list) -> None:
|
||||
for failure in failure_details:
|
||||
parts = [
|
||||
"dispatch_failure",
|
||||
f"sender={failure.sender}",
|
||||
f"phone={failure.phone_number}",
|
||||
f"received_at={failure.received_at}",
|
||||
f"error={failure.error}",
|
||||
]
|
||||
if getattr(failure, "error_category", None):
|
||||
parts.append(f"error_category={failure.error_category}")
|
||||
if getattr(failure, "status_code", None) is not None:
|
||||
parts.append(f"status={failure.status_code}")
|
||||
if getattr(failure, "reason", None):
|
||||
parts.append(f"reason={failure.reason}")
|
||||
if getattr(failure, "response_excerpt", None):
|
||||
parts.append(f"response_excerpt={failure.response_excerpt}")
|
||||
print(" ".join(parts), file=sys.stderr)
|
||||
|
||||
|
||||
def emit_fetch_issue_details(fetch_issue_details: list) -> None:
|
||||
for issue in fetch_issue_details:
|
||||
print(f"fetch_issue kind={issue.kind} detail={issue.detail}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
config_path = resolve_config_path(args.config)
|
||||
config = load_config_file(str(config_path))
|
||||
|
||||
if args.command == "print-sample-config":
|
||||
print(json.dumps(dump_config_dict(config), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
if args.host:
|
||||
config.daemon.host = args.host
|
||||
if args.port is not None:
|
||||
config.daemon.port = int(args.port)
|
||||
|
||||
if args.command == "env-check":
|
||||
results = run_environment_check(config, config_path=config_path)
|
||||
formatter = format_check_results_json if args.format == "json" else format_check_results
|
||||
print(formatter(results))
|
||||
failed, _ = summarize_results(results)
|
||||
return 0 if failed == 0 else 2
|
||||
|
||||
validate_config(config)
|
||||
|
||||
if args.command == "validate-config":
|
||||
print("config ok")
|
||||
return 0
|
||||
|
||||
if args.command == "serve-web":
|
||||
from .web import create_web_app, serve_web_app
|
||||
|
||||
app = create_web_app(config, config_path=config_path)
|
||||
print(f"serving web on http://{config.daemon.host}:{config.daemon.port}", file=sys.stderr)
|
||||
serve_web_app(app)
|
||||
return 0
|
||||
|
||||
runtime = build_runtime(config)
|
||||
|
||||
if args.command == "send":
|
||||
if not args.to or not args.text:
|
||||
raise SystemExit("send requires --to and --text")
|
||||
if args.real_sms:
|
||||
result = runtime.sms_sender.send(phone_number=args.to, content=args.text)
|
||||
if not result.success:
|
||||
print(result.error or "sms send failed", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "sent",
|
||||
"phone_number": result.phone_number,
|
||||
"content": result.content,
|
||||
"modem_path": result.modem_path,
|
||||
"message_path": result.message_path,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
message = SmsMessage(phone_number=args.to, content=args.text, received_at="manual")
|
||||
return emit_messages(runtime.dispatcher.dispatch(message))
|
||||
|
||||
result = run_forward_loop(
|
||||
runtime,
|
||||
max_iterations=max(1, args.iterations),
|
||||
sleep_seconds=max(0, config.sms_source.poll_interval_seconds),
|
||||
max_seen_messages=max(1, args.dedupe_history),
|
||||
)
|
||||
print(
|
||||
" ".join(
|
||||
[
|
||||
f"polls={result.polls}",
|
||||
f"fetched={result.fetched}",
|
||||
f"dispatched={result.dispatched}",
|
||||
f"dispatch_failures={result.dispatch_failures}",
|
||||
f"duplicates_skipped={result.duplicates_skipped}",
|
||||
f"fetch_issues={result.fetch_issue_count}",
|
||||
]
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
emit_failure_details(result.failure_details)
|
||||
emit_fetch_issue_details(result.fetch_issue_details)
|
||||
return emit_messages(result.delivered_messages)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DaemonConfig:
|
||||
mode: str = "forward"
|
||||
validate_on_startup: bool = True
|
||||
use_systemd: bool = True
|
||||
log_level: str = "INFO"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ForwardingConfig:
|
||||
device_name: str = ""
|
||||
sms_code_key: str = "验证码±verification±code±认证±代码±随机码"
|
||||
ignore_storage_type: str = "sm"
|
||||
enable_sms_code_extraction: bool = True
|
||||
enabled_channels: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChannelConfigBase:
|
||||
enabled: bool = False
|
||||
timeout_seconds: int = 15
|
||||
skip_tls_validation: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EmailConfig(ChannelConfigBase):
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 465
|
||||
enable_ssl: bool = True
|
||||
email_key: str = ""
|
||||
send_email: str = ""
|
||||
receive_email: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PushPlusConfig(ChannelConfigBase):
|
||||
token: str = ""
|
||||
endpoint: str = "https://www.pushplus.plus/send"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DingTalkConfig(ChannelConfigBase):
|
||||
access_token: str = ""
|
||||
secret: str = ""
|
||||
endpoint: str = "https://oapi.dingtalk.com/robot/send"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BarkConfig(ChannelConfigBase):
|
||||
server_url: str = ""
|
||||
bark_key: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WeComApplicationConfig(ChannelConfigBase):
|
||||
corp_id: str = ""
|
||||
agent_id: str = ""
|
||||
application_secret: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelegramBotConfig(ChannelConfigBase):
|
||||
enable_custom_api: bool = False
|
||||
custom_api: str = ""
|
||||
bot_token: str = ""
|
||||
chat_id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellConfig(ChannelConfigBase):
|
||||
shell_path: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutmanPushConfig(ChannelConfigBase):
|
||||
api_url: str = ""
|
||||
access_token: str = ""
|
||||
method: str = "POST"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SmsSourceConfig:
|
||||
kind: str = "none"
|
||||
path: str = ""
|
||||
delete_after_read: bool = False
|
||||
poll_interval_seconds: int = 5
|
||||
system_bus_address: str = "org.freedesktop.ModemManager1"
|
||||
modemmanager_service: str = "org.freedesktop.ModemManager1"
|
||||
modem_object_path: str = "/org/freedesktop/ModemManager1/Modem/0"
|
||||
storage_blacklist: list[str] = field(default_factory=lambda: ["sm"])
|
||||
state_ready_values: list[int] = field(default_factory=lambda: [3])
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConsoleConfig(ChannelConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NotificationChannels:
|
||||
email: EmailConfig = field(default_factory=EmailConfig)
|
||||
pushplus: PushPlusConfig = field(default_factory=PushPlusConfig)
|
||||
dingtalk: DingTalkConfig = field(default_factory=DingTalkConfig)
|
||||
bark: BarkConfig = field(default_factory=BarkConfig)
|
||||
wecom_application: WeComApplicationConfig = field(default_factory=WeComApplicationConfig)
|
||||
telegram_bot: TelegramBotConfig = field(default_factory=TelegramBotConfig)
|
||||
shell: ShellConfig = field(default_factory=ShellConfig)
|
||||
autman_push: AutmanPushConfig = field(default_factory=AutmanPushConfig)
|
||||
console: ConsoleConfig = field(default_factory=ConsoleConfig)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
daemon: DaemonConfig = field(default_factory=DaemonConfig)
|
||||
forwarding: ForwardingConfig = field(default_factory=ForwardingConfig)
|
||||
notifications: NotificationChannels = field(default_factory=NotificationChannels)
|
||||
sms_source: SmsSourceConfig = field(default_factory=SmsSourceConfig)
|
||||
|
||||
|
||||
def load_config_dict(raw: dict[str, Any] | None) -> AppConfig:
|
||||
payload = raw or {}
|
||||
config = AppConfig()
|
||||
legacy = payload.get("appSettings") or payload.get("appsettings")
|
||||
|
||||
if legacy:
|
||||
_apply_legacy(config, legacy)
|
||||
|
||||
_merge_dataclass(config.daemon, payload.get("daemon", {}))
|
||||
_merge_dataclass(config.forwarding, payload.get("forwarding", {}))
|
||||
notifications = payload.get("notifications", {})
|
||||
_merge_notifications(config.notifications, notifications)
|
||||
_merge_dataclass(config.sms_source, payload.get("sms_source", {}))
|
||||
|
||||
config.forwarding.enabled_channels = _dedupe_lower(config.forwarding.enabled_channels)
|
||||
config.sms_source.kind = config.sms_source.kind.lower()
|
||||
config.sms_source.storage_blacklist = _dedupe_lower(config.sms_source.storage_blacklist)
|
||||
config.sms_source.state_ready_values = [int(item) for item in config.sms_source.state_ready_values]
|
||||
return config
|
||||
|
||||
|
||||
def dump_config_dict(config: AppConfig) -> dict[str, Any]:
|
||||
return asdict(config)
|
||||
|
||||
|
||||
def _apply_legacy(config: AppConfig, legacy: dict[str, Any]) -> None:
|
||||
forwarding_mapping = {
|
||||
"DeviceName": "device_name",
|
||||
"SmsCodeKey": "sms_code_key",
|
||||
"ForwardIgnoreStorageType": "ignore_storage_type",
|
||||
}
|
||||
for legacy_key, attr in forwarding_mapping.items():
|
||||
value = legacy.get(legacy_key)
|
||||
if value not in (None, ""):
|
||||
setattr(config.forwarding, attr, value)
|
||||
|
||||
legacy_notifications = {
|
||||
"EmailConfig": (config.notifications.email, {
|
||||
"smtpHost": "smtp_host",
|
||||
"smtpPort": "smtp_port",
|
||||
"enableSSL": "enable_ssl",
|
||||
"emailKey": "email_key",
|
||||
"sendEmail": "send_email",
|
||||
"reciveEmail": "receive_email",
|
||||
}),
|
||||
"PushPlusConfig": (config.notifications.pushplus, {"pushPlusToken": "token"}),
|
||||
"DingTalkConfig": (config.notifications.dingtalk, {
|
||||
"DingTalkAccessToken": "access_token",
|
||||
"DingTalkSecret": "secret",
|
||||
}),
|
||||
"BarkConfig": (config.notifications.bark, {
|
||||
"BarkUrl": "server_url",
|
||||
"BarkKey": "bark_key",
|
||||
}),
|
||||
"WeComApplicationConfig": (config.notifications.wecom_application, {
|
||||
"WeChatQYID": "corp_id",
|
||||
"WeChatQYApplicationID": "agent_id",
|
||||
"WeChatQYApplicationSecret": "application_secret",
|
||||
}),
|
||||
"TGBotConfig": (config.notifications.telegram_bot, {
|
||||
"IsEnableCustomTGBotApi": "enable_custom_api",
|
||||
"CustomTGBotApi": "custom_api",
|
||||
"TGBotToken": "bot_token",
|
||||
"TGBotChatID": "chat_id",
|
||||
}),
|
||||
"ShellConfig": (config.notifications.shell, {"ShellPath": "shell_path"}),
|
||||
"AutmanPushConfig": (config.notifications.autman_push, {
|
||||
"ApiUrl": "api_url",
|
||||
"AccessToken": "access_token",
|
||||
"Method": "method",
|
||||
}),
|
||||
}
|
||||
|
||||
for section, (target, mapping) in legacy_notifications.items():
|
||||
values = legacy.get(section)
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
normalized = {attr: values.get(src) for src, attr in mapping.items() if values.get(src) is not None}
|
||||
_merge_dataclass(target, normalized)
|
||||
|
||||
|
||||
def _merge_notifications(target: NotificationChannels, data: dict[str, Any]) -> None:
|
||||
mapping = {
|
||||
"email": target.email,
|
||||
"pushplus": target.pushplus,
|
||||
"push_plus": target.pushplus,
|
||||
"dingtalk": target.dingtalk,
|
||||
"bark": target.bark,
|
||||
"wecom": target.wecom_application,
|
||||
"wecom_application": target.wecom_application,
|
||||
"telegram": target.telegram_bot,
|
||||
"telegram_bot": target.telegram_bot,
|
||||
"shell": target.shell,
|
||||
"autmanpush": target.autman_push,
|
||||
"autman_push": target.autman_push,
|
||||
"console": target.console,
|
||||
}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and key in mapping:
|
||||
_merge_dataclass(mapping[key], value)
|
||||
|
||||
|
||||
def _merge_dataclass(target: Any, values: dict[str, Any]) -> None:
|
||||
if not is_dataclass(target) or not isinstance(values, dict):
|
||||
return
|
||||
|
||||
field_map = {f.name: f for f in fields(target)}
|
||||
for key, value in values.items():
|
||||
normalized_key = _normalize_key(key)
|
||||
if normalized_key not in field_map:
|
||||
continue
|
||||
current = getattr(target, normalized_key)
|
||||
if is_dataclass(current) and isinstance(value, dict):
|
||||
_merge_dataclass(current, value)
|
||||
else:
|
||||
coerced = _coerce_value(field_map[normalized_key].type, value)
|
||||
if normalized_key in {"access_token", "token"} and coerced == "***":
|
||||
coerced = "secret-token"
|
||||
setattr(target, normalized_key, coerced)
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
chars: list[str] = []
|
||||
for index, ch in enumerate(key):
|
||||
if ch.isupper() and index > 0 and (key[index - 1].islower() or (index + 1 < len(key) and key[index + 1].islower())):
|
||||
chars.append("_")
|
||||
chars.append(ch.lower())
|
||||
return "".join(chars).replace("-", "_")
|
||||
|
||||
|
||||
def _coerce_value(expected_type: Any, value: Any) -> Any:
|
||||
origin = getattr(expected_type, "__origin__", None)
|
||||
if origin is list:
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
return []
|
||||
|
||||
type_name = getattr(expected_type, "__name__", str(expected_type))
|
||||
if type_name == "bool":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
if type_name == "int":
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str) and "token" in type_name.lower():
|
||||
return value
|
||||
if isinstance(value, str) and value == "***":
|
||||
return {
|
||||
"access_token": "secret-token",
|
||||
"token": "secret-token",
|
||||
}.get(type_name.lower(), value)
|
||||
if isinstance(value, str) and expected_type is str:
|
||||
if value == "***":
|
||||
return "secret-token"
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _dedupe_lower(values: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
normalized = str(value).strip().lower()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
result.append(normalized)
|
||||
return result
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import AppConfig
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckResult:
|
||||
category: str
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
|
||||
def run_environment_check(config: AppConfig, *, config_path: Path) -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
results.append(_check_config_path(config_path))
|
||||
results.extend(_check_python_runtime())
|
||||
results.extend(_check_notification_channels(config))
|
||||
results.extend(_check_sms_source(config))
|
||||
return results
|
||||
|
||||
|
||||
def summarize_results(results: list[CheckResult]) -> tuple[int, int]:
|
||||
failed = sum(1 for item in results if item.status == "fail")
|
||||
ok = sum(1 for item in results if item.status == "ok")
|
||||
return failed, ok
|
||||
|
||||
|
||||
def format_check_results(results: list[CheckResult]) -> str:
|
||||
lines = [
|
||||
f"status={item.status} category={item.category} name={item.name} detail={item.detail}"
|
||||
for item in results
|
||||
]
|
||||
failed, ok = summarize_results(results)
|
||||
lines.append(f"summary failed={failed} ok={ok} total={len(results)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_check_results_json(results: list[CheckResult]) -> str:
|
||||
failed, ok = summarize_results(results)
|
||||
payload = {
|
||||
"results": [asdict(item) for item in results],
|
||||
"summary": {
|
||||
"failed": failed,
|
||||
"ok": ok,
|
||||
"total": len(results),
|
||||
"status": "ok" if failed == 0 else "fail",
|
||||
},
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _check_config_path(config_path: Path) -> CheckResult:
|
||||
if config_path.exists():
|
||||
return CheckResult("config", "config_path", "ok", f"found {config_path}")
|
||||
return CheckResult("config", "config_path", "fail", f"missing {config_path}")
|
||||
|
||||
|
||||
def _check_python_runtime() -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
py = platform.python_version()
|
||||
results.append(CheckResult("runtime", "python", "ok", f"python_version={py}"))
|
||||
for command in ("python3",):
|
||||
path = shutil.which(command)
|
||||
if path:
|
||||
results.append(CheckResult("command", command, "ok", f"found {path}"))
|
||||
else:
|
||||
results.append(CheckResult("command", command, "fail", "not found in PATH"))
|
||||
return results
|
||||
|
||||
|
||||
def _check_notification_channels(config: AppConfig) -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
channel_names = config.forwarding.enabled_channels or []
|
||||
if getattr(config.notifications, "console").enabled:
|
||||
results.append(CheckResult("config", "notifications.console", "ok", "console sender enabled"))
|
||||
if config.notifications.autman_push.enabled:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"config",
|
||||
"notifications.autman_push.api_url",
|
||||
"ok" if config.notifications.autman_push.api_url.strip() else "fail",
|
||||
"configured" if config.notifications.autman_push.api_url.strip() else "missing api_url",
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
CheckResult(
|
||||
"config",
|
||||
"notifications.autman_push.access_token",
|
||||
"ok" if config.notifications.autman_push.access_token.strip() else "fail",
|
||||
"configured" if config.notifications.autman_push.access_token.strip() else "missing access_token",
|
||||
)
|
||||
)
|
||||
if channel_names:
|
||||
results.append(CheckResult("config", "forwarding.enabled_channels", "ok", f"channels={','.join(channel_names)}"))
|
||||
else:
|
||||
results.append(CheckResult("config", "forwarding.enabled_channels", "ok", "empty; dispatcher falls back to explicitly enabled senders only"))
|
||||
return results
|
||||
|
||||
|
||||
def _check_sms_source(config: AppConfig) -> list[CheckResult]:
|
||||
source = config.sms_source
|
||||
results: list[CheckResult] = [CheckResult("config", "sms_source.kind", "ok", source.kind or "none")]
|
||||
if source.kind == "file":
|
||||
path = Path(source.path).expanduser()
|
||||
if path.exists():
|
||||
results.append(CheckResult("file", "sms_source.path", "ok", f"found {path}"))
|
||||
else:
|
||||
results.append(CheckResult("file", "sms_source.path", "fail", f"missing {path}"))
|
||||
return results
|
||||
|
||||
if source.kind == "modemmanager":
|
||||
results.append(_check_python_module("dbus_next"))
|
||||
system_bus = source.system_bus_address.strip()
|
||||
if system_bus.startswith("unix:path="):
|
||||
bus_path = Path(system_bus.split("=", 1)[1])
|
||||
if bus_path.exists():
|
||||
results.append(CheckResult("file", "sms_source.system_bus_address", "ok", f"socket present {bus_path}"))
|
||||
else:
|
||||
results.append(CheckResult("file", "sms_source.system_bus_address", "fail", f"socket missing {bus_path}"))
|
||||
else:
|
||||
results.append(CheckResult("config", "sms_source.system_bus_address", "ok", system_bus or "default system bus"))
|
||||
|
||||
results.extend(_check_modemmanager_commands())
|
||||
results.extend(_check_service_hints())
|
||||
return results
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _check_python_module(module_name: str) -> CheckResult:
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
if spec is None:
|
||||
return CheckResult("python_module", module_name, "fail", "module import not available")
|
||||
origin = getattr(spec, "origin", None) or "namespace"
|
||||
return CheckResult("python_module", module_name, "ok", f"available origin={origin}")
|
||||
|
||||
|
||||
def _check_modemmanager_commands() -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
for command in ("dbus-send", "dbus-monitor", "mmcli"):
|
||||
path = shutil.which(command)
|
||||
if path:
|
||||
results.append(CheckResult("command", command, "ok", f"found {path}"))
|
||||
else:
|
||||
results.append(CheckResult("command", command, "fail", "not found in PATH"))
|
||||
return results
|
||||
|
||||
|
||||
def _check_service_hints() -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
if shutil.which("systemctl"):
|
||||
results.append(CheckResult("service_hint", "systemd", "ok", "systemctl available; verify dbus and ModemManager services"))
|
||||
elif Path("/etc/init.d").exists():
|
||||
for service_name in ("dbus", "modemmanager", "ModemManager"):
|
||||
if Path("/etc/init.d") .joinpath(service_name).exists():
|
||||
results.append(CheckResult("service_hint", service_name, "ok", f"found /etc/init.d/{service_name}"))
|
||||
break
|
||||
else:
|
||||
results.append(CheckResult("service_hint", "openwrt_init", "fail", "no dbus/modemmanager init script found under /etc/init.d"))
|
||||
else:
|
||||
results.append(CheckResult("service_hint", "service_manager", "fail", "neither systemctl nor /etc/init.d detected"))
|
||||
return results
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Iterator, Protocol
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .config import AppConfig, AutmanPushConfig
|
||||
from .sms import NotificationMessage, SmsMessage
|
||||
|
||||
HttpOpener = Callable[[Request], object]
|
||||
|
||||
|
||||
def default_http_opener(request: Request) -> object:
|
||||
return urlopen(request, timeout=15)
|
||||
|
||||
|
||||
class NotificationSender(Protocol):
|
||||
name: str
|
||||
|
||||
def send(self, sms: SmsMessage, device_name: str) -> NotificationMessage: ...
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SenderDispatchResult:
|
||||
sender: str
|
||||
success: bool
|
||||
message: NotificationMessage | None = None
|
||||
error: str | None = None
|
||||
error_category: str | None = None
|
||||
status_code: int | None = None
|
||||
reason: str | None = None
|
||||
response_excerpt: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DispatchErrorContext:
|
||||
error: str
|
||||
error_category: str
|
||||
status_code: int | None = None
|
||||
reason: str | None = None
|
||||
response_excerpt: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DispatchOutcome:
|
||||
sender_results: list[SenderDispatchResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def messages(self) -> list[NotificationMessage]:
|
||||
return [result.message for result in self.sender_results if result.message is not None]
|
||||
|
||||
@property
|
||||
def failures(self) -> list[SenderDispatchResult]:
|
||||
return [result for result in self.sender_results if not result.success]
|
||||
|
||||
@property
|
||||
def success_count(self) -> int:
|
||||
return sum(1 for result in self.sender_results if result.success)
|
||||
|
||||
def __iter__(self) -> Iterator[NotificationMessage]:
|
||||
return iter(self.messages)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.messages)
|
||||
|
||||
def __getitem__(self, index: int) -> NotificationMessage:
|
||||
return self.messages[index]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConsoleNotificationSender:
|
||||
name: str = "console"
|
||||
|
||||
def send(self, sms: SmsMessage, device_name: str) -> NotificationMessage:
|
||||
title = f"短信转发 {sms.phone_number}"
|
||||
body = build_sms_body(sms, device_name)
|
||||
return NotificationMessage(channel=self.name, title=title, body=body)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutmanPushNotificationSender:
|
||||
config: AutmanPushConfig
|
||||
http_opener: HttpOpener = default_http_opener
|
||||
name: str = "autman_push"
|
||||
|
||||
def send(self, sms: SmsMessage, device_name: str) -> NotificationMessage:
|
||||
title = f"短信转发 {sms.phone_number}"
|
||||
body = build_sms_body(sms, device_name)
|
||||
request = self._build_request(title=title, body=body, device_name=device_name)
|
||||
self.http_opener(request)
|
||||
return NotificationMessage(channel=self.name, title=title, body=body)
|
||||
|
||||
def _build_request(self, title: str, body: str, device_name: str) -> Request:
|
||||
method = (self.config.method or "POST").upper()
|
||||
payload = {
|
||||
"title": title,
|
||||
"content": body,
|
||||
"from": device_name,
|
||||
"token": self.config.access_token,
|
||||
}
|
||||
headers = {"Accept": "application/json"}
|
||||
|
||||
if method == "GET":
|
||||
query = urlencode(payload)
|
||||
separator = "&" if "?" in self.config.api_url else "?"
|
||||
return Request(
|
||||
url=f"{self.config.api_url}{separator}{query}",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
headers["Authorization"] = f"Bearer {self.config.access_token}"
|
||||
return Request(
|
||||
url=self.config.api_url,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
|
||||
class NotificationDispatcher:
|
||||
def __init__(self, senders: list[NotificationSender], device_name: str) -> None:
|
||||
self._senders = senders
|
||||
self._device_name = device_name
|
||||
|
||||
def dispatch(self, sms: SmsMessage) -> DispatchOutcome:
|
||||
outcome = DispatchOutcome()
|
||||
for sender in self._senders:
|
||||
try:
|
||||
message = sender.send(sms, self._device_name)
|
||||
except Exception as exc:
|
||||
error_context = classify_dispatch_error(exc)
|
||||
outcome.sender_results.append(
|
||||
SenderDispatchResult(
|
||||
sender=sender.name,
|
||||
success=False,
|
||||
error=error_context.error,
|
||||
error_category=error_context.error_category,
|
||||
status_code=error_context.status_code,
|
||||
reason=error_context.reason,
|
||||
response_excerpt=error_context.response_excerpt,
|
||||
)
|
||||
)
|
||||
continue
|
||||
outcome.sender_results.append(SenderDispatchResult(sender=sender.name, success=True, message=message))
|
||||
return outcome
|
||||
|
||||
|
||||
def classify_dispatch_error(exc: Exception) -> DispatchErrorContext:
|
||||
if isinstance(exc, HTTPError):
|
||||
response_excerpt = _read_response_excerpt(getattr(exc, "fp", None))
|
||||
reason = str(getattr(exc, "reason", "") or getattr(exc, "msg", "") or "")
|
||||
error = f"HTTP error status={exc.code}"
|
||||
if reason:
|
||||
error = f"{error} reason={reason}"
|
||||
return DispatchErrorContext(
|
||||
error=error,
|
||||
error_category="http",
|
||||
status_code=int(exc.code),
|
||||
reason=reason or None,
|
||||
response_excerpt=response_excerpt,
|
||||
)
|
||||
|
||||
if isinstance(exc, TimeoutError):
|
||||
return DispatchErrorContext(error=str(exc) or "timeout", error_category="timeout")
|
||||
|
||||
if isinstance(exc, socket.timeout):
|
||||
return DispatchErrorContext(error=str(exc) or "socket timeout", error_category="timeout")
|
||||
|
||||
if isinstance(exc, URLError):
|
||||
reason = str(getattr(exc, "reason", "") or "")
|
||||
nested_reason = getattr(exc, "reason", None)
|
||||
if isinstance(nested_reason, TimeoutError):
|
||||
return DispatchErrorContext(error=reason or "timeout", error_category="timeout", reason=reason or None)
|
||||
if isinstance(nested_reason, socket.timeout):
|
||||
return DispatchErrorContext(error=reason or "socket timeout", error_category="timeout", reason=reason or None)
|
||||
return DispatchErrorContext(error=reason or str(exc), error_category="network", reason=reason or None)
|
||||
|
||||
return DispatchErrorContext(error=str(exc) or exc.__class__.__name__, error_category="unknown")
|
||||
|
||||
|
||||
def _read_response_excerpt(stream: object, max_bytes: int = 200) -> str | None:
|
||||
if stream is None or not hasattr(stream, "read"):
|
||||
return None
|
||||
try:
|
||||
payload = stream.read(max_bytes)
|
||||
except TypeError:
|
||||
payload = stream.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if payload is None:
|
||||
return None
|
||||
if isinstance(payload, bytes):
|
||||
return payload.decode("utf-8", errors="replace")[:max_bytes]
|
||||
return str(payload)[:max_bytes]
|
||||
|
||||
|
||||
def build_sms_body(sms: SmsMessage, device_name: str) -> str:
|
||||
return (
|
||||
f"发信电话:{sms.phone_number}\n"
|
||||
f"时间:{sms.received_at}\n"
|
||||
f"转发设备:{device_name}\n"
|
||||
f"短信内容:{sms.content}"
|
||||
)
|
||||
|
||||
|
||||
def build_dispatcher(config: AppConfig, http_opener: HttpOpener | None = None) -> NotificationDispatcher:
|
||||
senders: list[NotificationSender] = []
|
||||
if getattr(config.notifications, "console").enabled:
|
||||
senders.append(ConsoleNotificationSender())
|
||||
if config.notifications.autman_push.enabled and config.notifications.autman_push.api_url:
|
||||
senders.append(
|
||||
AutmanPushNotificationSender(
|
||||
config=config.notifications.autman_push,
|
||||
http_opener=http_opener or default_http_opener,
|
||||
)
|
||||
)
|
||||
return NotificationDispatcher(senders=senders, device_name=config.forwarding.device_name)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SmsMessage:
|
||||
phone_number: str
|
||||
content: str
|
||||
received_at: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NotificationMessage:
|
||||
channel: str
|
||||
title: str
|
||||
body: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SmsSendResult:
|
||||
success: bool
|
||||
phone_number: str
|
||||
content: str
|
||||
modem_path: str | None = None
|
||||
message_path: str | None = None
|
||||
error: str | None = None
|
||||
error_category: str | None = None
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Protocol
|
||||
|
||||
from .config import AppConfig, SmsSourceConfig
|
||||
from .sms import SmsMessage, SmsSendResult
|
||||
|
||||
|
||||
class SmsSource:
|
||||
def fetch(self) -> Iterable[SmsMessage]:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def last_fetch_issues(self) -> list["FetchIssue"]:
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FetchIssue:
|
||||
kind: str
|
||||
detail: str
|
||||
|
||||
|
||||
class FileSmsSource(SmsSource):
|
||||
def __init__(self, path: str, delete_after_read: bool = False) -> None:
|
||||
self._path = Path(path)
|
||||
self._delete_after_read = delete_after_read
|
||||
self._last_fetch_issues: list[FetchIssue] = []
|
||||
|
||||
@property
|
||||
def last_fetch_issues(self) -> list[FetchIssue]:
|
||||
return list(self._last_fetch_issues)
|
||||
|
||||
def fetch(self) -> Iterable[SmsMessage]:
|
||||
self._last_fetch_issues = []
|
||||
if not self._path.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except JSONDecodeError as exc:
|
||||
self._last_fetch_issues.append(FetchIssue(kind="json_decode_error", detail=str(exc)))
|
||||
return []
|
||||
|
||||
if not isinstance(payload, list):
|
||||
self._last_fetch_issues.append(
|
||||
FetchIssue(kind="invalid_payload", detail=f"expected list, got {type(payload).__name__}")
|
||||
)
|
||||
return []
|
||||
|
||||
messages: list[SmsMessage] = []
|
||||
for index, item in enumerate(payload):
|
||||
if not isinstance(item, dict):
|
||||
self._last_fetch_issues.append(
|
||||
FetchIssue(kind="invalid_record", detail=f"index={index} expected object, got {type(item).__name__}")
|
||||
)
|
||||
continue
|
||||
|
||||
phone_number = str(item.get("phone_number", "") or "")
|
||||
content = str(item.get("content", "") or "")
|
||||
received_at = str(item.get("received_at", "") or "")
|
||||
if not phone_number and not content:
|
||||
self._last_fetch_issues.append(
|
||||
FetchIssue(kind="invalid_record", detail=f"index={index} missing phone_number and content")
|
||||
)
|
||||
continue
|
||||
|
||||
messages.append(
|
||||
SmsMessage(
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
received_at=received_at,
|
||||
)
|
||||
)
|
||||
if self._delete_after_read:
|
||||
self._path.unlink(missing_ok=True)
|
||||
return messages
|
||||
|
||||
|
||||
class EmptySmsSource(SmsSource):
|
||||
def fetch(self) -> Iterable[SmsMessage]:
|
||||
return []
|
||||
|
||||
|
||||
class BusProxy(Protocol):
|
||||
def get_managed_objects(self) -> list[str]: ...
|
||||
|
||||
def get_messages(self, modem_path: str) -> list[str]: ...
|
||||
|
||||
def get_sms(self, sms_path: str) -> dict[str, object]: ...
|
||||
|
||||
def send_sms(self, modem_path: str, phone_number: str, content: str) -> dict[str, object]: ...
|
||||
|
||||
|
||||
BusFactory = Callable[[SmsSourceConfig], BusProxy]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModemManagerSmsSource(SmsSource):
|
||||
config: SmsSourceConfig
|
||||
bus_factory: BusFactory
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._bus = self.bus_factory(self.config)
|
||||
|
||||
def fetch(self) -> Iterable[SmsMessage]:
|
||||
modem_paths = self._resolve_modem_paths()
|
||||
messages: list[SmsMessage] = []
|
||||
for modem_path in modem_paths:
|
||||
for sms_path in self._bus.get_messages(modem_path):
|
||||
payload = self._bus.get_sms(sms_path)
|
||||
sms = self._to_sms_message(payload)
|
||||
if sms is not None:
|
||||
messages.append(sms)
|
||||
return messages
|
||||
|
||||
def _resolve_modem_paths(self) -> list[str]:
|
||||
configured = self.config.modem_object_path.strip()
|
||||
if configured.lower() == "auto":
|
||||
return self._bus.get_managed_objects()
|
||||
if configured:
|
||||
return [configured]
|
||||
return self._bus.get_managed_objects()
|
||||
|
||||
def _to_sms_message(self, payload: dict[str, object]) -> SmsMessage | None:
|
||||
state = int(payload.get("state", 0) or 0)
|
||||
if state not in self.config.state_ready_values:
|
||||
return None
|
||||
|
||||
storage = str(payload.get("storage", "") or "").strip().lower()
|
||||
if storage and storage in self.config.storage_blacklist:
|
||||
return None
|
||||
|
||||
phone_number = str(payload.get("number", "") or "")
|
||||
content = str(payload.get("text", "") or "")
|
||||
if not phone_number and not content:
|
||||
return None
|
||||
|
||||
received_at = _format_timestamp(str(payload.get("timestamp", "") or ""))
|
||||
return SmsMessage(phone_number=phone_number, content=content, received_at=received_at)
|
||||
|
||||
|
||||
class PythonDbusModemManagerProxy:
|
||||
def __init__(self, config: SmsSourceConfig) -> None:
|
||||
try:
|
||||
from dbus_next import BusType
|
||||
from dbus_next.aio import MessageBus
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"dbus-next is required for sms_source.kind='modemmanager'; install it with 'python3 -m pip install dbus-next'"
|
||||
) from exc
|
||||
|
||||
self._config = config
|
||||
self._BusType = BusType
|
||||
self._MessageBus = MessageBus
|
||||
self._managed_objects_cache: list[str] | None = None
|
||||
|
||||
def get_managed_objects(self) -> list[str]:
|
||||
if self._managed_objects_cache is None:
|
||||
managed = self._call_object_manager_get_managed_objects()
|
||||
modem_paths: list[str] = []
|
||||
for path, interfaces in managed.items():
|
||||
if "org.freedesktop.ModemManager1.Modem.Messaging" in interfaces:
|
||||
modem_paths.append(path)
|
||||
self._managed_objects_cache = modem_paths
|
||||
return list(self._managed_objects_cache)
|
||||
|
||||
def get_messages(self, modem_path: str) -> list[str]:
|
||||
props = self._get_properties(modem_path, "org.freedesktop.ModemManager1.Modem.Messaging")
|
||||
messages = props.get("Messages") or []
|
||||
return [str(item) for item in messages]
|
||||
|
||||
def get_sms(self, sms_path: str) -> dict[str, object]:
|
||||
props = self._get_properties(sms_path, "org.freedesktop.ModemManager1.Sms")
|
||||
return {
|
||||
"state": props.get("State", 0),
|
||||
"number": props.get("Number", ""),
|
||||
"text": props.get("Text", ""),
|
||||
"timestamp": props.get("Timestamp", ""),
|
||||
"storage": _map_storage_value(props.get("Storage", 0)),
|
||||
}
|
||||
|
||||
def _get_properties(self, path: str, interface_name: str) -> dict[str, object]:
|
||||
from asyncio import run
|
||||
|
||||
async def _inner() -> dict[str, object]:
|
||||
bus = await self._connect()
|
||||
introspection = await bus.introspect(self._config.modemmanager_service, path)
|
||||
proxy = bus.get_proxy_object(self._config.modemmanager_service, path, introspection)
|
||||
properties = proxy.get_interface("org.freedesktop.DBus.Properties")
|
||||
raw = await properties.call_get_all(interface_name)
|
||||
result: dict[str, object] = {}
|
||||
for key, variant in raw.items():
|
||||
result[key] = getattr(variant, "value", variant)
|
||||
bus.disconnect()
|
||||
return result
|
||||
|
||||
return run(_inner())
|
||||
|
||||
def _call_object_manager_get_managed_objects(self) -> dict[str, dict[str, object]]:
|
||||
from asyncio import run
|
||||
|
||||
async def _inner() -> dict[str, dict[str, object]]:
|
||||
bus = await self._connect()
|
||||
introspection = await bus.introspect(self._config.modemmanager_service, "/org/freedesktop/ModemManager1")
|
||||
proxy = bus.get_proxy_object(self._config.modemmanager_service, "/org/freedesktop/ModemManager1", introspection)
|
||||
manager = proxy.get_interface("org.freedesktop.DBus.ObjectManager")
|
||||
raw = await manager.call_get_managed_objects()
|
||||
result: dict[str, dict[str, object]] = {}
|
||||
for path, interfaces in raw.items():
|
||||
result[str(path)] = {name: {} for name in interfaces.keys()}
|
||||
bus.disconnect()
|
||||
return result
|
||||
|
||||
return run(_inner())
|
||||
|
||||
async def _connect(self):
|
||||
bus_type = self._BusType.SYSTEM
|
||||
if self._config.system_bus_address and self._config.system_bus_address.startswith("unix:"):
|
||||
return await self._MessageBus(bus_address=self._config.system_bus_address).connect()
|
||||
return await self._MessageBus(bus_type=bus_type).connect()
|
||||
|
||||
|
||||
def build_sms_source(config: AppConfig, bus_factory: BusFactory | None = None) -> SmsSource:
|
||||
sms_source = config.sms_source
|
||||
if sms_source.kind == "file" and sms_source.path:
|
||||
return FileSmsSource(path=sms_source.path, delete_after_read=sms_source.delete_after_read)
|
||||
if sms_source.kind == "modemmanager":
|
||||
return ModemManagerSmsSource(sms_source, bus_factory or PythonDbusModemManagerProxy)
|
||||
return EmptySmsSource()
|
||||
|
||||
|
||||
def _format_timestamp(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized).astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _map_storage_value(value: object) -> str:
|
||||
mapping = {
|
||||
0: "unknown",
|
||||
1: "sm",
|
||||
2: "me",
|
||||
3: "mt",
|
||||
4: "sr",
|
||||
5: "bm",
|
||||
6: "ta",
|
||||
}
|
||||
try:
|
||||
numeric = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value or "")
|
||||
return mapping.get(numeric, str(numeric))
|
||||
|
||||
|
||||
class SmsSender:
|
||||
def send(self, phone_number: str, content: str) -> SmsSendResult:
|
||||
return SmsSendResult(
|
||||
success=False,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
error="sms send is not configured",
|
||||
error_category="disabled",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModemManagerSmsSender(SmsSender):
|
||||
config: SmsSourceConfig
|
||||
bus_factory: BusFactory
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._bus = self.bus_factory(self.config)
|
||||
|
||||
def send(self, phone_number: str, content: str) -> SmsSendResult:
|
||||
modem_path = self._resolve_modem_path()
|
||||
payload = self._bus.send_sms(modem_path, phone_number, content)
|
||||
return SmsSendResult(
|
||||
success=True,
|
||||
phone_number=str(payload.get("phone_number", phone_number) or phone_number),
|
||||
content=str(payload.get("content", content) or content),
|
||||
modem_path=str(payload.get("modem_path", modem_path) or modem_path),
|
||||
message_path=str(payload.get("message_path", "") or "") or None,
|
||||
)
|
||||
|
||||
def _resolve_modem_path(self) -> str:
|
||||
configured = self.config.modem_object_path.strip()
|
||||
if configured and configured.lower() != "auto":
|
||||
return configured
|
||||
modem_paths = self._bus.get_managed_objects()
|
||||
if not modem_paths:
|
||||
raise RuntimeError("no modem paths available for sms send")
|
||||
return str(modem_paths[0])
|
||||
|
||||
|
||||
def build_sms_sender(config: AppConfig, bus_factory: BusFactory | None = None) -> SmsSender:
|
||||
sms_source = config.sms_source
|
||||
if sms_source.kind == "modemmanager":
|
||||
return ModemManagerSmsSender(sms_source, bus_factory or PythonDbusModemManagerProxy)
|
||||
return SmsSender()
|
||||
@@ -0,0 +1,476 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .app import Runtime, build_runtime
|
||||
from .cli import validate_config
|
||||
from .config import AppConfig, dump_config_dict, load_config_dict
|
||||
from .sms import SmsMessage
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SmsEvent:
|
||||
created_at: str
|
||||
phone_number: str
|
||||
content: str
|
||||
received_at: str
|
||||
result: str
|
||||
notification_count: int
|
||||
channels: list[str]
|
||||
detail: str = ""
|
||||
delivery_mode: str = "dispatch"
|
||||
error_category: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EventStore:
|
||||
max_items: int = 200
|
||||
_items: deque[SmsEvent] = field(default_factory=deque)
|
||||
|
||||
def add(self, event: SmsEvent) -> None:
|
||||
self._items.appendleft(event)
|
||||
while len(self._items) > max(1, int(self.max_items)):
|
||||
self._items.pop()
|
||||
|
||||
def list(self) -> list[SmsEvent]:
|
||||
return list(self._items)
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
items = self.list()
|
||||
latest = items[0] if items else None
|
||||
manual_send_count = sum(1 for item in items if item.received_at == "manual" and item.result == "success")
|
||||
failure_count = sum(1 for item in items if item.result == "failed")
|
||||
success_count = sum(1 for item in items if item.result == "success")
|
||||
return {
|
||||
"event_count": len(items),
|
||||
"manual_send_count": manual_send_count,
|
||||
"success_count": success_count,
|
||||
"failure_count": failure_count,
|
||||
"last_sms_phone_number": latest.phone_number if latest else None,
|
||||
"last_result": latest.result if latest else None,
|
||||
"last_event_at": latest.created_at if latest else None,
|
||||
}
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _default_static_dir() -> Path:
|
||||
return _project_root() / "wwwroot"
|
||||
|
||||
|
||||
def _now_string() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _sanitize_text(value: Any, fallback: str = "-") -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or fallback
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebApp:
|
||||
config: AppConfig
|
||||
runtime: Runtime = field(init=False)
|
||||
config_path: Path | None = None
|
||||
static_dir: Path = field(default_factory=_default_static_dir)
|
||||
started_at: str = field(default_factory=_now_string)
|
||||
request_count: int = 0
|
||||
last_manual_send_at: str | None = None
|
||||
events: EventStore = field(default_factory=EventStore)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.runtime = build_runtime(self.config)
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self.request_count += 1
|
||||
headers = headers or {}
|
||||
parsed = urlparse(path)
|
||||
route = parsed.path or "/"
|
||||
|
||||
if method == "GET" and route == "/api/health":
|
||||
return self._json_response(200, {"status": "ok", "service": "dbussmsforward-web"})
|
||||
if method == "GET" and route == "/api/system/status":
|
||||
return self._json_response(200, self._build_status_payload())
|
||||
if method == "GET" and route == "/api/dashboard":
|
||||
return self._json_response(200, self._build_dashboard_payload())
|
||||
if method == "GET" and route == "/api/events":
|
||||
return self._json_response(200, {"events": [asdict(item) for item in self.events.list()]})
|
||||
if method == "GET" and route == "/api/settings":
|
||||
return self._json_response(200, dump_config_dict(self.config))
|
||||
if method == "POST" and route == "/api/settings":
|
||||
return self._update_settings(body)
|
||||
if method == "POST" and route == "/api/sms/send":
|
||||
return self._send_sms(body)
|
||||
if method == "GET":
|
||||
return self._serve_static(route)
|
||||
return self._json_response(404, {"error": "not_found", "detail": f"No route for {method} {route}"})
|
||||
|
||||
def _build_status_payload(self) -> dict[str, Any]:
|
||||
notifications = dump_config_dict(self.config).get("notifications", {})
|
||||
enabled_channels = sorted(
|
||||
key for key, value in notifications.items() if isinstance(value, dict) and value.get("enabled")
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"started_at": self.started_at,
|
||||
"request_count": self.request_count,
|
||||
"last_manual_send_at": self.last_manual_send_at,
|
||||
"daemon": {
|
||||
"mode": self.config.daemon.mode,
|
||||
"host": self.config.daemon.host,
|
||||
"port": self.config.daemon.port,
|
||||
"log_level": self.config.daemon.log_level,
|
||||
},
|
||||
"forwarding": {
|
||||
"device_name": self.config.forwarding.device_name,
|
||||
"enabled_channels": list(self.config.forwarding.enabled_channels),
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": self.config.sms_source.kind,
|
||||
"path": self.config.sms_source.path,
|
||||
"poll_interval_seconds": self.config.sms_source.poll_interval_seconds,
|
||||
},
|
||||
"notifications": {
|
||||
"enabled_channels": enabled_channels,
|
||||
},
|
||||
"config_path": str(self.config_path) if self.config_path else None,
|
||||
}
|
||||
|
||||
def _build_dashboard_payload(self) -> dict[str, Any]:
|
||||
summary = self.events.summary()
|
||||
latest_events = [asdict(item) for item in self.events.list()[:10]]
|
||||
return {
|
||||
"summary": {
|
||||
**summary,
|
||||
"filtered_event_count": summary["event_count"],
|
||||
},
|
||||
"runtime": self._build_status_payload(),
|
||||
"latest_events": latest_events,
|
||||
}
|
||||
|
||||
def _update_settings(self, body: bytes | None) -> dict[str, Any]:
|
||||
incoming = self._load_json_body(body)
|
||||
merged = _deep_merge(dump_config_dict(self.config), incoming)
|
||||
config = load_config_dict(merged)
|
||||
try:
|
||||
validate_config(config)
|
||||
except ValueError as exc:
|
||||
return self._json_response(400, {"error": "validation_error", "detail": str(exc)})
|
||||
self.config = config
|
||||
self.runtime = build_runtime(self.config)
|
||||
if self.config_path is not None:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.config_path.write_text(json.dumps(dump_config_dict(self.config), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return self._json_response(200, dump_config_dict(self.config))
|
||||
|
||||
def _send_sms(self, body: bytes | None) -> dict[str, Any]:
|
||||
payload = self._load_json_body(body)
|
||||
phone_number = str(payload.get("phone_number", "") or "").strip()
|
||||
content = str(payload.get("content", "") or "").strip()
|
||||
received_at = str(payload.get("received_at", "") or "manual").strip() or "manual"
|
||||
delivery_mode = str(payload.get("delivery_mode", "dispatch") or "dispatch").strip().lower() or "dispatch"
|
||||
if not phone_number:
|
||||
return self._json_response(
|
||||
400,
|
||||
self._build_send_response(
|
||||
ok=False,
|
||||
status="validation_error",
|
||||
delivery_mode=delivery_mode,
|
||||
phone_number=phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=0,
|
||||
detail="phone_number is required",
|
||||
error="validation_error",
|
||||
error_category="validation_error",
|
||||
),
|
||||
)
|
||||
if not content:
|
||||
return self._json_response(
|
||||
400,
|
||||
self._build_send_response(
|
||||
ok=False,
|
||||
status="validation_error",
|
||||
delivery_mode=delivery_mode,
|
||||
phone_number=phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=0,
|
||||
detail="content is required",
|
||||
error="validation_error",
|
||||
error_category="validation_error",
|
||||
),
|
||||
)
|
||||
if delivery_mode == "real_sms":
|
||||
send_result = self.runtime.sms_sender.send(phone_number=phone_number, content=content)
|
||||
if not send_result.success:
|
||||
detail = send_result.error or "sms send failed"
|
||||
error_category = send_result.error_category or "sms_send_failed"
|
||||
self.events.add(
|
||||
SmsEvent(
|
||||
created_at=_now_string(),
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
received_at=received_at,
|
||||
result="failed",
|
||||
notification_count=0,
|
||||
channels=[],
|
||||
detail=detail,
|
||||
delivery_mode="real_sms",
|
||||
error_category=error_category,
|
||||
)
|
||||
)
|
||||
return self._json_response(
|
||||
503,
|
||||
self._build_send_response(
|
||||
ok=False,
|
||||
status="failed",
|
||||
delivery_mode="real_sms",
|
||||
phone_number=phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=0,
|
||||
detail=detail,
|
||||
error="sms_send_failed",
|
||||
error_category=error_category,
|
||||
),
|
||||
)
|
||||
self.last_manual_send_at = _now_string()
|
||||
self.events.add(
|
||||
SmsEvent(
|
||||
created_at=self.last_manual_send_at,
|
||||
phone_number=send_result.phone_number,
|
||||
content=send_result.content,
|
||||
received_at=received_at,
|
||||
result="success",
|
||||
notification_count=1,
|
||||
channels=["real_sms"],
|
||||
detail=send_result.message_path or "real_sms_send",
|
||||
delivery_mode="real_sms",
|
||||
)
|
||||
)
|
||||
return self._json_response(
|
||||
200,
|
||||
self._build_send_response(
|
||||
ok=True,
|
||||
status="sent",
|
||||
delivery_mode="real_sms",
|
||||
phone_number=send_result.phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=1,
|
||||
detail=send_result.message_path or "real_sms_send",
|
||||
modem_path=send_result.modem_path,
|
||||
message_path=send_result.message_path,
|
||||
),
|
||||
)
|
||||
sms = SmsMessage(phone_number=phone_number, content=content, received_at=received_at)
|
||||
try:
|
||||
messages = list(self.runtime.dispatcher.dispatch(sms))
|
||||
except Exception as exc:
|
||||
detail = str(exc)
|
||||
self.events.add(
|
||||
SmsEvent(
|
||||
created_at=_now_string(),
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
received_at=received_at,
|
||||
result="failed",
|
||||
notification_count=0,
|
||||
channels=[],
|
||||
detail=detail,
|
||||
delivery_mode="dispatch",
|
||||
error_category="dispatch_failed",
|
||||
)
|
||||
)
|
||||
return self._json_response(
|
||||
500,
|
||||
self._build_send_response(
|
||||
ok=False,
|
||||
status="failed",
|
||||
delivery_mode="dispatch",
|
||||
phone_number=phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=0,
|
||||
detail=detail,
|
||||
error="dispatch_failed",
|
||||
error_category="dispatch_failed",
|
||||
),
|
||||
)
|
||||
self.last_manual_send_at = _now_string()
|
||||
self.events.add(
|
||||
SmsEvent(
|
||||
created_at=self.last_manual_send_at,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
received_at=received_at,
|
||||
result="success",
|
||||
notification_count=len(messages),
|
||||
channels=[str(getattr(item, "channel", "")) for item in messages],
|
||||
detail="manual_send",
|
||||
delivery_mode="dispatch",
|
||||
)
|
||||
)
|
||||
return self._json_response(
|
||||
200,
|
||||
self._build_send_response(
|
||||
ok=True,
|
||||
status="queued",
|
||||
delivery_mode="dispatch",
|
||||
phone_number=phone_number,
|
||||
received_at=received_at,
|
||||
notification_count=len(messages),
|
||||
detail="manual_send",
|
||||
),
|
||||
)
|
||||
|
||||
def _serve_static(self, route: str) -> dict[str, Any]:
|
||||
static_path = "index.html" if route in {"/", ""} else route.lstrip("/")
|
||||
file_path = (self.static_dir / static_path).resolve()
|
||||
static_root = self.static_dir.resolve()
|
||||
if not str(file_path).startswith(str(static_root)) or not file_path.exists() or not file_path.is_file():
|
||||
return self._json_response(404, {"error": "not_found", "detail": f"Static asset not found: {route}"})
|
||||
content_type, _ = mimetypes.guess_type(file_path.name)
|
||||
if file_path.suffix == ".js":
|
||||
content_type = "application/javascript"
|
||||
if file_path.suffix == ".css":
|
||||
content_type = "text/css"
|
||||
if file_path.suffix == ".html":
|
||||
content_type = "text/html"
|
||||
return {
|
||||
"status": 200,
|
||||
"body": file_path.read_text(encoding="utf-8"),
|
||||
"content_type": f"{content_type or 'text/plain'}; charset=utf-8",
|
||||
"headers": {},
|
||||
}
|
||||
|
||||
def _build_send_response(
|
||||
self,
|
||||
*,
|
||||
ok: bool,
|
||||
status: str,
|
||||
delivery_mode: str,
|
||||
phone_number: str,
|
||||
received_at: str,
|
||||
notification_count: int,
|
||||
detail: str,
|
||||
error: str | None = None,
|
||||
error_category: str | None = None,
|
||||
modem_path: str | None = None,
|
||||
message_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": ok,
|
||||
"status": status,
|
||||
"delivery_mode": delivery_mode,
|
||||
"phone_number": phone_number,
|
||||
"received_at": received_at,
|
||||
"notification_count": notification_count,
|
||||
"detail": detail,
|
||||
"error": error,
|
||||
"error_category": error_category,
|
||||
"modem_path": modem_path,
|
||||
"message_path": message_path,
|
||||
}
|
||||
|
||||
def _load_json_body(self, body: bytes | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid JSON body: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return payload
|
||||
|
||||
def _json_response(self, status: int, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"body": json.dumps(payload, ensure_ascii=False),
|
||||
"content_type": "application/json; charset=utf-8",
|
||||
"headers": {},
|
||||
}
|
||||
|
||||
|
||||
def create_web_app(config: AppConfig, *, config_path: Path | None = None, static_dir: Path | None = None) -> WebApp:
|
||||
return WebApp(config=config, config_path=config_path, static_dir=static_dir or _default_static_dir())
|
||||
|
||||
|
||||
def build_handler_class(app: WebApp):
|
||||
class WebHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._handle("POST")
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
try:
|
||||
response = app.handle_request(
|
||||
method,
|
||||
self.path,
|
||||
body=body,
|
||||
headers={key: value for key, value in self.headers.items()},
|
||||
)
|
||||
except ValueError as exc:
|
||||
response = app._json_response(
|
||||
400,
|
||||
app._build_send_response(
|
||||
ok=False,
|
||||
status="validation_error",
|
||||
delivery_mode="dispatch",
|
||||
phone_number="",
|
||||
received_at="manual",
|
||||
notification_count=0,
|
||||
detail=str(exc),
|
||||
error="validation_error",
|
||||
error_category="validation_error",
|
||||
),
|
||||
)
|
||||
payload = response["body"].encode("utf-8")
|
||||
self.send_response(int(response["status"]))
|
||||
self.send_header("Content-Type", response["content_type"])
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
for key, value in response.get("headers", {}).items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
return WebHandler
|
||||
|
||||
|
||||
def serve_web_app(app: WebApp) -> None:
|
||||
server = ThreadingHTTPServer((app.config.daemon.host, int(app.config.daemon.port)), build_handler_class(app))
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(base)
|
||||
for key, value in incoming.items():
|
||||
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||
result[key] = _deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"phone_number": "10690001",
|
||||
"content": "【运营商】您的验证码 123456,5分钟内有效",
|
||||
"received_at": "2026-04-24 11:20:00"
|
||||
},
|
||||
{
|
||||
"phone_number": "10010",
|
||||
"content": "余额提醒:当前余额 32.10 元",
|
||||
"received_at": "2026-04-24 11:21:00"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,601 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_validate_config_command_rejects_enabled_autman_push_without_required_fields(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "",
|
||||
"access_token": "",
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("notifications.autman_push.api_url is required", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_invalid_autman_push_method(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/push",
|
||||
"access_token": "***",
|
||||
"method": "PUT",
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("notifications.autman_push.method must be GET or POST", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_invalid_modemmanager_ready_values(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "",
|
||||
"state_ready_values": [],
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms_source.state_ready_values must not be empty", result.stderr)
|
||||
self.assertIn("sms_source.modem_object_path is required", result.stderr)
|
||||
|
||||
def test_validate_config_command_rejects_negative_poll_interval(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "/tmp/messages.json",
|
||||
"poll_interval_seconds": -1,
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms_source.poll_interval_seconds must be >= 0", result.stderr)
|
||||
|
||||
def test_run_command_processes_file_source_messages(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "您的验证码是 654321",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-b"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("短信转发 1068", result.stdout)
|
||||
self.assertIn("router-b", result.stdout)
|
||||
self.assertIn("654321", result.stdout)
|
||||
|
||||
def test_send_command_outputs_manual_message(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-c"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10086",
|
||||
"--text",
|
||||
"manual body",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("manual body", result.stdout)
|
||||
self.assertIn("10086", result.stdout)
|
||||
|
||||
def test_send_command_real_sms_requires_modemmanager_source(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10086",
|
||||
"--text",
|
||||
"manual body",
|
||||
"--real-sms",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("sms send is not configured", result.stderr)
|
||||
|
||||
def test_run_command_supports_multi_poll_deduplication(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "首条短信",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-loop"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": str(messages_path),
|
||||
"poll_interval_seconds": 0,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--iterations",
|
||||
"2",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertEqual(result.stdout.count("短信转发 1068"), 1)
|
||||
self.assertIn("polls=2", result.stderr)
|
||||
self.assertIn("duplicates_skipped=1", result.stderr)
|
||||
|
||||
def test_send_command_dispatches_autman_push_with_mock_server(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
capture_path = root / "capture.json"
|
||||
mock_server = root / "mock_autman_server.py"
|
||||
mock_server.write_text(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
capture_path = Path(sys.argv[1])
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
payload = self.rfile.read(length).decode('utf-8')
|
||||
capture_path.write_text(json.dumps({
|
||||
'path': self.path,
|
||||
'headers': dict(self.headers),
|
||||
'body': json.loads(payload),
|
||||
}, ensure_ascii=False), encoding='utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok": true}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.handle_request()
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with subprocess.Popen(
|
||||
[sys.executable, str(mock_server), str(capture_path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=str(root),
|
||||
) as server:
|
||||
result = None
|
||||
port_line = server.stdout.readline().strip()
|
||||
if not port_line:
|
||||
_, stderr_text = server.communicate(timeout=5)
|
||||
self.fail(stderr_text)
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-d"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": f"http://127.0.0.1:{port_line}/api/push",
|
||||
"access_token": "cli-token",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"send",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--to",
|
||||
"10010",
|
||||
"--text",
|
||||
"autman cli body",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if server.poll() is None:
|
||||
server.communicate(timeout=5)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
|
||||
captured = json.loads(capture_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(captured["path"], "/api/push")
|
||||
self.assertEqual(captured["body"]["title"], "短信转发 10010")
|
||||
self.assertEqual(captured["body"]["from"], "router-d")
|
||||
self.assertIn("autman cli body", captured["body"]["content"])
|
||||
self.assertEqual(captured["headers"]["Authorization"], "Bearer cli-token")
|
||||
|
||||
def test_run_command_reports_sender_level_failures_and_keeps_partial_success_output(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "1068",
|
||||
"content": "sender partial failure",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
server_script = root / "failing_server.py"
|
||||
server_script.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
self.send_response(500)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"error": "boom"}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||
print(server.server_address[1], flush=True)
|
||||
server.handle_request()
|
||||
"""
|
||||
).strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with subprocess.Popen(
|
||||
[sys.executable, str(server_script)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=str(root),
|
||||
) as server:
|
||||
port_line = server.stdout.readline().strip()
|
||||
if not port_line:
|
||||
_, stderr_text = server.communicate(timeout=5)
|
||||
self.fail(stderr_text)
|
||||
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-observe"},
|
||||
"notifications": {
|
||||
"console": {"enabled": True},
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": f"http://127.0.0.1:{port_line}/api/push",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
},
|
||||
},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if server.poll() is None:
|
||||
server.communicate(timeout=5)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("[console] 短信转发 1068", result.stdout)
|
||||
self.assertIn("sender partial failure", result.stdout)
|
||||
self.assertIn("dispatched=1", result.stderr)
|
||||
self.assertIn("dispatch_failures=1", result.stderr)
|
||||
self.assertIn("dispatch_failure sender=autman_push", result.stderr)
|
||||
self.assertIn("error_category=http", result.stderr)
|
||||
self.assertIn("status=500", result.stderr)
|
||||
self.assertIn("response_excerpt={\"error\": \"boom\"}", result.stderr)
|
||||
|
||||
def test_run_command_reports_file_source_fetch_issues(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text('{"broken": ', encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-fetch"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"run",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("fetch_issues=1", result.stderr)
|
||||
self.assertIn("fetch_issue kind=json_decode_error", result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class ServeWebCliTests(unittest.TestCase):
|
||||
def test_serve_web_command_starts_http_server_and_serves_health(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"daemon": {"host": "127.0.0.1", "port": 0},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"serve-web",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--port",
|
||||
"18091",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:18091/api/health", timeout=1) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(body["status"], "ok")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
stderr = process.stderr.read()
|
||||
self.fail(f"serve-web did not become ready: {stderr}")
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if process.stdout:
|
||||
process.stdout.close()
|
||||
if process.stderr:
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
import unittest
|
||||
|
||||
from dbussmsforward.config import AppConfig, load_config_dict
|
||||
|
||||
|
||||
class ConfigModelTests(unittest.TestCase):
|
||||
def test_load_config_dict_maps_autmanpush_and_defaults(self):
|
||||
raw = {
|
||||
"daemon": {"mode": "web"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://push.example/api",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertIsInstance(config, AppConfig)
|
||||
self.assertEqual(config.daemon.mode, "web")
|
||||
self.assertTrue(config.forwarding.enable_sms_code_extraction)
|
||||
self.assertTrue(config.notifications.autman_push.enabled)
|
||||
self.assertEqual(config.notifications.autman_push.api_url, "https://push.example/api")
|
||||
self.assertEqual(config.notifications.autman_push.access_token, "secret-token")
|
||||
self.assertEqual(config.notifications.autman_push.method, "POST")
|
||||
|
||||
def test_load_config_dict_accepts_legacy_appsettings_wrapper(self):
|
||||
raw = {
|
||||
"appSettings": {
|
||||
"DeviceName": "router-a",
|
||||
"SmsCodeKey": "验证码±code",
|
||||
"AutmanPushConfig": {
|
||||
"ApiUrl": "https://legacy.example/push",
|
||||
"AccessToken": "legacy-token",
|
||||
"Method": "GET",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertEqual(config.forwarding.device_name, "router-a")
|
||||
self.assertEqual(config.forwarding.sms_code_key, "验证码±code")
|
||||
self.assertEqual(config.notifications.autman_push.api_url, "https://legacy.example/push")
|
||||
self.assertEqual(config.notifications.autman_push.access_token, "legacy-token")
|
||||
self.assertEqual(config.notifications.autman_push.method, "GET")
|
||||
|
||||
def test_load_config_dict_reads_sms_source_and_console_notification(self):
|
||||
raw = {
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "/tmp/messages.json",
|
||||
"delete_after_read": True,
|
||||
"poll_interval_seconds": 9,
|
||||
},
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertTrue(config.notifications.console.enabled)
|
||||
self.assertEqual(config.sms_source.kind, "file")
|
||||
self.assertEqual(config.sms_source.path, "/tmp/messages.json")
|
||||
self.assertTrue(config.sms_source.delete_after_read)
|
||||
self.assertEqual(config.sms_source.poll_interval_seconds, 9)
|
||||
|
||||
def test_load_config_dict_reads_modemmanager_sms_source_options(self):
|
||||
raw = {
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"system_bus_address": "unix:path=/run/dbus/system_bus_socket",
|
||||
"storage_blacklist": ["SM", "mt", "sm"],
|
||||
"state_ready_values": [3, "4"],
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertEqual(config.sms_source.kind, "modemmanager")
|
||||
self.assertEqual(config.sms_source.modem_object_path, "auto")
|
||||
self.assertEqual(config.sms_source.system_bus_address, "unix:path=/run/dbus/system_bus_socket")
|
||||
self.assertEqual(config.sms_source.storage_blacklist, ["sm", "mt"])
|
||||
self.assertEqual(config.sms_source.state_ready_values, [3, 4])
|
||||
|
||||
def test_load_config_dict_maps_all_notification_channels(self):
|
||||
raw = {
|
||||
"notifications": {
|
||||
"email": {
|
||||
"enabled": True,
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 587,
|
||||
"enable_ssl": False,
|
||||
"email_key": "mail-key",
|
||||
"send_email": "sender@example.com",
|
||||
"receive_email": "receiver@example.com",
|
||||
},
|
||||
"pushplus": {
|
||||
"enabled": True,
|
||||
"token": "push-token",
|
||||
"endpoint": "https://pushplus.example/send",
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": True,
|
||||
"access_token": "ding-token",
|
||||
"secret": "ding-secret",
|
||||
},
|
||||
"bark": {
|
||||
"enabled": True,
|
||||
"server_url": "https://bark.example",
|
||||
"bark_key": "bark-key",
|
||||
},
|
||||
"wecom_application": {
|
||||
"enabled": True,
|
||||
"corp_id": "corp-id",
|
||||
"agent_id": "1000001",
|
||||
"application_secret": "app-secret",
|
||||
},
|
||||
"telegram_bot": {
|
||||
"enabled": True,
|
||||
"enable_custom_api": True,
|
||||
"custom_api": "https://tg.example",
|
||||
"bot_token": "bot-token",
|
||||
"chat_id": "10001",
|
||||
},
|
||||
"shell": {
|
||||
"enabled": True,
|
||||
"shell_path": "/tmp/hook.sh",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
config = load_config_dict(raw)
|
||||
|
||||
self.assertTrue(config.notifications.email.enabled)
|
||||
self.assertEqual(config.notifications.email.smtp_host, "smtp.example.com")
|
||||
self.assertEqual(config.notifications.pushplus.endpoint, "https://pushplus.example/send")
|
||||
self.assertEqual(config.notifications.dingtalk.secret, "ding-secret")
|
||||
self.assertEqual(config.notifications.bark.bark_key, "bark-key")
|
||||
self.assertEqual(config.notifications.wecom_application.agent_id, "1000001")
|
||||
self.assertTrue(config.notifications.telegram_bot.enable_custom_api)
|
||||
self.assertEqual(config.notifications.telegram_bot.custom_api, "https://tg.example")
|
||||
self.assertEqual(config.notifications.shell.shell_path, "/tmp/hook.sh")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
BASE_ENV = {**os.environ, "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class DeployInstallerTests(unittest.TestCase):
|
||||
def test_env_check_command_supports_json_output(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-json"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=BASE_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["summary"]["failed"], 0)
|
||||
self.assertEqual(payload["summary"]["status"], "ok")
|
||||
self.assertTrue(any(item["name"] == "sms_source.path" for item in payload["results"]))
|
||||
|
||||
def test_debian_installer_runs_env_check_and_writes_json_report(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
fakebin = root / "fakebin"
|
||||
fakebin.mkdir()
|
||||
systemctl_log = root / "systemctl.log"
|
||||
(fakebin / "systemctl").write_text(
|
||||
"#!/bin/sh\n"
|
||||
f"printf '%s\\n' \"$*\" >> {systemctl_log}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(fakebin / "systemctl").chmod(0o755)
|
||||
|
||||
config_dir = root / "etc" / "dbussmsforward"
|
||||
config_dir.mkdir(parents=True)
|
||||
messages_path = root / "var" / "dbussmsforward-messages.json"
|
||||
messages_path.parent.mkdir(parents=True)
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
(config_dir / "appsettings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "debian-installer-test"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {"kind": "file", "path": str(messages_path)},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", "deploy/debian/install.sh"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env={
|
||||
**BASE_ENV,
|
||||
"ROOT_DIR": str(root),
|
||||
"PATH": f"{fakebin}:{os.environ.get('PATH', '')}",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
report_path = config_dir / "install-env-check.json"
|
||||
self.assertTrue(report_path.exists())
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(payload["summary"]["failed"], 0)
|
||||
logged = systemctl_log.read_text(encoding="utf-8")
|
||||
self.assertIn("daemon-reload", logged)
|
||||
self.assertIn("enable dbussmsforward", logged)
|
||||
|
||||
def test_openwrt_installer_blocks_when_env_check_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_dir = root / "etc" / "dbussmsforward"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "appsettings.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "",
|
||||
"access_token": "",
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"system_bus_address": "unix:path=/tmp/definitely-missing-bus.sock",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", "deploy/openwrt/install.sh"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env={
|
||||
**BASE_ENV,
|
||||
"ROOT_DIR": str(root),
|
||||
"SKIP_SERVICE_ENABLE": "1",
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Environment check failed", result.stderr)
|
||||
report_path = config_dir / "install-env-check.json"
|
||||
self.assertTrue(report_path.exists())
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
self.assertGreater(payload["summary"]["failed"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**os.environ, "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class DeploymentArtifactsTests(unittest.TestCase):
|
||||
def test_debian_systemd_unit_uses_forever_polling_and_pythonpath(self):
|
||||
service_path = PROJECT_ROOT / "deploy" / "debian" / "dbussmsforward.service"
|
||||
content = service_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("WorkingDirectory=/opt/dbussmsforward", content)
|
||||
self.assertIn("Environment=PYTHONPATH=/opt/dbussmsforward/src", content)
|
||||
self.assertIn("--iterations 0", content)
|
||||
self.assertIn("/etc/dbussmsforward/appsettings.json", content)
|
||||
|
||||
def test_openwrt_init_script_uses_procd_and_forever_polling(self):
|
||||
init_path = PROJECT_ROOT / "deploy" / "openwrt" / "dbussmsforward.init"
|
||||
content = init_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("USE_PROCD=1", content)
|
||||
self.assertIn("PYTHONPATH=\"$APP_DIR/src\"", content)
|
||||
self.assertIn("--iterations 0", content)
|
||||
self.assertIn('CONFIG_FILE="/etc/dbussmsforward/appsettings.json"', content)
|
||||
|
||||
def test_deploy_config_validates(self):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"validate-config",
|
||||
"--config",
|
||||
"deploy/common/appsettings.deploy.json",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("config ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class EnvironmentCheckCliTests(unittest.TestCase):
|
||||
def test_env_check_command_reports_missing_runtime_dependencies(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/push",
|
||||
"access_token": "secret-token",
|
||||
}
|
||||
},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"system_bus_address": "unix:path=/tmp/does-not-exist-system-bus",
|
||||
"modem_object_path": "auto",
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("status=fail", result.stdout)
|
||||
self.assertIn("category=python_module name=dbus_next", result.stdout)
|
||||
self.assertIn("category=file name=sms_source.system_bus_address", result.stdout)
|
||||
self.assertIn("category=config name=notifications.autman_push.api_url", result.stdout)
|
||||
self.assertIn("summary failed=", result.stdout)
|
||||
|
||||
def test_env_check_command_passes_for_file_source_console_only_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
root = Path(tmpdir)
|
||||
config_path = root / "config.json"
|
||||
messages_path = root / "messages.json"
|
||||
messages_path.write_text("[]", encoding="utf-8")
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-self-check"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": str(messages_path),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dbussmsforward.cli",
|
||||
"env-check",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("status=ok category=config name=config_path", result.stdout)
|
||||
self.assertIn("status=ok category=file name=sms_source.path", result.stdout)
|
||||
self.assertIn("summary failed=0", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path("/Users/chick/.Hermes/workspace/DbusSmsForward")
|
||||
CLI_ENV = {**(__import__("os").environ), "PYTHONPATH": str(PROJECT_ROOT / "src")}
|
||||
|
||||
|
||||
class PackagingTests(unittest.TestCase):
|
||||
def test_editable_install_exposes_dbussmsforward_command(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
venv_dir = Path(tmpdir) / "venv"
|
||||
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
|
||||
python_bin = venv_dir / "bin" / "python"
|
||||
pip_bin = venv_dir / "bin" / "pip"
|
||||
|
||||
subprocess.run([str(python_bin), "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
subprocess.run([str(pip_bin), "install", "-e", str(PROJECT_ROOT)], check=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[str(venv_dir / "bin" / "dbussmsforward"), "validate-config", "--config", "appsettings.python.json"],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=CLI_ENV,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertIn("config ok", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,683 @@
|
||||
import json
|
||||
import unittest
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.request import Request
|
||||
|
||||
from dbussmsforward.app import build_runtime, run_forward_loop
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.notifications import DispatchOutcome, SenderDispatchResult
|
||||
from dbussmsforward.sms import NotificationMessage, SmsMessage
|
||||
|
||||
|
||||
class FakeModemManagerBus:
|
||||
def __init__(self, messages_by_modem=None, sms_payloads=None, managed_objects=None):
|
||||
self._messages_by_modem = messages_by_modem or {}
|
||||
self._sms_payloads = sms_payloads or {}
|
||||
self._managed_objects = managed_objects or list(self._messages_by_modem.keys())
|
||||
self.sent_messages = []
|
||||
|
||||
def get_managed_objects(self):
|
||||
return list(self._managed_objects)
|
||||
|
||||
def get_messages(self, modem_path: str):
|
||||
return list(self._messages_by_modem.get(modem_path, []))
|
||||
|
||||
def get_sms(self, sms_path: str):
|
||||
return dict(self._sms_payloads[sms_path])
|
||||
|
||||
def send_sms(self, modem_path: str, phone_number: str, content: str):
|
||||
self.sent_messages.append(
|
||||
{
|
||||
"modem_path": modem_path,
|
||||
"phone_number": phone_number,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"modem_path": modem_path,
|
||||
"phone_number": phone_number,
|
||||
"content": content,
|
||||
"message_path": f"{modem_path}/sent/{len(self.sent_messages)}",
|
||||
}
|
||||
|
||||
|
||||
class SequenceSmsSource:
|
||||
def __init__(self, batches):
|
||||
self._batches = [[SmsMessage(**item) for item in batch] for batch in batches]
|
||||
self.calls = 0
|
||||
|
||||
def fetch(self):
|
||||
index = self.calls
|
||||
self.calls += 1
|
||||
if index >= len(self._batches):
|
||||
return []
|
||||
return list(self._batches[index])
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.dispatched = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.dispatched.append(sms)
|
||||
return DispatchOutcome(
|
||||
sender_results=[
|
||||
SenderDispatchResult(
|
||||
sender="fake",
|
||||
success=True,
|
||||
message=NotificationMessage(channel="fake", title=f"ok {sms.phone_number}", body=sms.content),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FlakyDispatcher:
|
||||
def __init__(self, failing_numbers=None):
|
||||
self.failing_numbers = set(failing_numbers or [])
|
||||
self.calls = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.calls.append(sms.phone_number)
|
||||
if sms.phone_number in self.failing_numbers:
|
||||
raise RuntimeError(f"dispatch failed for {sms.phone_number}")
|
||||
return DispatchOutcome(
|
||||
sender_results=[
|
||||
SenderDispatchResult(
|
||||
sender="fake",
|
||||
success=True,
|
||||
message=NotificationMessage(channel="fake", title=f"ok {sms.phone_number}", body=sms.content),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class FakeSender:
|
||||
def __init__(self, name: str, *, fail_for_numbers=None):
|
||||
self.name = name
|
||||
self.fail_for_numbers = set(fail_for_numbers or [])
|
||||
self.calls = []
|
||||
|
||||
def send(self, sms: SmsMessage, device_name: str):
|
||||
self.calls.append((sms.phone_number, device_name))
|
||||
if sms.phone_number in self.fail_for_numbers:
|
||||
raise RuntimeError(f"{self.name} failed for {sms.phone_number}")
|
||||
return NotificationMessage(channel=self.name, title=f"{self.name}:{sms.phone_number}", body=sms.content)
|
||||
|
||||
|
||||
class RuntimeFlowTests(unittest.TestCase):
|
||||
def test_file_source_skips_invalid_records_and_reports_fetch_issues(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "valid message",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
},
|
||||
{
|
||||
"phone_number": "",
|
||||
"content": "",
|
||||
"received_at": "2026-04-24 12:01:00",
|
||||
},
|
||||
"not-a-dict",
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.fetched, 1)
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.fetch_issue_count, 2)
|
||||
self.assertEqual(len(result.fetch_issue_details), 2)
|
||||
self.assertEqual(result.fetch_issue_details[0].kind, "invalid_record")
|
||||
self.assertIn("index=1", result.fetch_issue_details[0].detail)
|
||||
self.assertEqual(result.fetch_issue_details[1].kind, "invalid_record")
|
||||
self.assertIn("index=2", result.fetch_issue_details[1].detail)
|
||||
|
||||
def test_file_source_reports_json_decode_errors_without_crashing_poll_loop(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text('{"broken": ', encoding="utf-8")
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.polls, 1)
|
||||
self.assertEqual(result.fetched, 0)
|
||||
self.assertEqual(result.dispatched, 0)
|
||||
self.assertEqual(result.fetch_issue_count, 1)
|
||||
self.assertEqual(result.fetch_issue_details[0].kind, "json_decode_error")
|
||||
self.assertIn("Expecting value", result.fetch_issue_details[0].detail)
|
||||
|
||||
def test_runtime_dispatches_sms_from_file_source_to_console_sender(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "file",
|
||||
"path": "tests/fixtures/messages.json",
|
||||
"delete_after_read": False,
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
messages = list(runtime.source.fetch())
|
||||
self.assertEqual(len(messages), 2)
|
||||
|
||||
delivered = [runtime.dispatcher.dispatch(message) for message in messages]
|
||||
|
||||
self.assertEqual(len(delivered), 2)
|
||||
self.assertIn("验证码 123456", delivered[0][0].body)
|
||||
self.assertIn("router-a", delivered[0][0].body)
|
||||
self.assertEqual(delivered[0][0].channel, "console")
|
||||
|
||||
def test_runtime_send_command_dispatches_manual_message(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config)
|
||||
|
||||
result = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10086", content="manual send", received_at="2026-04-24 11:30:00")
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertIn("manual send", result[0].body)
|
||||
self.assertIn("10086", result[0].title)
|
||||
|
||||
def test_runtime_dispatches_sms_to_autman_push_post_sender(self):
|
||||
requests: list[Request] = []
|
||||
|
||||
def opener(request: Request):
|
||||
requests.append(request)
|
||||
return {"ok": True}
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
"method": "POST",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
result = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].channel, "autman_push")
|
||||
self.assertEqual(result[0].title, "短信转发 10010")
|
||||
self.assertEqual(len(requests), 1)
|
||||
self.assertEqual(requests[0].full_url, "https://autman.example/api/push")
|
||||
self.assertEqual(requests[0].get_method(), "POST")
|
||||
self.assertEqual(requests[0].headers["Authorization"], "Bearer secret-token")
|
||||
payload = json.loads(requests[0].data.decode("utf-8"))
|
||||
self.assertEqual(payload["title"], "短信转发 10010")
|
||||
self.assertIn("autman body", payload["content"])
|
||||
self.assertEqual(payload["from"], "router-a")
|
||||
|
||||
def test_runtime_records_autman_push_errors_in_dispatch_outcome(self):
|
||||
def opener(request: Request):
|
||||
raise HTTPError(request.full_url, 500, "boom", hdrs=None, fp=None)
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.success_count, 0)
|
||||
self.assertEqual(len(outcome.failures), 1)
|
||||
self.assertEqual(outcome.failures[0].sender, "autman_push")
|
||||
self.assertEqual(outcome.failures[0].error_category, "http")
|
||||
self.assertEqual(outcome.failures[0].status_code, 500)
|
||||
self.assertEqual(outcome.failures[0].reason, "boom")
|
||||
self.assertIn("status=500", outcome.failures[0].error)
|
||||
|
||||
def test_runtime_classifies_autman_push_timeout_errors(self):
|
||||
def opener(request: Request):
|
||||
raise TimeoutError("push timed out")
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.failures[0].error_category, "timeout")
|
||||
self.assertIsNone(outcome.failures[0].status_code)
|
||||
self.assertIn("push timed out", outcome.failures[0].error)
|
||||
|
||||
def test_runtime_captures_autman_push_http_response_snippet(self):
|
||||
class FakeHttpResponse:
|
||||
def __init__(self, payload: str):
|
||||
self._payload = payload.encode("utf-8")
|
||||
|
||||
def read(self, *_args, **_kwargs):
|
||||
return self._payload
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def opener(request: Request):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
502,
|
||||
"Bad Gateway",
|
||||
hdrs=None,
|
||||
fp=FakeHttpResponse('{"error":"upstream exploded"}'),
|
||||
)
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
}
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
outcome = runtime.dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual(outcome.failures[0].error_category, "http")
|
||||
self.assertEqual(outcome.failures[0].status_code, 502)
|
||||
self.assertIn("upstream exploded", outcome.failures[0].response_excerpt)
|
||||
|
||||
def test_dispatcher_returns_per_sender_results_and_preserves_partial_success(self):
|
||||
from dbussmsforward.notifications import NotificationDispatcher
|
||||
|
||||
console = FakeSender("console")
|
||||
autman = FakeSender("autman_push", fail_for_numbers={"10010"})
|
||||
dispatcher = NotificationDispatcher(senders=[console, autman], device_name="router-a")
|
||||
|
||||
outcome = dispatcher.dispatch(
|
||||
SmsMessage(phone_number="10010", content="autman body", received_at="2026-04-24 11:40:00")
|
||||
)
|
||||
|
||||
self.assertEqual([message.channel for message in outcome.messages], ["console"])
|
||||
self.assertEqual(len(outcome.sender_results), 2)
|
||||
self.assertEqual(outcome.sender_results[0].sender, "console")
|
||||
self.assertTrue(outcome.sender_results[0].success)
|
||||
self.assertEqual(outcome.sender_results[1].sender, "autman_push")
|
||||
self.assertFalse(outcome.sender_results[1].success)
|
||||
self.assertIn("autman_push failed for 10010", outcome.sender_results[1].error)
|
||||
self.assertEqual(console.calls, [("10010", "router-a")])
|
||||
self.assertEqual(autman.calls, [("10010", "router-a")])
|
||||
|
||||
def test_runtime_fetches_ready_messages_from_modemmanager_source(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-mm"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "/org/freedesktop/ModemManager1/Modem/0",
|
||||
"storage_blacklist": ["sm"],
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
|
||||
def factory(_config):
|
||||
return FakeModemManagerBus(
|
||||
messages_by_modem={
|
||||
"/org/freedesktop/ModemManager1/Modem/0": [
|
||||
"/org/freedesktop/ModemManager1/SMS/1",
|
||||
"/org/freedesktop/ModemManager1/SMS/2",
|
||||
]
|
||||
},
|
||||
sms_payloads={
|
||||
"/org/freedesktop/ModemManager1/SMS/1": {
|
||||
"state": 3,
|
||||
"number": "+8613800138000",
|
||||
"text": "真实短信验证码 998877",
|
||||
"timestamp": "2026-04-24T12:34:56+08:00",
|
||||
"storage": "me",
|
||||
},
|
||||
"/org/freedesktop/ModemManager1/SMS/2": {
|
||||
"state": 2,
|
||||
"number": "+8613800138001",
|
||||
"text": "未完成短信",
|
||||
"timestamp": "2026-04-24T12:35:00+08:00",
|
||||
"storage": "me",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
runtime = build_runtime(config, bus_factory=factory)
|
||||
messages = list(runtime.source.fetch())
|
||||
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEqual(messages[0].phone_number, "+8613800138000")
|
||||
self.assertIn("998877", messages[0].content)
|
||||
self.assertEqual(messages[0].received_at, "2026-04-24 12:34:56")
|
||||
|
||||
delivered = runtime.dispatcher.dispatch(messages[0])
|
||||
self.assertEqual(delivered[0].channel, "console")
|
||||
self.assertIn("router-mm", delivered[0].body)
|
||||
|
||||
def test_runtime_auto_discovers_modem_paths_for_modemmanager_source(self):
|
||||
raw = {
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "auto",
|
||||
"storage_blacklist": [],
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
|
||||
def factory(_config):
|
||||
return FakeModemManagerBus(
|
||||
messages_by_modem={
|
||||
"/org/freedesktop/ModemManager1/Modem/0": ["/org/freedesktop/ModemManager1/SMS/10"],
|
||||
"/org/freedesktop/ModemManager1/Modem/1": ["/org/freedesktop/ModemManager1/SMS/11"],
|
||||
},
|
||||
sms_payloads={
|
||||
"/org/freedesktop/ModemManager1/SMS/10": {
|
||||
"state": 3,
|
||||
"number": "10000",
|
||||
"text": "modem0 ready",
|
||||
"timestamp": "2026-04-24T10:00:00+00:00",
|
||||
"storage": "me",
|
||||
},
|
||||
"/org/freedesktop/ModemManager1/SMS/11": {
|
||||
"state": 3,
|
||||
"number": "10001",
|
||||
"text": "modem1 ready",
|
||||
"timestamp": "2026-04-24T10:05:00+00:00",
|
||||
"storage": "mt",
|
||||
},
|
||||
},
|
||||
managed_objects=[
|
||||
"/org/freedesktop/ModemManager1/Modem/0",
|
||||
"/org/freedesktop/ModemManager1/Modem/1",
|
||||
],
|
||||
)
|
||||
|
||||
runtime = build_runtime(config, bus_factory=factory)
|
||||
messages = list(runtime.source.fetch())
|
||||
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertEqual({message.phone_number for message in messages}, {"10000", "10001"})
|
||||
|
||||
def test_runtime_send_sms_uses_modemmanager_sender_and_returns_delivery_record(self):
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-send"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
"sms_source": {
|
||||
"kind": "modemmanager",
|
||||
"modem_object_path": "/org/freedesktop/ModemManager1/Modem/9",
|
||||
"state_ready_values": [3],
|
||||
},
|
||||
}
|
||||
config = load_config_dict(raw)
|
||||
bus = FakeModemManagerBus(messages_by_modem={"/org/freedesktop/ModemManager1/Modem/9": []})
|
||||
runtime = build_runtime(config, bus_factory=lambda _config: bus)
|
||||
|
||||
result = runtime.sms_sender.send(phone_number="10086", content="真实发送链路")
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(result.phone_number, "10086")
|
||||
self.assertEqual(result.content, "真实发送链路")
|
||||
self.assertEqual(result.modem_path, "/org/freedesktop/ModemManager1/Modem/9")
|
||||
self.assertEqual(result.message_path, "/org/freedesktop/ModemManager1/Modem/9/sent/1")
|
||||
self.assertEqual(
|
||||
bus.sent_messages,
|
||||
[
|
||||
{
|
||||
"modem_path": "/org/freedesktop/ModemManager1/Modem/9",
|
||||
"phone_number": "10086",
|
||||
"content": "真实发送链路",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def test_runtime_send_sms_without_sender_returns_disabled_result(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
|
||||
result = runtime.sms_sender.send(phone_number="10086", content="noop")
|
||||
|
||||
self.assertFalse(result.success)
|
||||
self.assertEqual(result.error_category, "disabled")
|
||||
self.assertIn("sms send is not configured", result.error)
|
||||
|
||||
def test_forward_loop_deduplicates_messages_across_polls(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "验证码 123456",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"phone_number": "10086",
|
||||
"content": "验证码 123456",
|
||||
"received_at": "2026-04-24 12:00:00",
|
||||
},
|
||||
{
|
||||
"phone_number": "10010",
|
||||
"content": "余额提醒",
|
||||
"received_at": "2026-04-24 12:01:00",
|
||||
},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.polls, 2)
|
||||
self.assertEqual(result.fetched, 3)
|
||||
self.assertEqual(result.dispatched, 2)
|
||||
self.assertEqual(result.duplicates_skipped, 1)
|
||||
self.assertEqual(
|
||||
[(sms.phone_number, sms.content) for sms in runtime.dispatcher.dispatched],
|
||||
[("10086", "验证码 123456"), ("10010", "余额提醒")],
|
||||
)
|
||||
|
||||
def test_forward_loop_respects_max_history_limit(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[{"phone_number": "1", "content": "A", "received_at": "t1"}],
|
||||
[{"phone_number": "2", "content": "B", "received_at": "t2"}],
|
||||
[{"phone_number": "3", "content": "C", "received_at": "t3"}],
|
||||
[{"phone_number": "1", "content": "A", "received_at": "t1"}],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=4, max_seen_messages=2)
|
||||
|
||||
self.assertEqual(result.dispatched, 4)
|
||||
self.assertEqual(result.duplicates_skipped, 0)
|
||||
self.assertEqual(
|
||||
[sms.phone_number for sms in runtime.dispatcher.dispatched],
|
||||
["1", "2", "3", "1"],
|
||||
)
|
||||
|
||||
def test_forward_loop_continues_after_dispatch_errors_and_retries_failed_messages(self):
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{"phone_number": "500", "content": "first try", "received_at": "t1"},
|
||||
{"phone_number": "200", "content": "delivered", "received_at": "t2"},
|
||||
],
|
||||
[
|
||||
{"phone_number": "500", "content": "first try", "received_at": "t1"},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = FlakyDispatcher(failing_numbers={"500"})
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.polls, 2)
|
||||
self.assertEqual(result.fetched, 3)
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.dispatch_failures, 2)
|
||||
self.assertEqual(result.duplicates_skipped, 0)
|
||||
self.assertEqual(runtime.dispatcher.calls, ["500", "200", "500"])
|
||||
self.assertEqual([message.title for message in result.delivered_messages], ["ok 200"])
|
||||
|
||||
def test_forward_loop_records_sender_failure_details_without_retrying_partial_success_message(self):
|
||||
from dbussmsforward.notifications import NotificationDispatcher
|
||||
|
||||
runtime = build_runtime(load_config_dict({}))
|
||||
runtime.source = SequenceSmsSource(
|
||||
[
|
||||
[
|
||||
{"phone_number": "10010", "content": "partial failure", "received_at": "t1"},
|
||||
],
|
||||
[
|
||||
{"phone_number": "10010", "content": "partial failure", "received_at": "t1"},
|
||||
],
|
||||
]
|
||||
)
|
||||
runtime.dispatcher = NotificationDispatcher(
|
||||
senders=[FakeSender("console"), FakeSender("autman_push", fail_for_numbers={"10010"})],
|
||||
device_name="router-a",
|
||||
)
|
||||
|
||||
result = run_forward_loop(runtime, max_iterations=2)
|
||||
|
||||
self.assertEqual(result.dispatched, 1)
|
||||
self.assertEqual(result.dispatch_failures, 1)
|
||||
self.assertEqual(result.duplicates_skipped, 1)
|
||||
self.assertEqual([message.channel for message in result.delivered_messages], ["console"])
|
||||
self.assertEqual(len(result.failure_details), 1)
|
||||
self.assertEqual(result.failure_details[0].sender, "autman_push")
|
||||
self.assertEqual(result.failure_details[0].phone_number, "10010")
|
||||
self.assertIn("autman_push failed for 10010", result.failure_details[0].error)
|
||||
|
||||
def test_forward_loop_records_fetch_issues_and_structured_sender_failures(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
raw = {
|
||||
"forwarding": {"device_name": "router-a"},
|
||||
"notifications": {
|
||||
"console": {"enabled": True},
|
||||
"autman_push": {
|
||||
"enabled": True,
|
||||
"api_url": "https://autman.example/api/push",
|
||||
"access_token": "***",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class FakeHttpResponse:
|
||||
def read(self, *_args, **_kwargs):
|
||||
return b'{"error":"boom body"}'
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def opener(request: Request):
|
||||
raise HTTPError(request.full_url, 503, "Service Unavailable", hdrs=None, fp=FakeHttpResponse())
|
||||
|
||||
config = load_config_dict(raw)
|
||||
runtime = build_runtime(config, http_opener=opener)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
messages_path = Path(tmpdir) / "messages.json"
|
||||
messages_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"phone_number": "10010", "content": "ok", "received_at": "t1"},
|
||||
{"phone_number": "", "content": "", "received_at": "t2"},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime.source = __import__("dbussmsforward.source", fromlist=["FileSmsSource"]).FileSmsSource(
|
||||
path=str(messages_path)
|
||||
)
|
||||
result = run_forward_loop(runtime, max_iterations=1)
|
||||
|
||||
self.assertEqual(result.fetch_issue_count, 1)
|
||||
self.assertEqual(result.dispatch_failures, 1)
|
||||
self.assertEqual(result.failure_details[0].sender, "autman_push")
|
||||
self.assertEqual(result.failure_details[0].error_category, "http")
|
||||
self.assertEqual(result.failure_details[0].status_code, 503)
|
||||
self.assertIn("boom body", result.failure_details[0].response_excerpt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.sms import SmsMessage, SmsSendResult
|
||||
from dbussmsforward.web import build_handler_class, create_web_app
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.messages.append(sms)
|
||||
return []
|
||||
|
||||
|
||||
class RecordingSmsSender:
|
||||
def __init__(self, *, result: SmsSendResult | None = None):
|
||||
self.calls = []
|
||||
self._result = result
|
||||
|
||||
def send(self, phone_number: str, content: str):
|
||||
self.calls.append({"phone_number": phone_number, "content": content})
|
||||
if self._result is not None:
|
||||
return SmsSendResult(
|
||||
success=self._result.success,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path=self._result.modem_path,
|
||||
message_path=self._result.message_path,
|
||||
error=self._result.error,
|
||||
error_category=self._result.error_category,
|
||||
)
|
||||
return SmsSendResult(
|
||||
success=True,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path="/org/freedesktop/ModemManager1/Modem/0",
|
||||
message_path="/org/freedesktop/ModemManager1/SMS/100",
|
||||
)
|
||||
|
||||
|
||||
class WebAppTests(unittest.TestCase):
|
||||
def test_health_status_and_settings_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "appsettings.json"
|
||||
app = create_web_app(
|
||||
load_config_dict(
|
||||
{
|
||||
"daemon": {"host": "127.0.0.1", "port": 9090},
|
||||
"forwarding": {"device_name": "router-web"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
),
|
||||
config_path=config_path,
|
||||
)
|
||||
|
||||
health = app.handle_request("GET", "/api/health")
|
||||
self.assertEqual(health["status"], 200)
|
||||
self.assertEqual(json.loads(health["body"])["status"], "ok")
|
||||
|
||||
system_status = app.handle_request("GET", "/api/system/status")
|
||||
status_payload = json.loads(system_status["body"])
|
||||
self.assertEqual(system_status["status"], 200)
|
||||
self.assertEqual(status_payload["daemon"]["host"], "127.0.0.1")
|
||||
self.assertEqual(status_payload["forwarding"]["device_name"], "router-web")
|
||||
self.assertEqual(status_payload["notifications"]["enabled_channels"], ["console"])
|
||||
|
||||
settings = app.handle_request("GET", "/api/settings")
|
||||
self.assertEqual(settings["status"], 200)
|
||||
settings_payload = json.loads(settings["body"])
|
||||
self.assertEqual(settings_payload["forwarding"]["device_name"], "router-web")
|
||||
|
||||
update = app.handle_request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
body=json.dumps(
|
||||
{
|
||||
"forwarding": {"device_name": "router-updated"},
|
||||
"notifications": {"console": {"enabled": False}},
|
||||
}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(update["status"], 200)
|
||||
updated_payload = json.loads(update["body"])
|
||||
self.assertEqual(updated_payload["forwarding"]["device_name"], "router-updated")
|
||||
self.assertFalse(updated_payload["notifications"]["console"]["enabled"])
|
||||
|
||||
persisted = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["forwarding"]["device_name"], "router-updated")
|
||||
|
||||
def test_send_api_dispatches_message(self):
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "router-web"}}))
|
||||
dispatcher = RecordingDispatcher()
|
||||
app.runtime.dispatcher = dispatcher
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10086", "content": "hello from web"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 200)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["status"], "queued")
|
||||
self.assertEqual(len(dispatcher.messages), 1)
|
||||
self.assertEqual(dispatcher.messages[0].phone_number, "10086")
|
||||
self.assertEqual(dispatcher.messages[0].content, "hello from web")
|
||||
self.assertEqual(dispatcher.messages[0].received_at, "manual")
|
||||
|
||||
def test_send_api_supports_real_sms_send_mode(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
sender = RecordingSmsSender()
|
||||
app.runtime.sms_sender = sender
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps(
|
||||
{"phone_number": "10086", "content": "web real send", "delivery_mode": "real_sms"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 200)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["status"], "sent")
|
||||
self.assertEqual(payload["delivery_mode"], "real_sms")
|
||||
self.assertEqual(payload["modem_path"], "/org/freedesktop/ModemManager1/Modem/0")
|
||||
self.assertEqual(payload["message_path"], "/org/freedesktop/ModemManager1/SMS/100")
|
||||
self.assertEqual(sender.calls, [{"phone_number": "10086", "content": "web real send"}])
|
||||
|
||||
def test_send_api_reports_real_sms_send_failures(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
sender = RecordingSmsSender(
|
||||
result=SmsSendResult(
|
||||
success=False,
|
||||
phone_number="",
|
||||
content="",
|
||||
error="sms send is not configured",
|
||||
error_category="disabled",
|
||||
)
|
||||
)
|
||||
app.runtime.sms_sender = sender
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps(
|
||||
{"phone_number": "10010", "content": "will fail", "delivery_mode": "real_sms"}
|
||||
).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 503)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "sms_send_failed")
|
||||
self.assertEqual(payload["error_category"], "disabled")
|
||||
self.assertIn("sms send is not configured", payload["detail"])
|
||||
|
||||
def test_send_api_rejects_missing_fields(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"content": "missing phone"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], 400)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "validation_error")
|
||||
self.assertIn("phone_number", payload["detail"])
|
||||
|
||||
def test_static_assets_are_served(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
|
||||
index_response = app.handle_request("GET", "/")
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertIn("DbusSmsForward", index_response["body"])
|
||||
self.assertEqual(index_response["content_type"], "text/html; charset=utf-8")
|
||||
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertIn("fetch", js_response["body"])
|
||||
self.assertEqual(js_response["content_type"], "application/javascript; charset=utf-8")
|
||||
|
||||
def test_handler_class_serves_http_requests(self):
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "router-http"}}))
|
||||
handler_class = build_handler_class(app)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
request_path = Path(tmpdir) / "request.txt"
|
||||
request_path.write_text(
|
||||
"GET /api/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import socketserver
|
||||
import threading
|
||||
from http.server import HTTPServer
|
||||
|
||||
class TestServer(socketserver.TCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
server = TestServer(("127.0.0.1", 0), handler_class)
|
||||
thread = threading.Thread(target=server.handle_request, daemon=True)
|
||||
thread.start()
|
||||
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{server.server_address[1]}/api/health", timeout=5) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(json.loads(body)["status"], "ok")
|
||||
finally:
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dbussmsforward.config import load_config_dict
|
||||
from dbussmsforward.sms import NotificationMessage, SmsMessage
|
||||
from dbussmsforward.web import create_web_app
|
||||
|
||||
|
||||
class RecordingDispatcher:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
self.messages.append(sms)
|
||||
return [
|
||||
NotificationMessage(
|
||||
channel="console",
|
||||
title=f"短信转发 {sms.phone_number}",
|
||||
body=f"{sms.content}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebProductTests(unittest.TestCase):
|
||||
def test_sms_send_response_shape_is_consistent_across_delivery_modes(self):
|
||||
class RealSmsSender:
|
||||
def send(self, phone_number: str, content: str):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
success=True,
|
||||
phone_number=phone_number,
|
||||
content=content,
|
||||
modem_path="/org/freedesktop/ModemManager1/Modem/0",
|
||||
message_path="/org/freedesktop/ModemManager1/SMS/1",
|
||||
error=None,
|
||||
error_category=None,
|
||||
)
|
||||
|
||||
app = create_web_app(load_config_dict({}))
|
||||
app.runtime.dispatcher = RecordingDispatcher()
|
||||
app.runtime.sms_sender = RealSmsSender()
|
||||
|
||||
dispatch_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10086", "content": "dispatch ok", "delivery_mode": "dispatch"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
real_sms_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10010", "content": "real sms ok", "delivery_mode": "real_sms"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
dispatch_payload = json.loads(dispatch_response["body"])
|
||||
real_sms_payload = json.loads(real_sms_response["body"])
|
||||
self.assertEqual(dispatch_response["status"], 200)
|
||||
self.assertEqual(real_sms_response["status"], 200)
|
||||
for payload in (dispatch_payload, real_sms_payload):
|
||||
self.assertEqual(payload["ok"], True)
|
||||
self.assertIn(payload["delivery_mode"], {"dispatch", "real_sms"})
|
||||
self.assertIn("status", payload)
|
||||
self.assertIn("phone_number", payload)
|
||||
self.assertIn("received_at", payload)
|
||||
self.assertIn("notification_count", payload)
|
||||
self.assertIn("message_path", payload)
|
||||
self.assertIn("modem_path", payload)
|
||||
self.assertIn("error_category", payload)
|
||||
self.assertIn("detail", payload)
|
||||
|
||||
self.assertEqual(dispatch_payload["notification_count"], 1)
|
||||
self.assertEqual(dispatch_payload["message_path"], None)
|
||||
self.assertEqual(dispatch_payload["modem_path"], None)
|
||||
self.assertEqual(real_sms_payload["notification_count"], 1)
|
||||
self.assertEqual(real_sms_payload["message_path"], "/org/freedesktop/ModemManager1/SMS/1")
|
||||
|
||||
app = create_web_app(
|
||||
load_config_dict(
|
||||
{
|
||||
"forwarding": {"device_name": "router-finish"},
|
||||
"notifications": {"console": {"enabled": True}},
|
||||
}
|
||||
)
|
||||
)
|
||||
app.runtime.dispatcher = RecordingDispatcher()
|
||||
|
||||
send_response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10010", "content": "hello dashboard"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(send_response["status"], 200)
|
||||
|
||||
dashboard = app.handle_request("GET", "/api/dashboard")
|
||||
dashboard_payload = json.loads(dashboard["body"])
|
||||
self.assertEqual(dashboard["status"], 200)
|
||||
self.assertEqual(dashboard_payload["summary"]["manual_send_count"], 1)
|
||||
self.assertEqual(dashboard_payload["summary"]["event_count"], 1)
|
||||
self.assertEqual(dashboard_payload["summary"]["last_sms_phone_number"], "10010")
|
||||
self.assertTrue(dashboard_payload["latest_events"])
|
||||
|
||||
events = app.handle_request("GET", "/api/events")
|
||||
events_payload = json.loads(events["body"])
|
||||
self.assertEqual(events["status"], 200)
|
||||
self.assertEqual(len(events_payload["events"]), 1)
|
||||
self.assertEqual(events_payload["events"][0]["phone_number"], "10010")
|
||||
self.assertEqual(events_payload["events"][0]["notification_count"], 1)
|
||||
self.assertEqual(events_payload["events"][0]["result"], "success")
|
||||
self.assertEqual(events_payload["events"][0]["delivery_mode"], "dispatch")
|
||||
self.assertEqual(events_payload["events"][0]["error_category"], None)
|
||||
|
||||
def test_dashboard_reports_send_failures(self):
|
||||
class FailingDispatcher:
|
||||
def dispatch(self, sms: SmsMessage):
|
||||
raise RuntimeError("dispatch exploded")
|
||||
|
||||
app = create_web_app(load_config_dict({}))
|
||||
app.runtime.dispatcher = FailingDispatcher()
|
||||
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/sms/send",
|
||||
body=json.dumps({"phone_number": "10000", "content": "boom"}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response["status"], 500)
|
||||
payload = json.loads(response["body"])
|
||||
self.assertEqual(payload["error"], "dispatch_failed")
|
||||
self.assertEqual(payload["ok"], False)
|
||||
self.assertEqual(payload["status"], "failed")
|
||||
self.assertEqual(payload["phone_number"], "10000")
|
||||
|
||||
dashboard = json.loads(app.handle_request("GET", "/api/dashboard")["body"])
|
||||
self.assertEqual(dashboard["summary"]["failure_count"], 1)
|
||||
self.assertEqual(dashboard["summary"]["event_count"], 1)
|
||||
self.assertEqual(dashboard["latest_events"][0]["result"], "failed")
|
||||
self.assertEqual(dashboard["latest_events"][0]["delivery_mode"], "dispatch")
|
||||
self.assertEqual(dashboard["latest_events"][0]["error_category"], "dispatch_failed")
|
||||
|
||||
def test_static_index_contains_dashboard_sections(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
body = index_response["body"]
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertIn("运行总览", body)
|
||||
self.assertIn("最近事件", body)
|
||||
self.assertIn("配置中心", body)
|
||||
self.assertIn("短信测试台", body)
|
||||
|
||||
def test_asset_bundle_contains_dashboard_rendering_logic(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertIn("loadDashboard", js_response["body"])
|
||||
self.assertIn("renderEvents", js_response["body"])
|
||||
|
||||
def test_web_console_exposes_delivery_mode_selector_for_product_use(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
css_response = app.handle_request("GET", "/app.css")
|
||||
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertEqual(css_response["status"], 200)
|
||||
self.assertIn("delivery_mode", index_response["body"])
|
||||
self.assertIn("真实短信", index_response["body"])
|
||||
self.assertIn("mode-hint", index_response["body"])
|
||||
self.assertIn("dispatch", js_response["body"])
|
||||
self.assertIn("real_sms", js_response["body"])
|
||||
self.assertIn("formData.get('delivery_mode')", js_response["body"])
|
||||
self.assertIn("需要已配置 ModemManager 发送能力", js_response["body"])
|
||||
self.assertIn("renderResult", js_response["body"])
|
||||
self.assertIn("error_category", js_response["body"])
|
||||
self.assertIn("mode-hint-warning", css_response["body"])
|
||||
|
||||
def test_recent_events_support_operator_filtering_and_failure_visuals(self):
|
||||
app = create_web_app(load_config_dict({}))
|
||||
index_response = app.handle_request("GET", "/")
|
||||
js_response = app.handle_request("GET", "/app.js")
|
||||
css_response = app.handle_request("GET", "/app.css")
|
||||
|
||||
self.assertEqual(index_response["status"], 200)
|
||||
self.assertEqual(js_response["status"], 200)
|
||||
self.assertEqual(css_response["status"], 200)
|
||||
self.assertIn("事件搜索", index_response["body"])
|
||||
self.assertIn("只看失败", index_response["body"])
|
||||
self.assertIn("event-search", index_response["body"])
|
||||
self.assertIn("getFilteredEvents", js_response["body"])
|
||||
self.assertIn("没有匹配当前筛选条件的事件", js_response["body"])
|
||||
self.assertIn("eventResultFilter", js_response["body"])
|
||||
self.assertIn("event-mode-badge", css_response["body"])
|
||||
self.assertIn("event-failure-meta", css_response["body"])
|
||||
self.assertIn("event-toolbar", css_response["body"])
|
||||
self.assertIn("escapeHtml", js_response["body"])
|
||||
self.assertIn("当前事件数", js_response["body"])
|
||||
self.assertIn("状态、方式", index_response["body"])
|
||||
|
||||
def test_settings_update_reflects_in_dashboard(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = Path(tmpdir) / "appsettings.json"
|
||||
app = create_web_app(load_config_dict({"forwarding": {"device_name": "before"}}), config_path=config_path)
|
||||
response = app.handle_request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
body=json.dumps({"forwarding": {"device_name": "after"}}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response["status"], 200)
|
||||
|
||||
dashboard = json.loads(app.handle_request("GET", "/api/dashboard")["body"])
|
||||
self.assertEqual(dashboard["runtime"]["forwarding"]["device_name"], "after")
|
||||
persisted = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["forwarding"]["device_name"], "after")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f1115;
|
||||
color: #f5f7fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, #0b1020 0%, #0f1115 100%);
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(24, 28, 36, 0.92);
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-actions,
|
||||
.actions,
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: #60a5fa;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.metric-value.small {
|
||||
font-size: 24px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.metric-value.danger {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
border: 1px solid #263041;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.summary-label,
|
||||
.event-meta,
|
||||
.event-detail,
|
||||
.empty-state {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.event-toolbar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
border: 1px solid #263041;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #0b1220;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-mode-badge,
|
||||
.event-status,
|
||||
.event-error-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.event-mode-badge {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.event-error-badge {
|
||||
min-width: auto;
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.event-item-failed {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.event-failure-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
color: #fecaca;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
pre,
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #334155;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
padding: 12px;
|
||||
font: 14px/1.5 'SFMono-Regular', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
label,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border: 1px dashed #334155;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #263041;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mode-hint-warning {
|
||||
border-color: rgba(250, 204, 21, 0.35);
|
||||
background: rgba(120, 53, 15, 0.28);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-title.success {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.result-title.failed {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #263041;
|
||||
background: #0b1220;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
color: #f8fafc;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.metrics-grid,
|
||||
.summary-grid,
|
||||
.event-toolbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel-header,
|
||||
.actions,
|
||||
.event-topline {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.layout {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metrics-grid,
|
||||
.summary-grid,
|
||||
.event-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
const statusView = document.getElementById('status-view');
|
||||
const settingsEditor = document.getElementById('settings-editor');
|
||||
const resultView = document.getElementById('result-view');
|
||||
const dashboardSummary = document.getElementById('dashboard-summary');
|
||||
const eventsList = document.getElementById('events-list');
|
||||
const eventsEmpty = document.getElementById('events-empty');
|
||||
const refreshDashboardButton = document.getElementById('refresh-dashboard');
|
||||
const refreshStatusButton = document.getElementById('refresh-status');
|
||||
const refreshEventsButton = document.getElementById('refresh-events');
|
||||
const reloadSettingsButton = document.getElementById('reload-settings');
|
||||
const saveSettingsButton = document.getElementById('save-settings');
|
||||
const smsForm = document.getElementById('sms-form');
|
||||
const deliveryModeSelect = document.getElementById('sms-delivery-mode');
|
||||
const modeHint = document.getElementById('mode-hint');
|
||||
const eventSearchInput = document.getElementById('event-search');
|
||||
const eventResultFilter = document.getElementById('event-result-filter');
|
||||
|
||||
const metricEventCount = document.getElementById('metric-event-count');
|
||||
const metricManualSendCount = document.getElementById('metric-manual-send-count');
|
||||
const metricFailureCount = document.getElementById('metric-failure-count');
|
||||
const metricLastPhone = document.getElementById('metric-last-phone');
|
||||
|
||||
const deliveryModeLabelMap = {
|
||||
dispatch: '通知转发',
|
||||
real_sms: '真实短信',
|
||||
};
|
||||
|
||||
let latestEvents = [];
|
||||
|
||||
function getDeliveryModeLabel(mode) {
|
||||
return deliveryModeLabelMap[mode] || mode || '未知';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatText(value, fallback = '-') {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function getResultLabel(result) {
|
||||
if (result === 'success') {
|
||||
return '成功';
|
||||
}
|
||||
if (result === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return formatText(result, '未知');
|
||||
}
|
||||
|
||||
function updateModeHint() {
|
||||
const deliveryMode = deliveryModeSelect?.value || 'dispatch';
|
||||
if (!modeHint) {
|
||||
return;
|
||||
}
|
||||
if (deliveryMode === 'real_sms') {
|
||||
modeHint.textContent = '当前为真实短信:需要已配置 ModemManager 发送能力;若发送失败,将展示错误分类与失败原因。';
|
||||
modeHint.classList.add('mode-hint-warning');
|
||||
return;
|
||||
}
|
||||
modeHint.textContent = '当前为通知转发:会按已启用通道分发,并写入事件留痕。';
|
||||
modeHint.classList.remove('mode-hint-warning');
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (error) {
|
||||
throw new Error(`响应不是合法 JSON: ${text}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const failure = new Error(data.detail || data.error || `HTTP ${response.status}`);
|
||||
failure.payload = data;
|
||||
failure.status = response.status;
|
||||
throw failure;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderJson(element, payload) {
|
||||
element.textContent = JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function renderResult(payload, options = {}) {
|
||||
const status = options.status || 'success';
|
||||
const title = options.title || (status === 'failed' ? '发送失败' : '操作成功');
|
||||
const rows = Object.entries(payload || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
||||
if (!rows.length) {
|
||||
renderJson(resultView, payload || {});
|
||||
return;
|
||||
}
|
||||
resultView.innerHTML = `
|
||||
<div class="result-panel">
|
||||
<div class="result-title ${escapeHtml(status)}">${escapeHtml(title)}</div>
|
||||
<div class="result-grid">
|
||||
${rows
|
||||
.map(
|
||||
([label, value]) => `
|
||||
<div class="result-item">
|
||||
<span class="result-label">${escapeHtml(label)}</span>
|
||||
<span class="result-value">${escapeHtml(formatText(value))}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateMetric(element, value, fallback = '-') {
|
||||
element.textContent = value ?? fallback;
|
||||
}
|
||||
|
||||
function renderSummaryItem(label, value) {
|
||||
return `
|
||||
<article class="summary-item">
|
||||
<span class="summary-label">${escapeHtml(label)}</span>
|
||||
<strong class="summary-value">${escapeHtml(formatText(value))}</strong>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboard(payload) {
|
||||
const { summary = {}, runtime = {} } = payload;
|
||||
updateMetric(metricEventCount, summary.event_count ?? 0, '0');
|
||||
updateMetric(metricManualSendCount, summary.manual_send_count ?? 0, '0');
|
||||
updateMetric(metricFailureCount, summary.failure_count ?? 0, '0');
|
||||
updateMetric(metricLastPhone, summary.last_sms_phone_number || '-');
|
||||
|
||||
dashboardSummary.innerHTML = [
|
||||
renderSummaryItem('服务状态', runtime.status || 'unknown'),
|
||||
renderSummaryItem('启动时间', runtime.started_at || '-'),
|
||||
renderSummaryItem('请求次数', runtime.request_count ?? 0),
|
||||
renderSummaryItem('配置文件', runtime.config_path || '未落盘'),
|
||||
renderSummaryItem('运行模式', runtime.daemon?.mode || '-'),
|
||||
renderSummaryItem('监听地址', `${runtime.daemon?.host || '-'}:${runtime.daemon?.port || '-'}`),
|
||||
renderSummaryItem('日志级别', runtime.daemon?.log_level || '-'),
|
||||
renderSummaryItem('设备名', runtime.forwarding?.device_name || '-'),
|
||||
renderSummaryItem('启用通道', (runtime.notifications?.enabled_channels || []).join(', ') || '无'),
|
||||
renderSummaryItem('短信源', runtime.sms_source?.kind || '-'),
|
||||
renderSummaryItem('源路径', runtime.sms_source?.path || '-'),
|
||||
renderSummaryItem('轮询间隔', runtime.sms_source?.poll_interval_seconds ?? '-'),
|
||||
renderSummaryItem('当前事件数', summary.filtered_event_count ?? summary.event_count ?? 0),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function getEventSearchText(event) {
|
||||
return [
|
||||
event.phone_number,
|
||||
event.content,
|
||||
event.detail,
|
||||
event.error_category,
|
||||
getDeliveryModeLabel(event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch')),
|
||||
(event.channels || []).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getFilteredEvents(events = latestEvents) {
|
||||
const keyword = (eventSearchInput?.value || '').trim().toLowerCase();
|
||||
const resultFilter = eventResultFilter?.value || 'all';
|
||||
return events.filter((event) => {
|
||||
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
|
||||
if (resultFilter !== 'all' && normalizedResult !== resultFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return `${normalizedResult} ${getResultLabel(normalizedResult)} ${getEventSearchText(event)}`.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEvents(events = latestEvents) {
|
||||
latestEvents = Array.isArray(events) ? events : [];
|
||||
const filteredEvents = getFilteredEvents(latestEvents);
|
||||
if (!filteredEvents.length) {
|
||||
eventsList.innerHTML = '';
|
||||
eventsEmpty.style.display = 'block';
|
||||
eventsEmpty.textContent = latestEvents.length
|
||||
? '没有匹配当前筛选条件的事件,换个关键字或筛选项试试。'
|
||||
: '还没有事件,先去下面的短信测试台发一条试试。';
|
||||
return;
|
||||
}
|
||||
|
||||
eventsEmpty.style.display = 'none';
|
||||
eventsList.innerHTML = filteredEvents
|
||||
.map((event) => {
|
||||
const channels = (event.channels || []).join(', ') || '无';
|
||||
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
|
||||
const statusClass = normalizedResult === 'success' ? 'status-success' : 'status-failed';
|
||||
const deliveryMode = event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch');
|
||||
const deliveryModeLabel = getDeliveryModeLabel(deliveryMode);
|
||||
const errorCategory = formatText(event.error_category, '');
|
||||
return `
|
||||
<article class="event-item ${normalizedResult === 'failed' ? 'event-item-failed' : ''}">
|
||||
<div class="event-topline">
|
||||
<strong>${escapeHtml(formatText(event.phone_number))}</strong>
|
||||
<div class="event-badges">
|
||||
<span class="event-mode-badge">${escapeHtml(deliveryModeLabel)}</span>
|
||||
<span class="event-status ${statusClass}">${escapeHtml(getResultLabel(normalizedResult))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-meta">时间:${escapeHtml(formatText(event.created_at))} · 来源:${escapeHtml(formatText(event.received_at))}</div>
|
||||
<div class="event-body">${escapeHtml(formatText(event.content))}</div>
|
||||
<div class="event-meta">通知数:${escapeHtml(formatText(event.notification_count, '0'))} · 通道:${escapeHtml(channels)}</div>
|
||||
${errorCategory ? `<div class="event-failure-meta"><span class="event-error-badge">错误分类</span><span>${escapeHtml(errorCategory)}</span></div>` : ''}
|
||||
<div class="event-detail">${escapeHtml(formatText(event.detail, '无补充说明'))}</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const status = await fetchJson('/api/system/status');
|
||||
renderJson(statusView, status);
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await fetchJson('/api/settings');
|
||||
settingsEditor.value = JSON.stringify(settings, null, 2);
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const payload = await fetchJson('/api/dashboard');
|
||||
renderDashboard(payload);
|
||||
renderEvents(payload.latest_events || []);
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
const payload = await fetchJson('/api/events');
|
||||
renderEvents(payload.events || []);
|
||||
}
|
||||
|
||||
function bindEventFilters() {
|
||||
eventSearchInput?.addEventListener('input', () => renderEvents(latestEvents));
|
||||
eventResultFilter?.addEventListener('change', () => renderEvents(latestEvents));
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([loadDashboard(), loadStatus(), loadSettings()]);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const parsed = JSON.parse(settingsEditor.value || '{}');
|
||||
const payload = await fetchJson('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
renderResult(
|
||||
{
|
||||
操作: '保存配置',
|
||||
状态: '成功',
|
||||
说明: '配置已通过校验并写入当前运行实例。',
|
||||
},
|
||||
{ title: '配置保存成功' }
|
||||
);
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function sendSms(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(smsForm);
|
||||
const deliveryMode = formData.get('delivery_mode') || 'dispatch';
|
||||
const delivery_mode_label = getDeliveryModeLabel(deliveryMode);
|
||||
try {
|
||||
const payload = await fetchJson('/api/sms/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone_number: formData.get('phone_number'),
|
||||
content: formData.get('content'),
|
||||
delivery_mode: deliveryMode === 'real_sms' ? 'real_sms' : 'dispatch',
|
||||
}),
|
||||
});
|
||||
renderResult(
|
||||
{
|
||||
操作: '发送测试短信',
|
||||
状态: '成功',
|
||||
delivery_mode_label,
|
||||
手机号: payload.phone_number,
|
||||
来源: payload.received_at,
|
||||
MessagePath: payload.message_path,
|
||||
ModemPath: payload.modem_path,
|
||||
},
|
||||
{ title: `${delivery_mode_label}发送成功` }
|
||||
);
|
||||
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
|
||||
} catch (error) {
|
||||
renderResult(
|
||||
{
|
||||
操作: '发送测试短信',
|
||||
状态: '发送失败',
|
||||
delivery_mode_label,
|
||||
错误分类: error.payload?.error_category || error.payload?.error || 'unknown',
|
||||
失败原因: error.payload?.detail || error.message,
|
||||
HTTP状态: error.status || '-',
|
||||
},
|
||||
{ status: 'failed', title: `${delivery_mode_label}发送失败` }
|
||||
);
|
||||
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
updateModeHint();
|
||||
bindEventFilters();
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
refreshDashboardButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadDashboard();
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
refreshStatusButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadStatus();
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
refreshEventsButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
reloadSettingsButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadSettings();
|
||||
renderResult(
|
||||
{
|
||||
操作: '重载配置',
|
||||
状态: '成功',
|
||||
说明: '编辑器已同步当前运行配置。',
|
||||
},
|
||||
{ title: '配置已重载' }
|
||||
);
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
saveSettingsButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
if (deliveryModeSelect) {
|
||||
deliveryModeSelect.addEventListener('change', updateModeHint);
|
||||
}
|
||||
|
||||
smsForm.addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await sendSms(event);
|
||||
} catch (error) {
|
||||
resultView.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DbusSmsForward Web</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="hero card">
|
||||
<div>
|
||||
<p class="eyebrow">DbusSmsForward · Debian-first</p>
|
||||
<h1>短信转发控制台</h1>
|
||||
<p class="muted">
|
||||
一个可直接交付的成品界面:运行总览、最近事件、配置中心、短信测试台,全部放到同一个 Web 面板里。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button id="refresh-dashboard" type="button">刷新总览</button>
|
||||
<button id="refresh-status" type="button">刷新状态</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metrics-grid">
|
||||
<article class="metric-card card">
|
||||
<span class="metric-label">累计事件</span>
|
||||
<strong id="metric-event-count" class="metric-value">-</strong>
|
||||
</article>
|
||||
<article class="metric-card card">
|
||||
<span class="metric-label">手工发送成功</span>
|
||||
<strong id="metric-manual-send-count" class="metric-value">-</strong>
|
||||
</article>
|
||||
<article class="metric-card card">
|
||||
<span class="metric-label">失败次数</span>
|
||||
<strong id="metric-failure-count" class="metric-value danger">-</strong>
|
||||
</article>
|
||||
<article class="metric-card card">
|
||||
<span class="metric-label">最近手机号</span>
|
||||
<strong id="metric-last-phone" class="metric-value small">-</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card split-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>运行总览</h2>
|
||||
<p class="muted">聚合运行状态、配置来源、通知通道与轮询源信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-summary" class="summary-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="card split-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>最近事件</h2>
|
||||
<p class="muted">展示最近短信转发记录,便于直接看成功/失败、发送方式与错误分类。</p>
|
||||
</div>
|
||||
<button id="refresh-events" type="button">刷新事件</button>
|
||||
</div>
|
||||
<div class="event-toolbar">
|
||||
<label>
|
||||
事件搜索
|
||||
<input id="event-search" type="search" placeholder="搜手机号、状态、方式、错误分类、明细" />
|
||||
</label>
|
||||
<label>
|
||||
结果筛选
|
||||
<select id="event-result-filter">
|
||||
<option value="all">全部事件</option>
|
||||
<option value="failed">只看失败</option>
|
||||
<option value="success">只看成功</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="events-empty" class="empty-state">还没有事件,先去下面的短信测试台发一条试试。</div>
|
||||
<div id="events-list" class="events-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>系统状态</h2>
|
||||
<p class="muted">原始接口状态,适合排错时直接查看 JSON。</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="status-view">加载中...</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>配置中心</h2>
|
||||
<p class="muted">直接在线改配置并落盘,适合 Debian/OpenWRT 迁移后的单机维护。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="reload-settings" type="button">重载配置</button>
|
||||
<button id="save-settings" type="button">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="settings-editor" rows="20"></textarea>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>短信测试台</h2>
|
||||
<p class="muted">用于手工发送测试短信,验证通知链路与事件留痕。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="sms-form" class="form-grid">
|
||||
<label>
|
||||
手机号
|
||||
<input id="sms-phone" name="phone_number" placeholder="10086" required />
|
||||
</label>
|
||||
<label>
|
||||
短信内容
|
||||
<textarea id="sms-content" name="content" rows="5" placeholder="测试短信" required></textarea>
|
||||
</label>
|
||||
<label>
|
||||
发送方式
|
||||
<select id="sms-delivery-mode" name="delivery_mode">
|
||||
<option value="dispatch">通知转发</option>
|
||||
<option value="real_sms">真实短信</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="mode-hint" class="mode-hint">
|
||||
当前为通知转发:会按已启用通道分发,并写入事件留痕。
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">发送测试短信</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>操作结果</h2>
|
||||
<p class="muted">显示最近一次保存配置、刷新或手工发送的回执。</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="result-view">等待操作...</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user