├── .github └── workflows │ └── build.yml ├── .gitignore ├── .goreleaser.yaml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── cmd └── super-sms-bridge │ └── main.go ├── config.example.yaml ├── docs ├── eng-demo.png └── eng-sms-forwarder-setup.png ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ └── loader.go ├── handler │ ├── http.go │ └── websocket.go ├── service │ └── types.go └── telegram │ ├── client.go │ └── topic_cache.go └── pkg └── utils └── sign.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | branches: 8 | - '**' 9 | pull_request: 10 | 11 | permissions: 12 | contents: write 13 | id-token: write 14 | packages: write 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: 1.23.3 28 | 29 | - name: Build 30 | if: ${{ github.ref_type != 'tag' }} 31 | uses: goreleaser/goreleaser-action@v5 32 | with: 33 | version: latest 34 | args: build --clean --snapshot 35 | 36 | - name: Build 37 | if: ${{ github.ref_type == 'tag' }} 38 | uses: goreleaser/goreleaser-action@v5 39 | with: 40 | version: latest 41 | args: release --clean 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Upload Artifacts (for non-tag builds) 46 | if: ${{ github.ref_type != 'tag' }} 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: built-binaries 50 | path: dist/* 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | # End of https://www.toptal.com/developers/gitignore/api/go 28 | 29 | # Config file 30 | config.yaml 31 | 32 | # Data directory 33 | data/ 34 | 35 | # goreleaser 36 | dist/ 37 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: super-sms-bridge 2 | 3 | builds: 4 | - id: default 5 | main: ./cmd/super-sms-bridge 6 | binary: super-sms-bridge 7 | goos: 8 | - linux 9 | - darwin 10 | - windows 11 | goarch: 12 | - amd64 13 | - arm64 14 | env: 15 | - CGO_ENABLED=0 16 | ldflags: "-s -w" 17 | 18 | archives: 19 | - id: default 20 | format: zip 21 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 22 | files: 23 | - LICENSE 24 | - README.md 25 | - config.example.yaml 26 | 27 | checksum: 28 | name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" 29 | 30 | release: 31 | github: 32 | owner: PA733 33 | name: SuperSMSBridge 34 | 35 | snapshot: 36 | name_template: "{{ .Tag }}-dev" 37 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${workspaceFolder}/cmd/super-sms-bridge/", 13 | "cwd": "${workspaceFolder}" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Super SMS Bridge 2 | A middleware for forwarding SMS to Telegram using Super Chat. Compatible with SmsForwarder Webhook. 3 | 4 | ![demo](./docs/eng-demo.png) 5 | 6 | ## SmsForwarder Compatible 7 | 8 | > [!WARNING] 9 | > SMS Reply is not available due to SmsForwarder's restrictions. 10 | 11 | **Params** 12 | ```json 13 | { 14 | "sender": "[from]", 15 | "text": "[org_content]", 16 | "timestamp": "[timestamp]", 17 | "sign": "[sign]" 18 | } 19 | ``` 20 | 21 | ![setup](./docs/eng-sms-forwarder-setup.png) -------------------------------------------------------------------------------- /cmd/super-sms-bridge/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "super-sms-bridge/internal/config" 10 | "super-sms-bridge/internal/handler" 11 | "super-sms-bridge/internal/telegram" 12 | 13 | "github.com/soheilhy/cmux" 14 | ) 15 | 16 | func startServer(addr string, handler http.Handler, isTLS bool, certFile, keyFile string) error { 17 | if isTLS { 18 | return http.ListenAndServeTLS(addr, certFile, keyFile, handler) 19 | } 20 | return http.ListenAndServe(addr, handler) 21 | } 22 | 23 | func main() { 24 | configPath := flag.String("config", "config.yaml", "配置文件路径") 25 | dataDir := flag.String("data-dir", "./data", "数据目录路径") 26 | flag.Parse() 27 | 28 | // 加载配置 29 | cfg, err := config.LoadConfig(*configPath) 30 | if err != nil { 31 | log.Fatalf("加载配置失败: %v", err) 32 | } 33 | 34 | // 验证配置 35 | if err := cfg.Validate(); err != nil { 36 | log.Fatalf("配置验证失败: %v", err) 37 | } 38 | 39 | // 初始化Telegram客户端 40 | tg, err := telegram.NewClient( 41 | cfg.Telegram.BotToken, 42 | cfg.Telegram.TargetGroupID, 43 | *dataDir, 44 | ) 45 | if err != nil { 46 | log.Fatalf("初始化Telegram客户端失败: %v", err) 47 | } 48 | 49 | // 初始化HTTP和WebSocket处理器 50 | httpHandler := handler.NewHTTPHandler(tg, cfg.HTTP.SecretKey) 51 | wsHandler := handler.NewWSHandler(tg, cfg.WS.SecretKey) 52 | 53 | // 创建路由 54 | mux := http.NewServeMux() 55 | mux.HandleFunc("/message", httpHandler.HandleMessage) 56 | mux.HandleFunc("/ws", wsHandler.HandleWebSocket) 57 | 58 | // 判断是否需要复用端口 59 | if cfg.HTTP.Enabled && cfg.WS.Enabled && cfg.HTTP.Port == cfg.WS.Port { 60 | // 使用cmux复用端口 61 | addr := fmt.Sprintf(":%d", cfg.HTTP.Port) 62 | listener, err := net.Listen("tcp", addr) 63 | if err != nil { 64 | log.Fatalf("创建监听失败: %v", err) 65 | } 66 | 67 | m := cmux.New(listener) 68 | 69 | // 匹配WebSocket连接 70 | wsListener := m.Match(cmux.HTTP1HeaderField("Upgrade", "websocket")) 71 | // 匹配HTTP连接 72 | httpListener := m.Match(cmux.Any()) 73 | 74 | // 创建HTTP服务 75 | httpServer := &http.Server{ 76 | Handler: mux, 77 | } 78 | 79 | log.Printf("在端口 %d 启动复用服务器", cfg.HTTP.Port) 80 | 81 | // 启动服务 82 | go func() { 83 | if err := httpServer.Serve(httpListener); err != nil { 84 | log.Printf("HTTP服务错误: %v", err) 85 | } 86 | }() 87 | 88 | go func() { 89 | if err := httpServer.Serve(wsListener); err != nil { 90 | log.Printf("WebSocket服务错误: %v", err) 91 | } 92 | }() 93 | 94 | // 启动cmux 95 | if err := m.Serve(); err != nil { 96 | log.Fatalf("cmux.Serve() 错误: %v", err) 97 | } 98 | } else { 99 | // 分别启动HTTP和WebSocket服务 100 | if cfg.HTTP.Enabled { 101 | go func() { 102 | addr := fmt.Sprintf(":%d", cfg.HTTP.Port) 103 | log.Printf("启动HTTP服务在端口 %d", cfg.HTTP.Port) 104 | err := startServer(addr, mux, cfg.HTTP.TLS.Cert != "" && cfg.HTTP.TLS.Key != "", 105 | cfg.HTTP.TLS.Cert, cfg.HTTP.TLS.Key) 106 | if err != nil { 107 | log.Printf("HTTP服务错误: %v", err) 108 | } 109 | }() 110 | } 111 | 112 | if cfg.WS.Enabled { 113 | go func() { 114 | addr := fmt.Sprintf(":%d", cfg.WS.Port) 115 | log.Printf("启动WebSocket服务在端口 %d", cfg.WS.Port) 116 | err := startServer(addr, mux, cfg.WS.TLS.Cert != "" && cfg.WS.TLS.Key != "", 117 | cfg.WS.TLS.Cert, cfg.WS.TLS.Key) 118 | if err != nil { 119 | log.Printf("WebSocket服务错误: %v", err) 120 | } 121 | }() 122 | } 123 | 124 | // 保持主程序运行 125 | select {} 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | enabled: true 3 | port: 8080 4 | secret_key: "123456" 5 | tls: 6 | cert: "Path, leave it blank to disable the internel SSL/TLS support" 7 | key: "/path/to/cert.key" 8 | 9 | ws: 10 | enabled: true 11 | port: 8080 12 | secret_key: "123456" 13 | tls: 14 | cert: "" 15 | key: "" 16 | 17 | telegram: 18 | botToken: "123456:abcdefg" 19 | targetGroupId: -100 20 | -------------------------------------------------------------------------------- /docs/eng-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PA733/SuperSMSBridge/d4708dbc409e9e7a8295f43afcbfda973c742ece/docs/eng-demo.png -------------------------------------------------------------------------------- /docs/eng-sms-forwarder-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PA733/SuperSMSBridge/d4708dbc409e9e7a8295f43afcbfda973c742ece/docs/eng-sms-forwarder-setup.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module super-sms-bridge 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada 7 | github.com/gorilla/websocket v1.5.3 8 | github.com/soheilhy/cmux v0.1.5 9 | gopkg.in/yaml.v2 v2.4.0 10 | ) 11 | 12 | require ( 13 | golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb // indirect 14 | golang.org/x/text v0.3.3 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada h1:5ZtieioZyyfiJsGvjpj3d5Eso/3YjJJhNQ1M8at5U5k= 2 | github.com/OvyFlash/telegram-bot-api v0.0.0-20241219171906-3f2ca0c14ada/go.mod h1:2nRUdsKyWhvezqW/rBGWEQdcTQeTtnbSNd2dgx76WYA= 3 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 4 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= 6 | github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= 7 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 8 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 9 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 10 | golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb h1:eBmm0M9fYhWpKZLjQUUKka/LtIxf46G4fxeEz5KJr9U= 11 | golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 12 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 13 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 16 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 17 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 18 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 22 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 23 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | HTTP HTTPConfig `yaml:"http"` 5 | WS WebSocketConfig `yaml:"ws"` 6 | Telegram TelegramConfig `yaml:"telegram"` 7 | } 8 | 9 | type HTTPConfig struct { 10 | Enabled bool `yaml:"enabled"` 11 | Port int `yaml:"port"` 12 | SecretKey string `yaml:"secret_key"` 13 | TLS TLSConfig `yaml:"tls"` 14 | } 15 | 16 | type WebSocketConfig struct { 17 | Enabled bool `yaml:"enabled"` 18 | Port int `yaml:"port"` 19 | SecretKey string `yaml:"secret_key"` 20 | TLS TLSConfig `yaml:"tls"` 21 | } 22 | 23 | type TLSConfig struct { 24 | Cert string `yaml:"cert"` 25 | Key string `yaml:"key"` 26 | } 27 | 28 | type TelegramConfig struct { 29 | BotToken string `yaml:"botToken"` 30 | TargetGroupID int64 `yaml:"targetGroupId"` 31 | } 32 | 33 | // NewConfig 返回默认配置 34 | func NewConfig() *Config { 35 | return &Config{ 36 | HTTP: HTTPConfig{ 37 | Enabled: true, 38 | Port: 8080, 39 | }, 40 | WS: WebSocketConfig{ 41 | Enabled: true, 42 | Port: 8080, 43 | }, 44 | Telegram: TelegramConfig{}, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/config/loader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "gopkg.in/yaml.v2" 8 | ) 9 | 10 | // LoadConfig 从文件加载配置 11 | func LoadConfig(path string) (*Config, error) { 12 | config := NewConfig() 13 | 14 | // 读取配置文件 15 | data, err := os.ReadFile(path) 16 | if err != nil { 17 | return nil, fmt.Errorf("读取配置文件失败: %w", err) 18 | } 19 | 20 | // 解析YAML 21 | if err := yaml.Unmarshal(data, config); err != nil { 22 | return nil, fmt.Errorf("解析配置文件失败: %w", err) 23 | } 24 | 25 | // 从环境变量覆盖配置 26 | if token := os.Getenv("TELEGRAM_BOT_TOKEN"); token != "" { 27 | config.Telegram.BotToken = token 28 | } 29 | 30 | return config, nil 31 | } 32 | 33 | // Validate 验证配置是否有效 34 | func (c *Config) Validate() error { 35 | if c.Telegram.BotToken == "" { 36 | return fmt.Errorf("未设置Telegram Bot Token") 37 | } 38 | if c.Telegram.TargetGroupID == 0 { 39 | return fmt.Errorf("未设置目标群组ID") 40 | } 41 | 42 | // 如果HTTP和WS都启用且端口相同,检查TLS配置是否一致 43 | if c.HTTP.Enabled && c.WS.Enabled && c.HTTP.Port == c.WS.Port { 44 | if c.HTTP.TLS.Cert != c.WS.TLS.Cert || c.HTTP.TLS.Key != c.WS.TLS.Key { 45 | return fmt.Errorf("HTTP和WebSocket使用相同端口时必须使用相同的TLS配置") 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/handler/http.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "super-sms-bridge/internal/service" 8 | "super-sms-bridge/internal/telegram" 9 | "super-sms-bridge/pkg/utils" 10 | ) 11 | 12 | type HTTPHandler struct { 13 | tg *telegram.Client 14 | secret string // 用于验证签名的密钥 15 | } 16 | 17 | func NewHTTPHandler(tg *telegram.Client, secret string) *HTTPHandler { 18 | return &HTTPHandler{ 19 | tg: tg, 20 | secret: secret, 21 | } 22 | } 23 | 24 | func (h *HTTPHandler) HandleMessage(w http.ResponseWriter, r *http.Request) { 25 | if r.Method != http.MethodPost { 26 | writeJSON(w, &service.Response{ 27 | Code: http.StatusMethodNotAllowed, 28 | Message: "仅支持POST请求", 29 | }) 30 | return 31 | } 32 | 33 | // 输出请求体 34 | body, err := io.ReadAll(r.Body) 35 | if err != nil { 36 | http.Error(w, "Unable to read body", http.StatusBadRequest) 37 | return 38 | } 39 | defer r.Body.Close() 40 | // log.Printf("Body: %s", string(body)) 41 | 42 | var msg service.Message 43 | if err := json.Unmarshal(body, &msg); err != nil { 44 | writeJSON(w, &service.Response{ 45 | Code: http.StatusBadRequest, 46 | Message: "请求格式错误", 47 | }) 48 | return 49 | } 50 | 51 | // 验证签名 52 | if !utils.ValidateSign(msg.TimeStamp, msg.Sign, h.secret) { 53 | writeJSON(w, &service.Response{ 54 | Code: http.StatusUnauthorized, 55 | Message: "签名验证失败", 56 | }) 57 | return 58 | } 59 | 60 | // 发送消息到Telegram 61 | if err := h.tg.SendMessage(msg.Sender, msg.Text); err != nil { 62 | writeJSON(w, &service.Response{ 63 | Code: http.StatusInternalServerError, 64 | Message: "发送消息失败: " + err.Error(), 65 | }) 66 | return 67 | } 68 | 69 | writeJSON(w, &service.Response{ 70 | Code: 0, 71 | Message: "发送成功", 72 | }) 73 | } 74 | 75 | func writeJSON(w http.ResponseWriter, resp *service.Response) { 76 | w.Header().Set("Content-Type", "application/json") 77 | w.WriteHeader(http.StatusOK) 78 | json.NewEncoder(w).Encode(resp) 79 | } 80 | -------------------------------------------------------------------------------- /internal/handler/websocket.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "super-sms-bridge/internal/service" 10 | "super-sms-bridge/internal/telegram" 11 | "super-sms-bridge/pkg/utils" 12 | 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | var upgrader = websocket.Upgrader{ 17 | ReadBufferSize: 1024, 18 | WriteBufferSize: 1024, 19 | CheckOrigin: func(r *http.Request) bool { 20 | return true // 允许所有来源,生产环境中应该更严格 21 | }, 22 | } 23 | 24 | type WSHandler struct { 25 | tg *telegram.Client 26 | secret string 27 | clients sync.Map // 存储活跃的WebSocket连接,map[string]*websocket.Conn 28 | } 29 | 30 | type WSMessage struct { 31 | Action string `json:"action"` 32 | Payload service.Message `json:"payload"` 33 | } 34 | 35 | func NewWSHandler(tg *telegram.Client, secret string) *WSHandler { 36 | return &WSHandler{ 37 | tg: tg, 38 | secret: secret, 39 | } 40 | } 41 | 42 | func (h *WSHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) { 43 | // 升级HTTP连接为WebSocket 44 | conn, err := upgrader.Upgrade(w, r, nil) 45 | if err != nil { 46 | log.Printf("WebSocket升级失败: %v", err) 47 | return 48 | } 49 | 50 | // 设置连接关闭处理 51 | defer func() { 52 | conn.Close() 53 | }() 54 | 55 | // 设置读取超时 56 | conn.SetReadDeadline(time.Now().Add(60 * time.Second)) 57 | conn.SetPongHandler(func(string) error { 58 | conn.SetReadDeadline(time.Now().Add(60 * time.Second)) 59 | return nil 60 | }) 61 | 62 | // 启动心跳检测 63 | go h.ping(conn) 64 | 65 | // 处理接收到的消息 66 | for { 67 | var wsMsg WSMessage 68 | err := conn.ReadJSON(&wsMsg) 69 | if err != nil { 70 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 71 | log.Printf("WebSocket读取错误: %v", err) 72 | } 73 | break 74 | } 75 | 76 | // 处理消息 77 | switch wsMsg.Action { 78 | case "send_message": 79 | h.handleSendMessage(conn, &wsMsg.Payload) 80 | default: 81 | log.Printf("未知的操作类型: %s", wsMsg.Action) 82 | } 83 | } 84 | } 85 | 86 | func (h *WSHandler) handleSendMessage(conn *websocket.Conn, msg *service.Message) { 87 | response := &service.Response{} 88 | 89 | // 验证签名 90 | if !utils.ValidateSign(msg.TimeStamp, msg.Sign, h.secret) { 91 | response.Code = http.StatusUnauthorized 92 | response.Message = "签名验证失败" 93 | conn.WriteJSON(response) 94 | return 95 | } 96 | 97 | // 发送消息到Telegram 98 | if err := h.tg.SendMessage(msg.Sender, msg.Text); err != nil { 99 | response.Code = http.StatusInternalServerError 100 | response.Message = "发送消息失败: " + err.Error() 101 | conn.WriteJSON(response) 102 | return 103 | } 104 | 105 | // 存储连接,用于后续推送消息 106 | h.clients.Store(msg.Sender, conn) 107 | 108 | response.Code = 0 109 | response.Message = "发送成功" 110 | conn.WriteJSON(response) 111 | } 112 | 113 | // 心跳检测 114 | func (h *WSHandler) ping(conn *websocket.Conn) { 115 | ticker := time.NewTicker(30 * time.Second) 116 | defer ticker.Stop() 117 | 118 | for range ticker.C { 119 | if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)); err != nil { 120 | log.Printf("发送ping失败: %v", err) 121 | return 122 | } 123 | } 124 | } 125 | 126 | // PushMessage 推送消息给指定的sender 127 | func (h *WSHandler) PushMessage(target string, text string) { 128 | if conn, ok := h.clients.Load(target); ok { 129 | wsConn := conn.(*websocket.Conn) 130 | pushMsg := struct { 131 | Action string `json:"action"` 132 | Payload struct { 133 | Target string `json:"target"` 134 | Text string `json:"text"` 135 | } `json:"payload"` 136 | }{ 137 | Action: "push_message", 138 | Payload: struct { 139 | Target string `json:"target"` 140 | Text string `json:"text"` 141 | }{ 142 | Target: target, 143 | Text: text, 144 | }, 145 | } 146 | 147 | if err := wsConn.WriteJSON(pushMsg); err != nil { 148 | log.Printf("推送消息失败: %v", err) 149 | h.clients.Delete(target) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/service/types.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | // Message 表示一个消息请求 4 | type Message struct { 5 | Sender string `json:"sender"` 6 | Text string `json:"text"` 7 | TimeStamp string `json:"timestamp"` 8 | Sign string `json:"sign"` 9 | } 10 | 11 | // Response 表示通用的响应格式 12 | type Response struct { 13 | Code int `json:"code"` 14 | Message string `json:"message"` 15 | Data interface{} `json:"data,omitempty"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/telegram/client.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | tgbotapi "github.com/OvyFlash/telegram-bot-api" 8 | ) 9 | 10 | type Client struct { 11 | bot *tgbotapi.BotAPI 12 | groupID int64 13 | cache *TopicCache 14 | } 15 | 16 | func NewClient(token string, groupID int64, dataDir string) (*Client, error) { 17 | bot, err := tgbotapi.NewBotAPI(token) 18 | if err != nil { 19 | return nil, fmt.Errorf("初始化Telegram Bot失败: %w", err) 20 | } 21 | 22 | cache, err := NewTopicCache(dataDir) 23 | if err != nil { 24 | return nil, fmt.Errorf("初始化 Topic 缓存失败: %w", err) 25 | } 26 | 27 | return &Client{ 28 | bot: bot, 29 | groupID: groupID, 30 | cache: cache, 31 | }, nil 32 | } 33 | 34 | // getOrCreateTopic 获取或创建topic 35 | func (c *Client) getOrCreateTopic(sender string) (int, error) { 36 | // 先从缓存中查找 37 | if topicID, exists := c.cache.GetTopicID(c.groupID, sender); exists { 38 | return topicID, nil 39 | } 40 | 41 | // 创建新topic 42 | createConfig := tgbotapi.CreateForumTopicConfig{ 43 | ChatConfig: tgbotapi.ChatConfig{ChatID: c.groupID}, 44 | Name: sender, 45 | } 46 | 47 | msg, err := c.bot.Send(createConfig) 48 | if err != nil { 49 | return 0, fmt.Errorf("创建topic失败: %w", err) 50 | } 51 | 52 | // 保存到缓存 53 | topicID := msg.MessageThreadID 54 | if err := c.cache.SetTopicID(c.groupID, sender, topicID); err != nil { 55 | log.Printf("警告: 保存topic缓存失败: %v", err) 56 | } 57 | 58 | return topicID, nil 59 | } 60 | 61 | // SendMessage 发送消息到指定sender对应的topic 62 | func (c *Client) SendMessage(sender, text string) error { 63 | topicID, err := c.getOrCreateTopic(sender) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | msg := tgbotapi.NewMessage(c.groupID, text) 69 | msg.MessageThreadID = topicID 70 | 71 | _, err = c.bot.Send(msg) 72 | if err != nil { 73 | return fmt.Errorf("发送消息失败: %w", err) 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/telegram/topic_cache.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | ) 10 | 11 | type TopicCache struct { 12 | GroupTopics map[int64]map[string]int `json:"group_topics"` // groupID -> sender -> topicID 13 | mutex sync.RWMutex 14 | filePath string 15 | } 16 | 17 | func NewTopicCache(cacheDir string) (*TopicCache, error) { 18 | if err := os.MkdirAll(cacheDir, 0755); err != nil { 19 | return nil, fmt.Errorf("创建数据目录失败: %w", err) 20 | } 21 | 22 | filePath := filepath.Join(cacheDir, "topic_cache.json") 23 | cache := &TopicCache{ 24 | GroupTopics: make(map[int64]map[string]int), 25 | filePath: filePath, 26 | } 27 | 28 | // 尝试加载现有缓存 29 | if err := cache.load(); err != nil { 30 | // 如果文件不存在,使用空缓存 31 | if !os.IsNotExist(err) { 32 | return nil, fmt.Errorf("加载缓存失败: %w", err) 33 | } 34 | } 35 | 36 | return cache, nil 37 | } 38 | 39 | func (c *TopicCache) GetTopicID(groupID int64, sender string) (int, bool) { 40 | c.mutex.RLock() 41 | defer c.mutex.RUnlock() 42 | 43 | if topics, ok := c.GroupTopics[groupID]; ok { 44 | if topicID, exists := topics[sender]; exists { 45 | return topicID, true 46 | } 47 | } 48 | return 0, false 49 | } 50 | 51 | func (c *TopicCache) SetTopicID(groupID int64, sender string, topicID int) error { 52 | c.mutex.Lock() 53 | defer c.mutex.Unlock() 54 | 55 | if _, ok := c.GroupTopics[groupID]; !ok { 56 | c.GroupTopics[groupID] = make(map[string]int) 57 | } 58 | c.GroupTopics[groupID][sender] = topicID 59 | 60 | return c.save() 61 | } 62 | 63 | func (c *TopicCache) load() error { 64 | data, err := os.ReadFile(c.filePath) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | c.mutex.Lock() 70 | defer c.mutex.Unlock() 71 | 72 | return json.Unmarshal(data, &c.GroupTopics) 73 | } 74 | 75 | func (c *TopicCache) save() error { 76 | data, err := json.MarshalIndent(c.GroupTopics, "", " ") 77 | if err != nil { 78 | return fmt.Errorf("序列化缓存失败: %w", err) 79 | } 80 | 81 | return os.WriteFile(c.filePath, data, 0644) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/utils/sign.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "encoding/base64" 7 | "net/url" 8 | ) 9 | 10 | // ValidateSign 验证签名 11 | // timestamp: 时间戳 12 | // sign: 已经 URL 编码的签名 13 | // secret: 密钥 14 | func ValidateSign(timestamp, sign, secret string) bool { 15 | // 1. URL decode签名 16 | decodedSign, err := url.QueryUnescape(sign) 17 | if err != nil { 18 | return false 19 | } 20 | 21 | // 2. 计算预期的签名 22 | signStr := timestamp + "\n" + secret 23 | h := hmac.New(sha256.New, []byte(secret)) 24 | h.Write([]byte(signStr)) 25 | 26 | // 3. Base64 编码 27 | expectedSign := base64.StdEncoding.EncodeToString(h.Sum(nil)) 28 | 29 | return decodedSign == expectedSign 30 | } 31 | 32 | // GenerateSign 生成签名 (用于测试) 33 | func GenerateSign(timestamp, secret string) string { 34 | // 1. 计算签名 35 | signStr := timestamp + "\n" + secret 36 | h := hmac.New(sha256.New, []byte(secret)) 37 | h.Write([]byte(signStr)) 38 | 39 | // 2. Base64 编码 40 | b64Sign := base64.StdEncoding.EncodeToString(h.Sum(nil)) 41 | 42 | // 3. URL 编码 43 | return url.QueryEscape(b64Sign) 44 | } 45 | --------------------------------------------------------------------------------