diff --git a/models/send_ins.go b/models/send_ins.go index b1e3b0d..db3afdd 100644 --- a/models/send_ins.go +++ b/models/send_ins.go @@ -51,6 +51,10 @@ type InsAliyunSMSConfig struct { TemplateCode string `json:"template_code" validate:"required" label:"短信模板CODE"` } +// InsTelegramConfig 实例里面的Telegram config +type InsTelegramConfig struct { +} + // ManyAddTaskIns 批量添加实例 func ManyAddTaskIns(taskIns []SendTasksIns) error { tx := db.Begin() diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index a3f00a6..1d98002 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -64,6 +64,7 @@ const ( MessageTypeWeChatOFAccount = "WeChatOFAccount" MessageTypeMessageNest = "MessageNest" MessageTypeAliyunSMS = "AliyunSMS" + MessageTypeTelegram = "Telegram" ) // 限制goroutine的最大数量 diff --git a/pkg/message/telegram.go b/pkg/message/telegram.go new file mode 100644 index 0000000..ec2689a --- /dev/null +++ b/pkg/message/telegram.go @@ -0,0 +1,181 @@ +package message + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +type telegramResponse struct { + Ok bool `json:"ok"` + Description string `json:"description"` +} + +type Telegram struct { + BotToken string + ChatID string + ApiHost string // 可选的自定义API地址(优先级最高) + ProxyURL string // 可选的代理地址,支持 http://、https://、socks5:// 格式 +} + +func (t *Telegram) Request(params map[string]interface{}) ([]byte, error) { + apiURL := t.getAPIURL() + + // 构建请求体 + data := url.Values{} + for key, value := range params { + data.Set(key, fmt.Sprintf("%v", value)) + } + + // 创建 HTTP 客户端 + client := t.getHTTPClient() + + resp, err := client.Post(apiURL, "application/x-www-form-urlencoded", bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + // 忽略关闭错误 + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r telegramResponse + err = json.Unmarshal(body, &r) + if err != nil { + return body, err + } + + if !r.Ok { + return body, errors.New(fmt.Sprintf("telegram api error: %s", r.Description)) + } + + return body, nil +} + +// SendMessageText 发送文本消息 +func (t *Telegram) SendMessageText(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageMarkdown 发送Markdown格式消息 +func (t *Telegram) SendMessageMarkdown(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "Markdown", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +// SendMessageHTML 发送HTML格式消息 +func (t *Telegram) SendMessageHTML(text string) ([]byte, error) { + params := map[string]interface{}{ + "chat_id": t.ChatID, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": "true", + } + + return t.Request(params) +} + +func (t *Telegram) getAPIURL() string { + // 自定义 API 地址优先级最高 + if t.ApiHost != "" { + return fmt.Sprintf("%s/bot%s/sendMessage", t.ApiHost, t.BotToken) + } + return fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken) +} + +// getHTTPClient 获取配置了代理的 HTTP 客户端 +func (t *Telegram) getHTTPClient() *http.Client { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 如果配置了代理且没有自定义 API 地址,则使用代理 + // 自定义 API 地址优先级更高,通常用于自建代理服务器 + if t.ProxyURL != "" && t.ApiHost == "" { + proxyURL, err := url.Parse(t.ProxyURL) + if err == nil { + // 判断是否为 SOCKS5 代理 + if strings.HasPrefix(strings.ToLower(t.ProxyURL), "socks5://") { + // 使用 SOCKS5 代理 + dialer, err := t.createSOCKS5Dialer(proxyURL) + if err == nil { + client.Transport = &http.Transport{ + DialContext: dialer.DialContext, + } + } + } else { + // 使用 HTTP/HTTPS 代理 + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + } + } + } + } + + return client +} + +// createSOCKS5Dialer 创建 SOCKS5 代理拨号器 +func (t *Telegram) createSOCKS5Dialer(proxyURL *url.URL) (proxy.ContextDialer, error) { + // 解析代理地址 + host := proxyURL.Host + + // 检查是否有认证信息 + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{ + User: proxyURL.User.Username(), + Password: password, + } + } + + // 创建基础拨号器 + baseDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + + // 创建 SOCKS5 拨号器 + dialer, err := proxy.SOCKS5("tcp", host, auth, baseDialer) + if err != nil { + return nil, err + } + + // 转换为 ContextDialer + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, errors.New("failed to convert to ContextDialer") + } + + return contextDialer, nil +} diff --git a/service/send_ins_service/send_ins.go b/service/send_ins_service/send_ins.go index f0cd835..62dfe43 100644 --- a/service/send_ins_service/send_ins.go +++ b/service/send_ins_service/send_ins.go @@ -119,6 +119,10 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string, _, Msg := app.CommonPlaygroundValid(Config) return Msg, Config } + if ins.WayType == constant.MessageTypeTelegram { + var Config models.InsTelegramConfig + return "", Config + } return "未知的渠道的config校验", empty } diff --git a/service/send_message_service/unified/channel_registry.go b/service/send_message_service/unified/channel_registry.go index bccdb3f..f612624 100644 --- a/service/send_message_service/unified/channel_registry.go +++ b/service/send_message_service/unified/channel_registry.go @@ -26,6 +26,7 @@ const ( MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount MessageTypeMessageNest = channels.MessageTypeMessageNest MessageTypeAliyunSMS = channels.MessageTypeAliyunSMS + MessageTypeTelegram = channels.MessageTypeTelegram ) // ChannelRegistry 渠道注册表 @@ -99,6 +100,7 @@ func GetGlobalChannelRegistry() *ChannelRegistry { globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel()) globalChannelRegistry.Register(channels.NewMessageNestChannel()) globalChannelRegistry.Register(channels.NewAliyunSMSChannel()) + globalChannelRegistry.Register(channels.NewTelegramChannel()) }) return globalChannelRegistry } diff --git a/service/send_message_service/unified/channels/telegram.go b/service/send_message_service/unified/channels/telegram.go new file mode 100644 index 0000000..9a709d7 --- /dev/null +++ b/service/send_message_service/unified/channels/telegram.go @@ -0,0 +1,69 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type TelegramChannel struct{ *BaseChannel } + +func NewTelegramChannel() *TelegramChannel { + return &TelegramChannel{BaseChannel: NewBaseChannel(MessageTypeTelegram, []string{FormatTypeMarkdown, FormatTypeHTML, FormatTypeText})} +} + +func (c *TelegramChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailTelegram) + if !ok { + return "", "类型转换失败" + } + + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return errStr, "" + } + + _, ok = configInterface.(models.InsTelegramConfig) + if !ok { + return "Telegram config校验失败", "" + } + + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + + cli := message.Telegram{ + BotToken: auth.BotToken, + ChatID: auth.ChatID, + ApiHost: auth.ApiHost, + ProxyURL: auth.ProxyURL, + } + + var res []byte + var errMsg string + + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(formattedContent) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else if contentType == FormatTypeHTML { + res, err = cli.SendMessageHTML(formattedContent) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else { + errMsg = fmt.Sprintf("未知的Telegram发送内容类型:%s", contentType) + } + + return string(res), errMsg +} diff --git a/service/send_message_service/unified/channels/types.go b/service/send_message_service/unified/channels/types.go index bdf23ee..3d11fab 100644 --- a/service/send_message_service/unified/channels/types.go +++ b/service/send_message_service/unified/channels/types.go @@ -18,6 +18,7 @@ const ( MessageTypeWeChatOFAccount = constant.MessageTypeWeChatOFAccount MessageTypeMessageNest = constant.MessageTypeMessageNest MessageTypeAliyunSMS = constant.MessageTypeAliyunSMS + MessageTypeTelegram = constant.MessageTypeTelegram ) // UnifiedMessageContent 统一的消息内容结构 diff --git a/service/send_way_service/send_way.go b/service/send_way_service/send_way.go index c68eb73..85f5d38 100644 --- a/service/send_way_service/send_way.go +++ b/service/send_way_service/send_way.go @@ -45,6 +45,7 @@ var ( constant.MessageTypeWeChatOFAccount: func() WayValidator { return &WeChatOFAccount{} }, constant.MessageTypeMessageNest: func() WayValidator { return &MessageNest{} }, constant.MessageTypeAliyunSMS: func() WayValidator { return &WayDetailAliyunSMS{} }, + constant.MessageTypeTelegram: func() WayValidator { return &WayDetailTelegram{} }, } testerRegistry = map[string]func(interface{}) WayTester{ constant.MessageTypeEmail: func(m interface{}) WayTester { return m.(*WayDetailEmail) }, @@ -55,6 +56,7 @@ var ( constant.MessageTypeWeChatOFAccount: func(m interface{}) WayTester { return m.(*WeChatOFAccount) }, constant.MessageTypeMessageNest: func(m interface{}) WayTester { return m.(*MessageNest) }, constant.MessageTypeAliyunSMS: func(m interface{}) WayTester { return m.(*WayDetailAliyunSMS) }, + constant.MessageTypeTelegram: func(m interface{}) WayTester { return m.(*WayDetailTelegram) }, } ) @@ -251,6 +253,39 @@ func (w *MessageNest) Test() (string, string) { return "自托管消息不用测试运行,请直接添加", "" } +// WayDetailTelegram Telegram机器人渠道明细字段 +type WayDetailTelegram struct { + BotToken string `json:"bot_token" validate:"required,max=100" label:"Telegram Bot Token"` + ChatID string `json:"chat_id" validate:"required,max=50" label:"Chat ID"` + ApiHost string `json:"api_host" validate:"max=200" label:"自定义API地址"` + ProxyURL string `json:"proxy_url" validate:"max=200" label:"代理地址"` +} + +func (w *WayDetailTelegram) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "Telegram参数反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, w +} + +func (w *WayDetailTelegram) Test() (string, string) { + testMsg := "This is a test message from message-nest." + var cli = message.Telegram{ + BotToken: w.BotToken, + ChatID: w.ChatID, + ApiHost: w.ApiHost, + ProxyURL: w.ProxyURL, + } + res, err := cli.SendMessageText(testMsg) + if err != nil { + return fmt.Sprintf("发送失败:%s", err), string(res) + } + return "", string(res) +} + func (sw *SendWay) GetByID() (interface{}, error) { return models.GetWayByID(sw.ID) } diff --git a/web/src/components/pages/sendWays/WaysForm.vue b/web/src/components/pages/sendWays/WaysForm.vue index 755a32e..002210e 100644 --- a/web/src/components/pages/sendWays/WaysForm.vue +++ b/web/src/components/pages/sendWays/WaysForm.vue @@ -338,8 +338,8 @@ const saveButtonText = computed(() => { ? - -

