Initial commit: Web UI improvements, ARM64 support, and documentation
This commit is contained in:
commit
930e5ba8d1
25 files changed
+22747
No files matched your search
@@ -0,0 +1,87 @@
|
||||
# SMS Forwarder - AI编程助手指南
|
||||
|
||||
## 项目概览
|
||||
|
||||
SMS Forwarder 是一个用 Go 编写的短信转发工具,从 ModemManager (Linux D-Bus) 接收 SMS,支持存储到 JSON 并转发到多个通知渠道。
|
||||
|
||||
**关键依赖**: `godbus/dbus/v5` (D-Bus 交互), `BurntSushi/toml` (配置解析)
|
||||
|
||||
## 架构组件
|
||||
|
||||
```
|
||||
main.go → forwarder → [modem/manager + storage + notifier]
|
||||
↓ ↓
|
||||
D-Bus/ModemManager 多通道发送 (email/bark/gotify/serverchan/webhook)
|
||||
```
|
||||
|
||||
### 数据流向
|
||||
|
||||
1. **Modem Manager** ([modem/manager.go](internal/modem/manager.go)) 通过 D-Bus 连接 ModemManager,订阅设备添加/移除事件
|
||||
2. **Messaging** ([modem/messaging.go](internal/modem/messaging.go)) 为每个调制解调器创建独立 D-Bus 连接,订阅 SMS "Added" 信号
|
||||
3. **Forwarder** ([forwarder/forwarder.go](internal/forwarder/forwarder.go)) 协调所有组件:
|
||||
- 维护设备 `ObjectPath` 到 IMEI 的映射 (`equipment`, `modems` maps)
|
||||
- 处理设备插拔 (hotplug),通过 `cancels` map 管理 goroutine 生命周期
|
||||
- 去重设备:同一 IMEI 只保留一个活动订阅
|
||||
4. **Storage** ([storage/storage.go](internal/storage/storage.go)) 使用互斥锁保护 JSON 文件的读写操作
|
||||
5. **Notifier** ([notifier/notifier.go](internal/notifier/notifier.go)) 遍历所有通道发送通知,记录失败但继续尝试后续通道
|
||||
|
||||
## D-Bus 交互模式
|
||||
|
||||
**关键点**: ModemManager 使用 D-Bus Object Manager 接口
|
||||
|
||||
- **系统总线连接**: 所有操作都在 `dbus.SystemBus()` 上进行
|
||||
- **独立订阅连接**: [messaging.go#L39-L52](internal/modem/messaging.go#L39-L52) 为每个调制解调器的 SMS 订阅创建独立的 `dbus.SystemBusPrivate()` 连接,确保信号隔离
|
||||
- **信号匹配**: 使用 `WithMatchPathNamespace` 限制信号范围,避免接收无关事件
|
||||
- **属性读取**: `GetProperty()` 获取 `EquipmentIdentifier`, `Model` 等属性
|
||||
|
||||
## 配置模式
|
||||
|
||||
[config.example.toml](config.example.toml) 展示了配置结构:
|
||||
|
||||
- **单一配置文件**: TOML 格式,支持 `[[channels]]` 数组定义多个通知渠道
|
||||
- **通道类型**: 所有通道共用 `ChannelConfig` 结构体,通过 `type` 字段区分 (email/bark/gotify/serverchan/webhook)
|
||||
- **可选存储**: `storage.enabled` 控制是否保存到 JSON 文件
|
||||
- **调制解调器别名**: 可选的 `[[modems]]` 数组通过 IMEI 设置设备别名
|
||||
|
||||
## 并发与生命周期管理
|
||||
|
||||
- **Context 驱动**: [main.go#L49-L57](main.go#L49-L57) 使用 `context.WithCancel` 管理整个应用生命周期
|
||||
- **每设备 Context**: [forwarder.go#L110-L113](internal/forwarder/forwarder.go#L110-L113) 为每个调制解调器创建独立的子 context
|
||||
- **设备热插拔**: `addModem` 检测重复设备时先 `cancel()` 旧订阅再创建新订阅
|
||||
- **优雅关闭**: [main.go#L62-L68](main.go#L62-L68) 捕获 SIGINT/SIGTERM 信号后取消 context,触发所有 goroutine 退出
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 添加新通知渠道
|
||||
|
||||
1. 在 [notifier/](internal/notifier/) 创建 `<type>.go` (参考 [bark.go](internal/notifier/bark.go) 的简单实现)
|
||||
2. 实现 `Channel` 接口: `Send(Message) error` 和 `Type() string`
|
||||
3. 在 [notifier.go#L88](internal/notifier/notifier.go#L88) 的 `createChannel()` switch 中添加 case
|
||||
4. 在 [config.go](internal/config/config.go) 的 `ChannelConfig` 添加所需配置字段
|
||||
5. 更新 [config.example.toml](config.example.toml) 添加示例配置
|
||||
|
||||
### D-Bus 调试
|
||||
|
||||
- **查看可用调制解调器**: `mmcli -L`
|
||||
- **查看短信**: `mmcli -m <modem-id> --messaging-list-sms`
|
||||
- **监控 D-Bus 信号**: `dbus-monitor --system "type='signal',interface='org.freedesktop.DBus.ObjectManager'"`
|
||||
|
||||
## 运行与构建
|
||||
|
||||
```bash
|
||||
# 开发运行
|
||||
go run main.go -config config.toml
|
||||
|
||||
# 构建
|
||||
go build -o sms-forwarder
|
||||
|
||||
# 测试 (需要 ModemManager)
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 特殊注意
|
||||
|
||||
- **仅限 Linux**: 依赖 ModemManager D-Bus 接口,不支持 Windows/macOS
|
||||
- **设备去重**: [forwarder.go#L95-L104](internal/forwarder/forwarder.go#L95-L104) 使用 `EquipmentIdentifier` (IMEI) 作为唯一标识,防止同一设备重复订阅
|
||||
- **异步通知**: 所有通知通道并行发送,一个失败不影响其他通道
|
||||
- **SMS 接收延迟**: [messaging.go#L85](internal/modem/messaging.go#L85) 调用 `waitForSMSReceived` 等待 SMS 状态变为已接收 (D-Bus 信号和属性更新有时序差异)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# 二进制文件
|
||||
sms-forwarder
|
||||
*.exe
|
||||
|
||||
# 配置文件
|
||||
config.toml
|
||||
|
||||
# 数据文件
|
||||
sms.json
|
||||
*.db
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -0,0 +1,45 @@
|
||||
# SMS Forwarder
|
||||
|
||||
一个简单的短信转发工具,基于 ModemManager 开发,支持将短信转发到多种通知渠道。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持多种转发接通道:Email, Bark, Gotify, ServerChan, Webhook。
|
||||
- 提供 Web 管理界面,支持查看短信列表和设备状态。
|
||||
- 支持 USSD 代码执行(如查询话费)。
|
||||
- 支持短信去重和持久化存储。
|
||||
- 适配 Linux x64 和 Linux ARM64 平台 (仅支持 Linux 系统)。
|
||||
|
||||
## 已测试设备
|
||||
|
||||
- Sierra Wireless AirPrime® EM7430
|
||||
- Qualcomm® Snapdragon™ 410 UFI
|
||||
|
||||
## 编译方法
|
||||
|
||||
使用 Go 语言进行编译:
|
||||
|
||||
```bash
|
||||
# 编译 Linux ARM64 版本
|
||||
GOOS=linux GOARCH=arm64 go build -o sms-forwarder-linux-arm64
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
1. 将 `config.example.toml` 重命名为 `config.toml`。
|
||||
2. 编辑 `config.toml` 文件,配置短信转发通道和相关参数。
|
||||
|
||||
## 运行
|
||||
|
||||
直接运行编译后的二进制文件:
|
||||
|
||||
```bash
|
||||
./sms-forwarder-linux-arm64
|
||||
```
|
||||
|
||||
程序默认监听端口可在配置文件中修改。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 程序依赖 ModemManager,请确保运行环境已安装并运行 ModemManager 服务。
|
||||
- 请确保有足够的权限访问 DBus 系统总线。
|
||||
@@ -0,0 +1,57 @@
|
||||
# SMS 转发器配置文件
|
||||
|
||||
# 存储配置
|
||||
[storage]
|
||||
enabled = true
|
||||
path = "./sms.json" # 短信保存路径
|
||||
|
||||
# Web 服务器配置
|
||||
[server]
|
||||
enabled = true
|
||||
listen = ":8080" # 监听地址,访问 http://localhost:8080
|
||||
|
||||
# 通知通道配置
|
||||
# 支持多个通道,按顺序尝试发送
|
||||
|
||||
# Email 通道
|
||||
[[channels]]
|
||||
type = "email"
|
||||
enabled = true # 是否启用此通道
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
username = "your-email@example.com"
|
||||
password = "your-password"
|
||||
from = "your-email@example.com"
|
||||
to = ["recipient@example.com"]
|
||||
# use_tls = true # 可选,默认 true
|
||||
|
||||
# Bark 通道 (iOS)
|
||||
[[channels]]
|
||||
type = "bark"
|
||||
enabled = false # 是否启用此通道
|
||||
endpoint = "https://api.day.app/your-device-key"
|
||||
# title = "SMS 通知" # 可选
|
||||
|
||||
# Gotify 通道
|
||||
[[channels]]
|
||||
type = "gotify"
|
||||
enabled = false # 是否启用此通道
|
||||
endpoint = "https://gotify.example.com"
|
||||
token = "your-app-token"
|
||||
# priority = 5 # 可选,默认 5
|
||||
|
||||
# ServerChan (Server酱) 通道
|
||||
[[channels]]
|
||||
type = "serverchan"
|
||||
enabled = false # 是否启用此通道
|
||||
send_key = "your-sendkey"
|
||||
|
||||
# Webhook 通道
|
||||
[[channels]]
|
||||
type = "webhook"
|
||||
enabled = false # 是否启用此通道
|
||||
url = "https://your-webhook-endpoint.com/notify"
|
||||
method = "POST" # GET 或 POST
|
||||
# headers 可选
|
||||
[channels.headers]
|
||||
Authorization = "Bearer your-token"
|
||||
@@ -0,0 +1,13 @@
|
||||
module github.com/sms-forwarder
|
||||
|
||||
go 1.21.6
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/easychen/serverchan-sdk-golang v1.0.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/easychen/serverchan-sdk-golang v1.0.0 h1:4B0v0e9+OAFILgCarTMdLLAMekacnINLSZMCKLmHrwk=
|
||||
github.com/easychen/serverchan-sdk-golang v1.0.0/go.mod h1:8zrp/XzKEQgi+KhiVGkeI+WBmdIDOlFMjBK+3pWIVpo=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -0,0 +1,180 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// Config 主配置结构
|
||||
type Config struct {
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Server ServerConfig `toml:"server"`
|
||||
Channels []ChannelConfig `toml:"channels"`
|
||||
}
|
||||
|
||||
// StorageConfig 存储配置
|
||||
type StorageConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Enabled bool `toml:"enabled"` // 是否启用 Web 服务器
|
||||
Listen string `toml:"listen"` // 监听地址,如 ":8080"
|
||||
}
|
||||
|
||||
// ChannelConfig 通道配置
|
||||
type ChannelConfig struct {
|
||||
Type string `toml:"type" json:"type"` // email, bark, gotify, serverchan, pushdeer, webhook
|
||||
Enabled bool `toml:"enabled" json:"enabled"` // 是否启用,默认 false
|
||||
|
||||
// Email
|
||||
Host string `toml:"host" json:"host"`
|
||||
Port int `toml:"port" json:"port"`
|
||||
Username string `toml:"username" json:"username"`
|
||||
Password string `toml:"password" json:"password"`
|
||||
From string `toml:"from" json:"from"`
|
||||
To []string `toml:"to" json:"to"`
|
||||
UseTLS bool `toml:"use_tls" json:"use_tls"`
|
||||
|
||||
// Bark
|
||||
Endpoint string `toml:"endpoint" json:"endpoint"`
|
||||
Title string `toml:"title" json:"title"`
|
||||
|
||||
// Gotify
|
||||
Token string `toml:"token" json:"token"`
|
||||
Priority int `toml:"priority" json:"priority"`
|
||||
|
||||
// ServerChan
|
||||
SendKey string `toml:"send_key" json:"send_key"`
|
||||
|
||||
// Webhook
|
||||
URL string `toml:"url" json:"url"`
|
||||
Method string `toml:"method" json:"method"`
|
||||
Headers map[string]string `toml:"headers" json:"headers"`
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save 保存配置到文件
|
||||
func (c *Config) Save(path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建配置文件: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 构建用于保存的配置,过滤掉每个通道不相关的字段
|
||||
saveConfig := struct {
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Server ServerConfig `toml:"server"`
|
||||
Channels []any `toml:"channels"`
|
||||
}{
|
||||
Storage: c.Storage,
|
||||
Server: c.Server,
|
||||
Channels: make([]any, 0, len(c.Channels)),
|
||||
}
|
||||
|
||||
for _, ch := range c.Channels {
|
||||
switch ch.Type {
|
||||
case "email":
|
||||
saveConfig.Channels = append(saveConfig.Channels, struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
From string `toml:"from"`
|
||||
To []string `toml:"to"`
|
||||
UseTLS bool `toml:"use_tls,omitempty"`
|
||||
}{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
Host: ch.Host,
|
||||
Port: ch.Port,
|
||||
Username: ch.Username,
|
||||
Password: ch.Password,
|
||||
From: ch.From,
|
||||
To: ch.To,
|
||||
UseTLS: ch.UseTLS,
|
||||
})
|
||||
case "bark":
|
||||
saveConfig.Channels = append(saveConfig.Channels, struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
Endpoint string `toml:"endpoint"`
|
||||
Title string `toml:"title,omitempty"`
|
||||
}{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
Endpoint: ch.Endpoint,
|
||||
Title: ch.Title,
|
||||
})
|
||||
case "gotify":
|
||||
saveConfig.Channels = append(saveConfig.Channels, struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
Endpoint string `toml:"endpoint"`
|
||||
Token string `toml:"token"`
|
||||
Priority int `toml:"priority,omitempty"`
|
||||
}{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
Endpoint: ch.Endpoint,
|
||||
Token: ch.Token,
|
||||
Priority: ch.Priority,
|
||||
})
|
||||
case "serverchan":
|
||||
saveConfig.Channels = append(saveConfig.Channels, struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
SendKey string `toml:"send_key"`
|
||||
}{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
SendKey: ch.SendKey,
|
||||
})
|
||||
case "webhook":
|
||||
saveConfig.Channels = append(saveConfig.Channels, struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
URL string `toml:"url"`
|
||||
Method string `toml:"method"`
|
||||
Headers map[string]string `toml:"headers,omitempty"`
|
||||
}{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
URL: ch.URL,
|
||||
Method: ch.Method,
|
||||
Headers: ch.Headers,
|
||||
})
|
||||
default:
|
||||
// 未知类型,保存所有字段
|
||||
saveConfig.Channels = append(saveConfig.Channels, ch)
|
||||
}
|
||||
}
|
||||
|
||||
encoder := toml.NewEncoder(f)
|
||||
if err := encoder.Encode(saveConfig); err != nil {
|
||||
return fmt.Errorf("编码配置文件: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
"github.com/sms-forwarder/internal/modem"
|
||||
"github.com/sms-forwarder/internal/notifier"
|
||||
"github.com/sms-forwarder/internal/storage"
|
||||
)
|
||||
|
||||
// Forwarder 短信转发器
|
||||
type Forwarder struct {
|
||||
cfg *config.Config
|
||||
manager *modem.Manager
|
||||
storage *storage.Storage
|
||||
notifier *notifier.Notifier
|
||||
mu sync.Mutex
|
||||
cancels map[dbus.ObjectPath]context.CancelFunc
|
||||
equipment map[string]dbus.ObjectPath
|
||||
modems map[dbus.ObjectPath]string
|
||||
processed map[string]time.Time // key: timestamp_from_text
|
||||
}
|
||||
|
||||
// New 创建转发器
|
||||
func New(cfg *config.Config, manager *modem.Manager, store *storage.Storage) (*Forwarder, error) {
|
||||
notif, err := notifier.New(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建通知器: %w", err)
|
||||
}
|
||||
|
||||
return &Forwarder{
|
||||
cfg: cfg,
|
||||
manager: manager,
|
||||
storage: store,
|
||||
notifier: notif,
|
||||
cancels: make(map[dbus.ObjectPath]context.CancelFunc),
|
||||
equipment: make(map[string]dbus.ObjectPath),
|
||||
modems: make(map[dbus.ObjectPath]string),
|
||||
processed: make(map[string]time.Time),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run 运行转发器
|
||||
func (f *Forwarder) Run(ctx context.Context) error {
|
||||
if len(f.cfg.Channels) == 0 && (f.storage == nil || !f.cfg.Storage.Enabled) {
|
||||
slog.Info("未配置任何通道或存储,转发器将不执行任何操作")
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取现有调制解调器
|
||||
modems, err := f.manager.Modems()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取调制解调器列表: %w", err)
|
||||
}
|
||||
for path, m := range modems {
|
||||
f.addModem(ctx, path, m)
|
||||
}
|
||||
|
||||
// 订阅调制解调器事件
|
||||
unsubscribe, err := f.manager.Subscribe(func(event modem.ModemEvent) error {
|
||||
switch event.Type {
|
||||
case modem.ModemEventAdded:
|
||||
if event.Modem == nil {
|
||||
return nil
|
||||
}
|
||||
f.addModem(ctx, event.Path, event.Modem)
|
||||
case modem.ModemEventRemoved:
|
||||
f.removeModem(event.Path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("订阅调制解调器事件: %w", err)
|
||||
}
|
||||
defer unsubscribe()
|
||||
|
||||
<-ctx.Done()
|
||||
f.stopAll()
|
||||
return nil
|
||||
}
|
||||
|
||||
// addModem 添加调制解调器
|
||||
func (f *Forwarder) addModem(ctx context.Context, path dbus.ObjectPath, m *modem.Modem) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
// 处理重复设备
|
||||
var oldCancel context.CancelFunc
|
||||
if m.EquipmentIdentifier != "" {
|
||||
if existingPath, ok := f.equipment[m.EquipmentIdentifier]; ok && existingPath != path {
|
||||
oldCancel = f.cancels[existingPath]
|
||||
delete(f.cancels, existingPath)
|
||||
delete(f.modems, existingPath)
|
||||
delete(f.equipment, m.EquipmentIdentifier)
|
||||
slog.Info("检测到重复设备,取消旧订阅", "imei", m.EquipmentIdentifier, "old_path", existingPath, "new_path", path)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已订阅
|
||||
if _, ok := f.cancels[path]; ok {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
modemCtx, cancel := context.WithCancel(ctx)
|
||||
f.cancels[path] = cancel
|
||||
if m.EquipmentIdentifier != "" {
|
||||
f.equipment[m.EquipmentIdentifier] = path
|
||||
f.modems[path] = m.EquipmentIdentifier
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
// 立即取消旧订阅
|
||||
if oldCancel != nil {
|
||||
oldCancel()
|
||||
}
|
||||
|
||||
slog.Info("订阅调制解调器", "imei", m.EquipmentIdentifier, "model", m.Model)
|
||||
|
||||
// 启动订阅
|
||||
go func() {
|
||||
if err := m.Messaging().Subscribe(modemCtx, func(message *modem.SMS) error {
|
||||
return f.handleMessage(m, message)
|
||||
}); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("调制解调器订阅停止", "error", err, "imei", m.EquipmentIdentifier)
|
||||
}
|
||||
f.removeModem(path)
|
||||
}()
|
||||
}
|
||||
|
||||
// removeModem 移除调制解调器
|
||||
func (f *Forwarder) removeModem(path dbus.ObjectPath) {
|
||||
var cancel context.CancelFunc
|
||||
f.mu.Lock()
|
||||
cancel = f.cancels[path]
|
||||
delete(f.cancels, path)
|
||||
if equipmentID, ok := f.modems[path]; ok {
|
||||
delete(f.modems, path)
|
||||
delete(f.equipment, equipmentID)
|
||||
slog.Info("移除调制解调器", "imei", equipmentID)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// stopAll 停止所有订阅
|
||||
func (f *Forwarder) stopAll() {
|
||||
f.mu.Lock()
|
||||
cancels := make([]context.CancelFunc, 0, len(f.cancels))
|
||||
for _, cancel := range f.cancels {
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
f.cancels = make(map[dbus.ObjectPath]context.CancelFunc)
|
||||
f.equipment = make(map[string]dbus.ObjectPath)
|
||||
f.modems = make(map[dbus.ObjectPath]string)
|
||||
f.mu.Unlock()
|
||||
|
||||
for _, cancel := range cancels {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessage 处理接收到的短信
|
||||
func (f *Forwarder) handleMessage(m *modem.Modem, sms *modem.SMS) error {
|
||||
// 只处理接收的短信,跳过发送的短信
|
||||
if sms.State == modem.SMSStateSent || sms.State == modem.SMSStateSending {
|
||||
slog.Debug("跳过已发送的短信", "state", sms.State.String(), "from", sms.Number)
|
||||
return nil
|
||||
}
|
||||
|
||||
// incoming := sms.State == modem.SMSStateReceived || sms.State == modem.SMSStateReceiving
|
||||
|
||||
smsPath := sms.Path()
|
||||
slog.Info("handleMessage调用", "path", smsPath, "from", sms.Number, "state", sms.State.String(), "text_preview", sms.Text[:min(20, len(sms.Text))])
|
||||
|
||||
// 优先过滤旧消息(避免重启后重复发送)
|
||||
// if incoming && !sms.Timestamp.IsZero() && time.Since(sms.Timestamp) > 5*time.Minute {
|
||||
// slog.Info("跳过 5 分钟前的短信", "timestamp", sms.Timestamp, "imei", m.EquipmentIdentifier)
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// 检查存储中是否已存在(持久化去重)
|
||||
if f.storage != nil && f.cfg.Storage.Enabled {
|
||||
if f.storage.Has(m.EquipmentIdentifier, sms) {
|
||||
slog.Debug("跳过已存储的重复短信", "timestamp", sms.Timestamp, "from", sms.Number)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if f.isDuplicateSMS(sms) {
|
||||
slog.Debug("跳过重复短信", "path", sms.Path(), "from", sms.Number)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("收到短信",
|
||||
"imei", m.EquipmentIdentifier,
|
||||
"from", sms.Number,
|
||||
"timestamp", sms.Timestamp,
|
||||
"text", sms.Text,
|
||||
)
|
||||
|
||||
// 保存到存储
|
||||
if f.storage != nil && f.cfg.Storage.Enabled {
|
||||
if err := f.storage.Save(m.EquipmentIdentifier, sms); err != nil {
|
||||
slog.Error("保存短信失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发到通知通道
|
||||
if len(f.cfg.Channels) > 0 {
|
||||
msg := f.formatMessage(m, sms)
|
||||
if err := f.notifier.Send(msg); err != nil {
|
||||
slog.Error("发送通知失败", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forwarder) isDuplicateSMS(sms *modem.SMS) bool {
|
||||
if sms == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用 timestamp+from+text 作为唯一标识
|
||||
key := fmt.Sprintf("%d_%s_%s", sms.Timestamp.Unix(), sms.Number, sms.Text)
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-2 * time.Hour)
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// 清理过期记录
|
||||
for k, t := range f.processed {
|
||||
if t.Before(cutoff) {
|
||||
delete(f.processed, k)
|
||||
}
|
||||
}
|
||||
|
||||
if lastTime, exists := f.processed[key]; exists {
|
||||
slog.Info("检测到重复短信", "key", key, "last_processed", lastTime, "gap_ms", now.Sub(lastTime).Milliseconds())
|
||||
return true
|
||||
}
|
||||
f.processed[key] = now
|
||||
slog.Debug("短信首次处理", "key", key)
|
||||
return false
|
||||
}
|
||||
|
||||
// formatMessage 格式化消息
|
||||
func (f *Forwarder) formatMessage(m *modem.Modem, sms *modem.SMS) notifier.Message {
|
||||
incoming := sms.State == modem.SMSStateReceived || sms.State == modem.SMSStateReceiving
|
||||
sender, recipient := sms.Number, m.Number
|
||||
if !incoming {
|
||||
sender, recipient = recipient, sender
|
||||
}
|
||||
|
||||
modemName := f.getModemName(m)
|
||||
|
||||
return notifier.Message{
|
||||
Modem: modemName,
|
||||
From: sender,
|
||||
To: recipient,
|
||||
Timestamp: sms.Timestamp,
|
||||
Text: strings.TrimSpace(sms.Text),
|
||||
Incoming: incoming,
|
||||
}
|
||||
}
|
||||
|
||||
// getModemName 获取调制解调器名称
|
||||
func (f *Forwarder) getModemName(m *modem.Modem) string {
|
||||
if m.Model != "" {
|
||||
return strings.TrimSpace(m.Model)
|
||||
}
|
||||
return m.EquipmentIdentifier
|
||||
}
|
||||
|
||||
// GetModems 获取所有调制解调器信息
|
||||
func (f *Forwarder) GetModems() []*modem.Modem {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
modems := make([]*modem.Modem, 0, len(f.modems))
|
||||
for path := range f.modems {
|
||||
if m, err := modem.NewModem(f.manager.Conn(), path); err == nil {
|
||||
modems = append(modems, m)
|
||||
}
|
||||
}
|
||||
return modems
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package modem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
ModemManagerInterface = "org.freedesktop.ModemManager1"
|
||||
ModemManagerPath = "/org/freedesktop/ModemManager1"
|
||||
ObjectManagerInterface = "org.freedesktop.DBus.ObjectManager"
|
||||
)
|
||||
|
||||
// Manager 调制解调器管理器
|
||||
type Manager struct {
|
||||
conn *dbus.Conn
|
||||
dbusObject dbus.BusObject
|
||||
}
|
||||
|
||||
// NewManager 创建管理器
|
||||
func NewManager() (*Manager, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接系统总线: %w", err)
|
||||
}
|
||||
|
||||
obj := conn.Object(ModemManagerInterface, ModemManagerPath)
|
||||
return &Manager{
|
||||
conn: conn,
|
||||
dbusObject: obj,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Modems 获取所有调制解调器
|
||||
func (m *Manager) Modems() (map[dbus.ObjectPath]*Modem, error) {
|
||||
// 使用 ObjectManager 接口获取所有管理的对象
|
||||
managedObjects := make(map[dbus.ObjectPath]map[string]map[string]dbus.Variant)
|
||||
err := m.dbusObject.Call(ObjectManagerInterface+".GetManagedObjects", 0).Store(&managedObjects)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取调制解调器列表: %w", err)
|
||||
}
|
||||
|
||||
modems := make(map[dbus.ObjectPath]*Modem)
|
||||
for objectPath, interfaces := range managedObjects {
|
||||
// 检查对象是否包含 Modem 接口
|
||||
if _, hasModem := interfaces[ModemInterface]; !hasModem {
|
||||
continue
|
||||
}
|
||||
|
||||
modem, err := NewModem(m.conn, objectPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
modems[objectPath] = modem
|
||||
}
|
||||
|
||||
return modems, nil
|
||||
}
|
||||
|
||||
// Subscribe 订阅调制解调器事件
|
||||
func (m *Manager) Subscribe(handler func(event ModemEvent) error) (func(), error) {
|
||||
if err := m.conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(ObjectManagerInterface),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
dbus.WithMatchPathNamespace(ModemManagerPath),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := m.conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(ObjectManagerInterface),
|
||||
dbus.WithMatchMember("InterfacesRemoved"),
|
||||
dbus.WithMatchPathNamespace(ModemManagerPath),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signalChan := make(chan *dbus.Signal, 10)
|
||||
m.conn.Signal(signalChan)
|
||||
|
||||
go func() {
|
||||
for sig := range signalChan {
|
||||
var event ModemEvent
|
||||
event.Path = sig.Body[0].(dbus.ObjectPath)
|
||||
|
||||
switch sig.Name {
|
||||
case ObjectManagerInterface + ".InterfacesAdded":
|
||||
event.Type = ModemEventAdded
|
||||
modem, err := NewModem(m.conn, event.Path)
|
||||
if err == nil {
|
||||
event.Modem = modem
|
||||
}
|
||||
case ObjectManagerInterface + ".InterfacesRemoved":
|
||||
event.Type = ModemEventRemoved
|
||||
}
|
||||
|
||||
_ = handler(event)
|
||||
}
|
||||
}()
|
||||
|
||||
unsubscribe := func() {
|
||||
m.conn.RemoveSignal(signalChan)
|
||||
close(signalChan)
|
||||
}
|
||||
|
||||
return unsubscribe, nil
|
||||
}
|
||||
|
||||
// Conn 返回 D-Bus 连接
|
||||
func (m *Manager) Conn() *dbus.Conn {
|
||||
return m.conn
|
||||
}
|
||||
|
||||
// ModemEventType 调制解调器事件类型
|
||||
type ModemEventType int
|
||||
|
||||
const (
|
||||
ModemEventAdded ModemEventType = iota
|
||||
ModemEventRemoved
|
||||
)
|
||||
|
||||
// ModemEvent 调制解调器事件
|
||||
type ModemEvent struct {
|
||||
Type ModemEventType
|
||||
Path dbus.ObjectPath
|
||||
Modem *Modem
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package modem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const ModemMessagingInterface = ModemInterface + ".Messaging"
|
||||
|
||||
// Messaging 消息接口
|
||||
type Messaging struct {
|
||||
modem *Modem
|
||||
}
|
||||
|
||||
// List 列出所有短信
|
||||
func (msg *Messaging) List() ([]*SMS, error) {
|
||||
var messages []dbus.ObjectPath
|
||||
err := msg.modem.dbusObject.Call(ModemMessagingInterface+".List", 0).Store(&messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := make([]*SMS, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
sms, err := msg.Retrieve(message)
|
||||
if err != nil {
|
||||
slog.Error("获取短信失败", "path", message, "error", err)
|
||||
continue
|
||||
}
|
||||
s = append(s, sms)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Subscribe 订阅新短信
|
||||
func (msg *Messaging) Subscribe(ctx context.Context, subscriber func(message *SMS) error) error {
|
||||
conn, err := dbus.SystemBusPrivate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := conn.Auth(nil); err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
if err := conn.Hello(); err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
slog.Error("关闭 DBus 连接失败", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchMember("Added"),
|
||||
dbus.WithMatchPathNamespace(msg.modem.objectPath),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signalChan := make(chan *dbus.Signal, 10)
|
||||
conn.Signal(signalChan)
|
||||
defer conn.RemoveSignal(signalChan)
|
||||
|
||||
for {
|
||||
select {
|
||||
case sig := <-signalChan:
|
||||
if len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
// Body[0] 是 ObjectPath, Body[1] 是 bool (received)
|
||||
received, ok := sig.Body[1].(bool)
|
||||
if !ok || !received {
|
||||
continue
|
||||
}
|
||||
|
||||
path, ok := sig.Body[0].(dbus.ObjectPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
sms, err := msg.waitForSMSReceived(ctx, conn, path, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
slog.Error("处理短信失败", "error", err, "path", path)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := subscriber(sms); err != nil {
|
||||
slog.Error("订阅者处理失败", "error", err, "path", path)
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
slog.Info("取消订阅短信", "modem", msg.modem.EquipmentIdentifier)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForSMSReceived 等待短信完全接收
|
||||
func (msg *Messaging) waitForSMSReceived(ctx context.Context, conn *dbus.Conn, path dbus.ObjectPath, interval time.Duration) (*SMS, error) {
|
||||
if interval <= 0 {
|
||||
interval = 100 * time.Millisecond
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
sms, err := msg.retrieveWithConn(conn, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sms.State == SMSStateReceived {
|
||||
return sms, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve 获取短信详情
|
||||
func (msg *Messaging) Retrieve(objectPath dbus.ObjectPath) (*SMS, error) {
|
||||
return msg.retrieveWithConn(msg.modem.conn, objectPath)
|
||||
}
|
||||
|
||||
// Delete 删除短信
|
||||
func (msg *Messaging) Delete(path dbus.ObjectPath) error {
|
||||
return msg.modem.dbusObject.Call(ModemMessagingInterface+".Delete", 0, path).Err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package modem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const ModemInterface = ModemManagerInterface + ".Modem"
|
||||
|
||||
// Modem 调制解调器
|
||||
type Modem struct {
|
||||
conn *dbus.Conn
|
||||
objectPath dbus.ObjectPath
|
||||
dbusObject dbus.BusObject
|
||||
EquipmentIdentifier string // IMEI
|
||||
Model string
|
||||
Manufacturer string
|
||||
Number string // MSISDN
|
||||
SignalQuality uint32 // 信号质量 (0-100)
|
||||
OperatorName string // 运营商名称
|
||||
ICCID string // SIM 卡 ICCID
|
||||
}
|
||||
|
||||
// NewModem 创建调制解调器实例
|
||||
func NewModem(conn *dbus.Conn, path dbus.ObjectPath) (*Modem, error) {
|
||||
obj := conn.Object(ModemManagerInterface, path)
|
||||
|
||||
modem := &Modem{
|
||||
conn: conn,
|
||||
objectPath: path,
|
||||
dbusObject: obj,
|
||||
}
|
||||
|
||||
// 获取设备信息
|
||||
if variant, err := obj.GetProperty(ModemInterface + ".EquipmentIdentifier"); err == nil {
|
||||
modem.EquipmentIdentifier = variant.Value().(string)
|
||||
}
|
||||
|
||||
if variant, err := obj.GetProperty(ModemInterface + ".Model"); err == nil {
|
||||
modem.Model = variant.Value().(string)
|
||||
}
|
||||
|
||||
if variant, err := obj.GetProperty(ModemInterface + ".Manufacturer"); err == nil {
|
||||
modem.Manufacturer = variant.Value().(string)
|
||||
}
|
||||
|
||||
// 尝试获取 MSISDN
|
||||
if variant, err := obj.GetProperty(ModemInterface + ".OwnNumbers"); err == nil {
|
||||
if numbers, ok := variant.Value().([]string); ok && len(numbers) > 0 {
|
||||
modem.Number = numbers[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取信号质量
|
||||
modem.UpdateSignalQuality()
|
||||
|
||||
// 获取运营商名称
|
||||
modem.UpdateOperatorName()
|
||||
|
||||
// 获取 ICCID
|
||||
modem.UpdateICCID()
|
||||
|
||||
return modem, nil
|
||||
}
|
||||
|
||||
// Path 返回 DBus 路径
|
||||
func (m *Modem) Path() dbus.ObjectPath {
|
||||
return m.objectPath
|
||||
}
|
||||
|
||||
// Messaging 返回消息接口
|
||||
func (m *Modem) Messaging() *Messaging {
|
||||
return &Messaging{modem: m}
|
||||
}
|
||||
|
||||
// String 返回调制解调器描述
|
||||
func (m *Modem) String() string {
|
||||
if m.Model != "" {
|
||||
return fmt.Sprintf("%s (%s)", m.Model, m.EquipmentIdentifier)
|
||||
}
|
||||
return m.EquipmentIdentifier
|
||||
}
|
||||
|
||||
// UpdateSignalQuality 更新信号质量
|
||||
func (m *Modem) UpdateSignalQuality() {
|
||||
if variant, err := m.dbusObject.GetProperty(ModemInterface + ".SignalQuality"); err == nil {
|
||||
// SignalQuality 返回 (quality, recent) 元组
|
||||
if tuple, ok := variant.Value().([]interface{}); ok && len(tuple) >= 1 {
|
||||
if quality, ok := tuple[0].(uint32); ok {
|
||||
m.SignalQuality = quality
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateOperatorName 更新运营商名称
|
||||
func (m *Modem) UpdateOperatorName() {
|
||||
const Modem3gppInterface = ModemManagerInterface + ".Modem.Modem3gpp"
|
||||
if variant, err := m.dbusObject.GetProperty(Modem3gppInterface + ".OperatorName"); err == nil {
|
||||
if name, ok := variant.Value().(string); ok {
|
||||
m.OperatorName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateICCID 更新 SIM 卡 ICCID
|
||||
func (m *Modem) UpdateICCID() {
|
||||
const SimInterface = ModemManagerInterface + ".Sim"
|
||||
// 首先获取 Sim 对象路径
|
||||
if variant, err := m.dbusObject.GetProperty(ModemInterface + ".Sim"); err == nil {
|
||||
if simPath, ok := variant.Value().(dbus.ObjectPath); ok && simPath != "/" {
|
||||
// 获取 Sim 对象的 SimIdentifier 属性
|
||||
simObj := m.conn.Object(ModemManagerInterface, simPath)
|
||||
if simVariant, err := simObj.GetProperty(SimInterface + ".SimIdentifier"); err == nil {
|
||||
if iccid, ok := simVariant.Value().(string); ok {
|
||||
m.ICCID = iccid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunUSSD 执行 USSD 代码
|
||||
func (m *Modem) RunUSSD(code string) (string, error) {
|
||||
const UssdInterface = ModemManagerInterface + ".Modem.Modem3gpp.Ussd"
|
||||
|
||||
// 调用 Initiate 方法
|
||||
// Initiate (s command) -> (s reply)
|
||||
var reply string
|
||||
err := m.dbusObject.Call(UssdInterface+".Initiate", 0, code).Store(&reply)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("USSD 执行失败: %w", err)
|
||||
}
|
||||
|
||||
// 有些 Modem 可能不通过返回值返回结果,而是通过 Respond 方法或信号
|
||||
// 但通常 Initiate 会等待直到会话结束或返回
|
||||
return reply, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package modem
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const ModemSMSInterface = ModemManagerInterface + ".Sms"
|
||||
|
||||
// SMSState 短信状态
|
||||
type SMSState uint32
|
||||
|
||||
const (
|
||||
SMSStateUnknown SMSState = iota // 未知状态
|
||||
SMSStateStored // 已存储
|
||||
SMSStateReceiving // 接收中
|
||||
SMSStateReceived // 已接收
|
||||
SMSStateSending // 发送中
|
||||
SMSStateSent // 已发送
|
||||
)
|
||||
|
||||
// String 返回状态描述
|
||||
func (s SMSState) String() string {
|
||||
switch s {
|
||||
case SMSStateUnknown:
|
||||
return "unknown"
|
||||
case SMSStateStored:
|
||||
return "stored"
|
||||
case SMSStateReceiving:
|
||||
return "receiving"
|
||||
case SMSStateReceived:
|
||||
return "received"
|
||||
case SMSStateSending:
|
||||
return "sending"
|
||||
case SMSStateSent:
|
||||
return "sent"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 短信
|
||||
type SMS struct {
|
||||
objectPath dbus.ObjectPath
|
||||
State SMSState
|
||||
Number string
|
||||
Text string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Path 返回 DBus 路径
|
||||
func (sms *SMS) Path() dbus.ObjectPath {
|
||||
return sms.objectPath
|
||||
}
|
||||
|
||||
// retrieveWithConn 使用指定连接获取短信
|
||||
func (msg *Messaging) retrieveWithConn(conn *dbus.Conn, objectPath dbus.ObjectPath) (*SMS, error) {
|
||||
obj := conn.Object(ModemManagerInterface, objectPath)
|
||||
|
||||
sms := SMS{objectPath: objectPath}
|
||||
|
||||
// 获取状态
|
||||
variant, err := obj.GetProperty(ModemSMSInterface + ".State")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sms.State = SMSState(variant.Value().(uint32))
|
||||
|
||||
// 获取号码
|
||||
variant, err = obj.GetProperty(ModemSMSInterface + ".Number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sms.Number = variant.Value().(string)
|
||||
|
||||
// 获取文本
|
||||
variant, err = obj.GetProperty(ModemSMSInterface + ".Text")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sms.Text = variant.Value().(string)
|
||||
|
||||
// 获取时间戳
|
||||
variant, err = obj.GetProperty(ModemSMSInterface + ".Timestamp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t := variant.Value().(string); t != "" {
|
||||
// 处理时区格式(+08:00 需要转为 +08:00)
|
||||
if len(t) >= 3 && (t[len(t)-3] == '+' || t[len(t)-3] == '-') {
|
||||
t = t + ":00"
|
||||
}
|
||||
sms.Timestamp, err = time.Parse(time.RFC3339, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &sms, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// BarkChannel Bark 通道
|
||||
type BarkChannel struct {
|
||||
cfg config.ChannelConfig
|
||||
}
|
||||
|
||||
// NewBarkChannel 创建 Bark 通道
|
||||
func NewBarkChannel(cfg config.ChannelConfig) (*BarkChannel, error) {
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, fmt.Errorf("Bark endpoint 未配置")
|
||||
}
|
||||
return &BarkChannel{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Type 返回通道类型
|
||||
func (b *BarkChannel) Type() string {
|
||||
return "bark"
|
||||
}
|
||||
|
||||
// Send 发送 Bark 通知
|
||||
func (b *BarkChannel) Send(msg Message) error {
|
||||
title := msg.From
|
||||
if title == "" {
|
||||
title = "未知号码"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
"body": msg.String(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(b.cfg.Endpoint, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Bark 返回错误状态: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// EmailChannel Email 通道
|
||||
type EmailChannel struct {
|
||||
cfg config.ChannelConfig
|
||||
}
|
||||
|
||||
// NewEmailChannel 创建 Email 通道
|
||||
func NewEmailChannel(cfg config.ChannelConfig) (*EmailChannel, error) {
|
||||
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" || len(cfg.To) == 0 {
|
||||
return nil, fmt.Errorf("Email 配置不完整")
|
||||
}
|
||||
if cfg.UseTLS {
|
||||
cfg.UseTLS = true // 默认使用 TLS
|
||||
}
|
||||
return &EmailChannel{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Type 返回通道类型
|
||||
func (e *EmailChannel) Type() string {
|
||||
return "email"
|
||||
}
|
||||
|
||||
// Send 发送邮件
|
||||
func (e *EmailChannel) Send(msg Message) error {
|
||||
subject := msg.From
|
||||
if subject == "" {
|
||||
subject = "未知号码"
|
||||
}
|
||||
body := msg.String()
|
||||
|
||||
// 构建邮件内容
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = e.cfg.From
|
||||
headers["To"] = strings.Join(e.cfg.To, ",")
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/plain; charset=UTF-8"
|
||||
|
||||
var message strings.Builder
|
||||
for k, v := range headers {
|
||||
message.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
message.WriteString("\r\n")
|
||||
message.WriteString(body)
|
||||
|
||||
// 发送邮件
|
||||
addr := fmt.Sprintf("%s:%d", e.cfg.Host, e.cfg.Port)
|
||||
|
||||
var auth smtp.Auth
|
||||
if e.cfg.Username != "" && e.cfg.Password != "" {
|
||||
auth = smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.Host)
|
||||
}
|
||||
|
||||
if e.cfg.UseTLS {
|
||||
// 使用 TLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: e.cfg.Host,
|
||||
}
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TLS 连接失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, e.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
|
||||
}
|
||||
defer client.Quit()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(e.cfg.From); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, to := range e.cfg.To {
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte(message.String()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// 不使用 TLS
|
||||
return smtp.SendMail(addr, auth, e.cfg.From, e.cfg.To, []byte(message.String()))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// GotifyChannel Gotify 通道
|
||||
type GotifyChannel struct {
|
||||
cfg config.ChannelConfig
|
||||
}
|
||||
|
||||
// NewGotifyChannel 创建 Gotify 通道
|
||||
func NewGotifyChannel(cfg config.ChannelConfig) (*GotifyChannel, error) {
|
||||
if cfg.Endpoint == "" || cfg.Token == "" {
|
||||
return nil, fmt.Errorf("Gotify 配置不完整")
|
||||
}
|
||||
if cfg.Priority == 0 {
|
||||
cfg.Priority = 5
|
||||
}
|
||||
return &GotifyChannel{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Type 返回通道类型
|
||||
func (g *GotifyChannel) Type() string {
|
||||
return "gotify"
|
||||
}
|
||||
|
||||
// Send 发送 Gotify 通知
|
||||
func (g *GotifyChannel) Send(msg Message) error {
|
||||
url := fmt.Sprintf("%s/message?token=%s", g.cfg.Endpoint, g.cfg.Token)
|
||||
title := msg.From
|
||||
if title == "" {
|
||||
title = "未知号码"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": title,
|
||||
"message": msg.String(),
|
||||
"priority": g.cfg.Priority,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Gotify 返回错误状态: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// Message 通知消息
|
||||
type Message struct {
|
||||
Modem string
|
||||
From string
|
||||
To string
|
||||
Timestamp time.Time
|
||||
Text string
|
||||
Incoming bool
|
||||
}
|
||||
|
||||
// String 返回纯文本格式
|
||||
func (m Message) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\n\n发送人: %s\n时间: %s",
|
||||
m.displayText(),
|
||||
m.From,
|
||||
m.formatTimestamp(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Message) displayText() string {
|
||||
if m.Text == "" {
|
||||
return "(空消息)"
|
||||
}
|
||||
return m.Text
|
||||
}
|
||||
|
||||
func (m Message) formatTimestamp() string {
|
||||
if m.Timestamp.IsZero() {
|
||||
return "未知"
|
||||
}
|
||||
return m.Timestamp.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// Notifier 通知器
|
||||
type Notifier struct {
|
||||
channels []Channel
|
||||
}
|
||||
|
||||
// New 创建通知器
|
||||
func New(cfg *config.Config) (*Notifier, error) {
|
||||
channels := make([]Channel, 0, len(cfg.Channels))
|
||||
|
||||
for _, chCfg := range cfg.Channels {
|
||||
if !chCfg.Enabled {
|
||||
slog.Info("跳过未启用的通道", "type", chCfg.Type)
|
||||
continue
|
||||
}
|
||||
ch, err := createChannel(chCfg)
|
||||
if err != nil {
|
||||
slog.Error("创建通道失败", "type", chCfg.Type, "error", err)
|
||||
continue
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
slog.Info("已加载通道", "type", chCfg.Type)
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
channels: channels,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send 发送通知
|
||||
func (n *Notifier) Send(msg Message) error {
|
||||
if len(n.channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, ch := range n.channels {
|
||||
if err := ch.Send(msg); err != nil {
|
||||
slog.Error("通道发送失败", "type", ch.Type(), "error", err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
slog.Info("通知已发送", "type", ch.Type())
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// createChannel 根据配置创建通道
|
||||
func createChannel(cfg config.ChannelConfig) (Channel, error) {
|
||||
switch cfg.Type {
|
||||
case "email":
|
||||
return NewEmailChannel(cfg)
|
||||
case "bark":
|
||||
return NewBarkChannel(cfg)
|
||||
case "gotify":
|
||||
return NewGotifyChannel(cfg)
|
||||
case "serverchan":
|
||||
return NewServerChanChannel(cfg)
|
||||
case "webhook":
|
||||
return NewWebhookChannel(cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("未知通道类型: %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel 通知通道接口
|
||||
type Channel interface {
|
||||
Type() string
|
||||
Send(msg Message) error
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
serverchan_sdk "github.com/easychen/serverchan-sdk-golang"
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// ServerChanChannel ServerChan 通道
|
||||
type ServerChanChannel struct {
|
||||
cfg config.ChannelConfig
|
||||
}
|
||||
|
||||
// NewServerChanChannel 创建 ServerChan 通道
|
||||
func NewServerChanChannel(cfg config.ChannelConfig) (*ServerChanChannel, error) {
|
||||
if cfg.SendKey == "" {
|
||||
return nil, fmt.Errorf("ServerChan SendKey 未配置")
|
||||
}
|
||||
return &ServerChanChannel{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Type 返回通道类型
|
||||
func (s *ServerChanChannel) Type() string {
|
||||
return "serverchan"
|
||||
}
|
||||
|
||||
// Send 发送 ServerChan 通知
|
||||
func (s *ServerChanChannel) Send(msg Message) error {
|
||||
title := msg.From
|
||||
if title == "" {
|
||||
title = "未知号码"
|
||||
}
|
||||
markdown := msg.String()
|
||||
|
||||
resp, err := serverchan_sdk.ScSend(s.cfg.SendKey, title, markdown, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("ServerChan 返回空响应")
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fmt.Errorf("ServerChan 返回错误: %s", resp.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
)
|
||||
|
||||
// WebhookChannel Webhook 通道
|
||||
type WebhookChannel struct {
|
||||
cfg config.ChannelConfig
|
||||
}
|
||||
|
||||
// NewWebhookChannel 创建 Webhook 通道
|
||||
func NewWebhookChannel(cfg config.ChannelConfig) (*WebhookChannel, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, fmt.Errorf("Webhook URL 未配置")
|
||||
}
|
||||
if cfg.Method == "" {
|
||||
cfg.Method = "POST"
|
||||
}
|
||||
return &WebhookChannel{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
// Type 返回通道类型
|
||||
func (w *WebhookChannel) Type() string {
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
// Send 发送 Webhook 通知
|
||||
func (w *WebhookChannel) Send(msg Message) error {
|
||||
formattedTime := "未知"
|
||||
if !msg.Timestamp.IsZero() {
|
||||
formattedTime = msg.Timestamp.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"time": formattedTime,
|
||||
"from": msg.From,
|
||||
"text": msg.Text,
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if strings.ToUpper(w.cfg.Method) == "GET" {
|
||||
// GET 请求
|
||||
params := url.Values{}
|
||||
params.Set("time", formattedTime)
|
||||
params.Set("from", msg.From)
|
||||
params.Set("text", msg.Text)
|
||||
|
||||
fullURL := w.cfg.URL
|
||||
if strings.Contains(fullURL, "?") {
|
||||
fullURL += "&" + params.Encode()
|
||||
} else {
|
||||
fullURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("GET", fullURL, nil)
|
||||
} else {
|
||||
// POST 请求
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err = http.NewRequest("POST", w.cfg.URL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 添加自定义头
|
||||
for k, v := range w.cfg.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Webhook 返回错误状态: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
"github.com/sms-forwarder/internal/forwarder"
|
||||
"github.com/sms-forwarder/internal/modem"
|
||||
"github.com/sms-forwarder/internal/notifier"
|
||||
"github.com/sms-forwarder/internal/storage"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFiles embed.FS
|
||||
|
||||
// Server Web 服务器
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
forwarder *forwarder.Forwarder
|
||||
storage *storage.Storage
|
||||
server *http.Server
|
||||
configPath string
|
||||
clients map[*websocket.Conn]bool
|
||||
clientsMu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// New 创建服务器
|
||||
func New(cfg *config.Config, fwd *forwarder.Forwarder, store *storage.Storage, configPath string) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
forwarder: fwd,
|
||||
storage: store,
|
||||
configPath: configPath,
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有源
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行服务器
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if !s.cfg.Server.Enabled {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API 路由
|
||||
mux.HandleFunc("/api/modems", s.handleModems)
|
||||
mux.HandleFunc("/api/messages", s.handleMessages)
|
||||
mux.HandleFunc("/api/messages/delete", s.handleDeleteMessage)
|
||||
mux.HandleFunc("/api/channels", s.handleChannels)
|
||||
mux.HandleFunc("/api/channels/save", s.handleSaveChannels)
|
||||
mux.HandleFunc("/api/channels/test", s.handleTestChannel)
|
||||
mux.HandleFunc("/api/ussd", s.handleUSSD)
|
||||
mux.HandleFunc("/ws", s.handleWebSocket)
|
||||
|
||||
// 静态文件 - 使用 web 子目录
|
||||
webFS, err := fs.Sub(webFiles, "web")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mux.Handle("/", http.FileServer(http.FS(webFS)))
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: s.cfg.Server.Listen,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
slog.Info("Web 服务器启动", "listen", s.cfg.Server.Listen)
|
||||
|
||||
// 启动服务器
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("服务器错误", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
// 优雅关闭
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return s.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// ModemInfo 调制解调器信息
|
||||
type ModemInfo struct {
|
||||
IMEI string `json:"imei"`
|
||||
Model string `json:"model"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Number string `json:"number"`
|
||||
SignalQuality uint32 `json:"signal_quality"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
ICCID string `json:"iccid"`
|
||||
}
|
||||
|
||||
// handleModems 处理调制解调器信息请求
|
||||
func (s *Server) handleModems(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
modems := s.forwarder.GetModems()
|
||||
infos := make([]ModemInfo, 0, len(modems))
|
||||
|
||||
for _, modem := range modems {
|
||||
// 更新实时信息
|
||||
modem.UpdateSignalQuality()
|
||||
modem.UpdateOperatorName()
|
||||
modem.UpdateICCID()
|
||||
|
||||
info := ModemInfo{
|
||||
IMEI: modem.EquipmentIdentifier,
|
||||
Model: modem.Model,
|
||||
Manufacturer: modem.Manufacturer,
|
||||
Number: modem.Number,
|
||||
SignalQuality: modem.SignalQuality,
|
||||
OperatorName: modem.OperatorName,
|
||||
ICCID: modem.ICCID,
|
||||
}
|
||||
|
||||
infos = append(infos, info)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(infos)
|
||||
}
|
||||
|
||||
// handleMessages 处理短信列表请求
|
||||
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if s.storage == nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"items": []storage.Message{},
|
||||
"total": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析分页参数
|
||||
query := r.URL.Query()
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l := query.Get("limit"); l != "" {
|
||||
fmt.Sscanf(l, "%d", &limit)
|
||||
}
|
||||
if o := query.Get("offset"); o != "" {
|
||||
fmt.Sscanf(o, "%d", &offset)
|
||||
}
|
||||
|
||||
messages, total := s.storage.ListWithPagination(limit, offset)
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"items": messages,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUSSD 处理 USSD 请求
|
||||
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request) {
|
||||
// Helper to send JSON error
|
||||
sendError := func(w http.ResponseWriter, msg string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IMEI string `json:"imei"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendError(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
modems := s.forwarder.GetModems()
|
||||
var targetModem *modem.Modem
|
||||
for _, m := range modems {
|
||||
if m.EquipmentIdentifier == req.IMEI {
|
||||
targetModem = m
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetModem == nil {
|
||||
sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("执行 USSD", "imei", req.IMEI, "code", req.Code)
|
||||
|
||||
reply, err := targetModem.RunUSSD(req.Code)
|
||||
if err != nil {
|
||||
slog.Error("USSD 执行失败", "error", err)
|
||||
sendError(w, "执行失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"reply": reply,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteMessage 处理删除短信请求
|
||||
func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
http.Error(w, "Storage not enabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.Delete(req.ID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// handleChannels 处理获取通道配置请求
|
||||
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 定义所有预设通道模板
|
||||
channels := []config.ChannelConfig{
|
||||
{
|
||||
Type: "email",
|
||||
Enabled: false,
|
||||
Port: 587,
|
||||
UseTLS: true,
|
||||
},
|
||||
{
|
||||
Type: "bark",
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Type: "gotify",
|
||||
Enabled: false,
|
||||
Priority: 5,
|
||||
},
|
||||
{
|
||||
Type: "serverchan",
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Type: "webhook",
|
||||
Enabled: false,
|
||||
Method: "POST",
|
||||
},
|
||||
}
|
||||
|
||||
// 将配置文件中的值合并到模板中
|
||||
for i := range channels {
|
||||
for j := range s.cfg.Channels {
|
||||
if channels[i].Type == s.cfg.Channels[j].Type {
|
||||
channels[i] = s.cfg.Channels[j]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(channels)
|
||||
}
|
||||
|
||||
// handleSaveChannels 处理保存通道配置请求
|
||||
func (s *Server) handleSaveChannels(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var channels []config.ChannelConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&channels); err != nil {
|
||||
http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 直接保存所有通道(包括未启用的)
|
||||
s.cfg.Channels = channels
|
||||
|
||||
// 保存到配置文件
|
||||
if err := s.cfg.Save(s.configPath); err != nil {
|
||||
http.Error(w, "Failed to save config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// handleTestChannel 处理测试通道请求
|
||||
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var channels []config.ChannelConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&channels); err != nil {
|
||||
http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建通知发送器(包含所有要测试的通道)
|
||||
testCfg := &config.Config{
|
||||
Channels: channels,
|
||||
}
|
||||
n, err := notifier.New(testCfg)
|
||||
if err != nil {
|
||||
http.Error(w, "创建通知器失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建测试消息
|
||||
testMsg := notifier.Message{
|
||||
Modem: "测试设备",
|
||||
From: "测试号码",
|
||||
Text: "这是一条测试推送消息,如果您收到此消息,说明推送通道配置正确。",
|
||||
Timestamp: time.Now(),
|
||||
Incoming: true,
|
||||
}
|
||||
|
||||
// 发送测试消息
|
||||
if err := n.Send(testMsg); err != nil {
|
||||
http.Error(w, "测试失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "测试消息已发送",
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocket 处理 WebSocket 连接
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("WebSocket 升级失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 注册客户端
|
||||
s.clientsMu.Lock()
|
||||
s.clients[conn] = true
|
||||
s.clientsMu.Unlock()
|
||||
|
||||
slog.Info("WebSocket 客户端连接", "remote", r.RemoteAddr)
|
||||
|
||||
// 客户端断开时清理
|
||||
defer func() {
|
||||
s.clientsMu.Lock()
|
||||
delete(s.clients, conn)
|
||||
s.clientsMu.Unlock()
|
||||
conn.Close()
|
||||
slog.Info("WebSocket 客户端断开", "remote", r.RemoteAddr)
|
||||
}()
|
||||
|
||||
// 保持连接并处理 ping/pong
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastMessage 广播新消息给所有 WebSocket 客户端
|
||||
func (s *Server) BroadcastMessage(msg storage.Message) {
|
||||
s.clientsMu.RLock()
|
||||
defer s.clientsMu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(map[string]interface{}{
|
||||
"type": "new_message",
|
||||
"message": msg,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("序列化消息失败", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for client := range s.clients {
|
||||
err := client.WriteMessage(websocket.TextMessage, data)
|
||||
if err != nil {
|
||||
slog.Error("发送 WebSocket 消息失败", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sms-forwarder/internal/modem"
|
||||
)
|
||||
|
||||
// Message 存储的短信消息
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Modem string `json:"modem"` // 调制解调器 IMEI
|
||||
Number string `json:"number"` // 发送方号码
|
||||
Text string `json:"text"` // 短信内容
|
||||
Timestamp time.Time `json:"timestamp"` // 接收时间
|
||||
Saved time.Time `json:"saved"` // 保存时间
|
||||
}
|
||||
|
||||
// Storage 短信存储
|
||||
type Storage struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
messages []Message
|
||||
ids map[string]bool // 用于快速查重
|
||||
messageHandler func(Message) // 新消息回调
|
||||
}
|
||||
|
||||
// New 创建存储实例
|
||||
func New(path string) (*Storage, error) {
|
||||
s := &Storage{
|
||||
path: path,
|
||||
messages: []Message{},
|
||||
ids: make(map[string]bool),
|
||||
}
|
||||
|
||||
// 尝试加载已有数据
|
||||
if err := s.load(); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("加载存储文件: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GenerateID 生成消息唯一 ID
|
||||
func GenerateID(modemIMEI string, sms *modem.SMS) string {
|
||||
// 使用 MD5(IMEI + Timestamp + Number + Text) 作为唯一 ID
|
||||
// 这样即使同一秒收到两方发来的消息(或者同一方发的两条不同消息),也能区分
|
||||
data := fmt.Sprintf("%s|%d|%s|%s", modemIMEI, sms.Timestamp.Unix(), sms.Number, sms.Text)
|
||||
hash := md5.Sum([]byte(data))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Save 保存短信
|
||||
func (s *Storage) Save(modemIMEI string, sms *modem.SMS) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id := GenerateID(modemIMEI, sms)
|
||||
|
||||
// 检查是否已存在
|
||||
if s.ids[id] {
|
||||
return nil // 已存在,跳过
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
ID: id,
|
||||
Modem: modemIMEI,
|
||||
Number: sms.Number,
|
||||
Text: sms.Text,
|
||||
Timestamp: sms.Timestamp,
|
||||
Saved: time.Now(),
|
||||
}
|
||||
|
||||
s.messages = append(s.messages, msg)
|
||||
s.ids[id] = true
|
||||
|
||||
// 触发新消息回调(在解锁后执行,避免阻塞)
|
||||
// 注意:为了简单起见,这里仍在持有锁的情况下启动 goroutine,但 goroutine 内部不应再请求此锁
|
||||
if s.messageHandler != nil {
|
||||
go s.messageHandler(msg)
|
||||
}
|
||||
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Has 检查消息是否存在
|
||||
func (s *Storage) Has(modemIMEI string, sms *modem.SMS) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id := GenerateID(modemIMEI, sms)
|
||||
return s.ids[id]
|
||||
}
|
||||
|
||||
// SetMessageHandler 设置新消息处理器
|
||||
func (s *Storage) SetMessageHandler(handler func(Message)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.messageHandler = handler
|
||||
}
|
||||
|
||||
// List 列出所有短信
|
||||
func (s *Storage) List() []Message {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
result := make([]Message, len(s.messages))
|
||||
copy(result, s.messages)
|
||||
return result
|
||||
}
|
||||
|
||||
// ListWithPagination 分页获取短信 (按时间倒序)
|
||||
func (s *Storage) ListWithPagination(limit, offset int) ([]Message, int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
total := int64(len(s.messages))
|
||||
if total == 0 {
|
||||
return []Message{}, 0
|
||||
}
|
||||
|
||||
// 既然需要倒序(最新的在前),offset=0 代表 slice 的最后一个元素
|
||||
// index = len - 1 - offset
|
||||
start := int64(len(s.messages)) - 1 - int64(offset)
|
||||
if start < 0 {
|
||||
return []Message{}, total
|
||||
}
|
||||
|
||||
end := start - int64(limit) + 1
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
count := start - end + 1
|
||||
result := make([]Message, 0, count)
|
||||
for i := start; i >= end; i-- {
|
||||
result = append(result, s.messages[i])
|
||||
}
|
||||
|
||||
return result, total
|
||||
}
|
||||
|
||||
// Delete 删除短信
|
||||
func (s *Storage) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, msg := range s.messages {
|
||||
if msg.ID == id {
|
||||
// 删除该消息
|
||||
s.messages = append(s.messages[:i], s.messages[i+1:]...)
|
||||
delete(s.ids, id)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("消息不存在")
|
||||
}
|
||||
|
||||
// load 从文件加载
|
||||
func (s *Storage) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &s.messages); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重建索引
|
||||
s.ids = make(map[string]bool)
|
||||
for _, msg := range s.messages {
|
||||
s.ids[msg.ID] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save 保存到文件
|
||||
func (s *Storage) save() error {
|
||||
data, err := json.MarshalIndent(s.messages, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
|
||||
// Close 关闭存储
|
||||
func (s *Storage) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/sms-forwarder/internal/config"
|
||||
"github.com/sms-forwarder/internal/forwarder"
|
||||
"github.com/sms-forwarder/internal/modem"
|
||||
"github.com/sms-forwarder/internal/server"
|
||||
"github.com/sms-forwarder/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configFile := flag.String("config", "config.toml", "配置文件路径")
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(*configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化调制解调器管理器
|
||||
manager, err := modem.NewManager()
|
||||
if err != nil {
|
||||
log.Fatalf("初始化 ModemManager 失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化存储
|
||||
var store *storage.Storage
|
||||
if cfg.Storage.Enabled {
|
||||
store, err = storage.New(cfg.Storage.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化存储失败: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
}
|
||||
|
||||
// 初始化转发器
|
||||
fwd, err := forwarder.New(cfg, manager, store)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化转发器失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化 Web 服务器
|
||||
srv := server.New(cfg, fwd, store, *configFile)
|
||||
|
||||
// 设置新消息处理器
|
||||
if store != nil {
|
||||
store.SetMessageHandler(func(msg storage.Message) {
|
||||
srv.BroadcastMessage(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// 创建上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 启动转发器
|
||||
go func() {
|
||||
if err := fwd.Run(ctx); err != nil {
|
||||
slog.Error("转发器运行错误", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 启动 Web 服务器
|
||||
go func() {
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
slog.Error("服务器运行错误", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("SMS 转发器已启动")
|
||||
|
||||
// 等待信号
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
slog.Info("收到停止信号,正在关闭...")
|
||||
cancel()
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user