feat: publish dbussmsforward refactor

This commit is contained in:
2026-05-08 23:54:03 +08:00
commit 9f1ee9ffe6
77 changed files with 15585 additions and 0 deletions
+63
View File
@@ -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
View File
@@ -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`
这样每一步都可验证,也不会再跑偏。
+25
View File
@@ -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
+34
View File
@@ -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>
+208
View File
@@ -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 _);
}
}
+45
View File
@@ -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)
};
}
}
+11
View File
@@ -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
+836
View File
@@ -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&copy={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 += $"&timestamp={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;
}
}
}
+112
View File
@@ -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;
}
}
}
}
+73
View File
@@ -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 ? "短信已发送" : "短信发送失败");
}
+15
View File
@@ -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;
}
}
+8
View File
@@ -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;
}
+68
View File
@@ -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&copy="+ 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);
}
}
}
}
+105
View File
@@ -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 += $"&timestamp={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));
}
}
}
+99
View File
@@ -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);
}
}
}
}
+66
View File
@@ -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);
}
}
}
}
+125
View File
@@ -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);
}
}
}
}
+100
View File
@@ -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);
}
}
}
}
+93
View File
@@ -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);
}
}
}
}
+23
View File
@@ -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);
}
}
+14
View File
@@ -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
{
}
+40
View File
@@ -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": ""
}
}
+165
View File
@@ -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";
}
+5
View File
@@ -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
+5
View File
@@ -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
+230
View File
@@ -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)
+19
View File
@@ -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"'"}'
+31
View File
@@ -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
]
}
}
+175
View File
@@ -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:实施重构、补齐配置/服务化/兼容逻辑**
+39
View File
@@ -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]
}
}
+21
View File
@@ -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
+35
View File
@@ -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"
+34
View File
@@ -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
}
+35
View File
@@ -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}"
+371
View File
@@ -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 或无需 runtimeself-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 部署入口可以现在直接完成。
+172
View File
@@ -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.
+34
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
"""Python refactor of DbusSmsForward."""
from .config import AppConfig, load_config_dict
__all__ = ["AppConfig", "load_config_dict"]
+134
View File
@@ -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
]
+189
View File
@@ -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())
+304
View File
@@ -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
+170
View File
@@ -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
+224
View File
@@ -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)
+28
View File
@@ -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
+308
View File
@@ -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()
+476
View File
@@ -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
View File
+12
View File
@@ -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"
}
]
+601
View File
@@ -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()
+78
View File
@@ -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()
+150
View File
@@ -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()
+158
View File
@@ -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()
+53
View File
@@ -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()
+106
View File
@@ -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()
+37
View File
@@ -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()
+683
View File
@@ -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()
+229
View File
@@ -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()
+223
View File
@@ -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
View File
@@ -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
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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();
+149
View File
@@ -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>