{{ currentChannelConfig.tips.desc }}

+ +
diff --git a/web/src/constant.js b/web/src/constant.js index 1c7b1ab..17cf74c 100644 --- a/web/src/constant.js +++ b/web/src/constant.js @@ -180,6 +180,28 @@ const CONSTANT = { { value: '', col: 'template_code', desc: "短信模板CODE(在阿里云短信控制台获取)", label: '短信模板CODE' }, ], }, + { + type: 'Telegram', + label: 'Telegram机器人', + inputs: [ + { subLabel: 'Bot Token', value: '', col: 'bot_token', desc: "Telegram Bot Token" }, + { subLabel: 'Chat ID', value: '', col: 'chat_id', desc: "接收消息的Chat ID或User ID" }, + { subLabel: '自定义API地址', value: '', col: 'api_host', desc: "可选,自定义Telegram API地址(优先级最高)" }, + { subLabel: '代理地址', value: '', col: 'proxy_url', desc: "可选,支持 HTTP/HTTPS/SOCKS5 代理" }, + { subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" }, + ], + tips: { + text: "Telegram机器人说明", + desc: "使用Telegram Bot发送消息。
1. 通过 @BotFather 创建机器人获取Bot Token
2. Chat ID可以是用户ID、群组ID或频道ID
3. 代理配置说明:
  • 自定义API地址(优先级最高):适用于自建代理服务器,如 https://api.example.com
  • 代理地址(优先级较低):支持以下格式
    - HTTP代理:http://127.0.0.1:7890
    - HTTPS代理:https://proxy.example.com:8080
    - SOCKS5代理:socks5://127.0.0.1:1080
    - 带认证的SOCKS5:socks5://user:pass@host:1080
  • 如果同时配置,将优先使用自定义API地址,代理地址会被忽略" + }, + taskInsRadios: [ + { subLabel: 'text', content: 'text' }, + { subLabel: 'markdown', content: 'markdown' }, + { subLabel: 'html', content: 'html' }, + ], + taskInsInputs: [ + ], + }, ], API_VIEW_DATA: [ { label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString },