feat: add tg bot send way

This commit is contained in:
engigu
2026-01-14 20:51:58 +08:00
parent 1d13bc69ab
commit 53a60b54b2
10 changed files with 321 additions and 2 deletions
+4
View File
@@ -51,6 +51,10 @@ type InsAliyunSMSConfig struct {
TemplateCode string `json:"template_code" validate:"required" label:"短信模板CODE"` TemplateCode string `json:"template_code" validate:"required" label:"短信模板CODE"`
} }
// InsTelegramConfig 实例里面的Telegram config
type InsTelegramConfig struct {
}
// ManyAddTaskIns 批量添加实例 // ManyAddTaskIns 批量添加实例
func ManyAddTaskIns(taskIns []SendTasksIns) error { func ManyAddTaskIns(taskIns []SendTasksIns) error {
tx := db.Begin() tx := db.Begin()
+1
View File
@@ -64,6 +64,7 @@ const (
MessageTypeWeChatOFAccount = "WeChatOFAccount" MessageTypeWeChatOFAccount = "WeChatOFAccount"
MessageTypeMessageNest = "MessageNest" MessageTypeMessageNest = "MessageNest"
MessageTypeAliyunSMS = "AliyunSMS" MessageTypeAliyunSMS = "AliyunSMS"
MessageTypeTelegram = "Telegram"
) )
// 限制goroutine的最大数量 // 限制goroutine的最大数量
+181
View File
@@ -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
}
+4
View File
@@ -119,6 +119,10 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string,
_, Msg := app.CommonPlaygroundValid(Config) _, Msg := app.CommonPlaygroundValid(Config)
return Msg, Config return Msg, Config
} }
if ins.WayType == constant.MessageTypeTelegram {
var Config models.InsTelegramConfig
return "", Config
}
return "未知的渠道的config校验", empty return "未知的渠道的config校验", empty
} }
@@ -26,6 +26,7 @@ const (
MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount
MessageTypeMessageNest = channels.MessageTypeMessageNest MessageTypeMessageNest = channels.MessageTypeMessageNest
MessageTypeAliyunSMS = channels.MessageTypeAliyunSMS MessageTypeAliyunSMS = channels.MessageTypeAliyunSMS
MessageTypeTelegram = channels.MessageTypeTelegram
) )
// ChannelRegistry 渠道注册表 // ChannelRegistry 渠道注册表
@@ -99,6 +100,7 @@ func GetGlobalChannelRegistry() *ChannelRegistry {
globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel()) globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel())
globalChannelRegistry.Register(channels.NewMessageNestChannel()) globalChannelRegistry.Register(channels.NewMessageNestChannel())
globalChannelRegistry.Register(channels.NewAliyunSMSChannel()) globalChannelRegistry.Register(channels.NewAliyunSMSChannel())
globalChannelRegistry.Register(channels.NewTelegramChannel())
}) })
return globalChannelRegistry return globalChannelRegistry
} }
@@ -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
}
@@ -18,6 +18,7 @@ const (
MessageTypeWeChatOFAccount = constant.MessageTypeWeChatOFAccount MessageTypeWeChatOFAccount = constant.MessageTypeWeChatOFAccount
MessageTypeMessageNest = constant.MessageTypeMessageNest MessageTypeMessageNest = constant.MessageTypeMessageNest
MessageTypeAliyunSMS = constant.MessageTypeAliyunSMS MessageTypeAliyunSMS = constant.MessageTypeAliyunSMS
MessageTypeTelegram = constant.MessageTypeTelegram
) )
// UnifiedMessageContent 统一的消息内容结构 // UnifiedMessageContent 统一的消息内容结构
+35
View File
@@ -45,6 +45,7 @@ var (
constant.MessageTypeWeChatOFAccount: func() WayValidator { return &WeChatOFAccount{} }, constant.MessageTypeWeChatOFAccount: func() WayValidator { return &WeChatOFAccount{} },
constant.MessageTypeMessageNest: func() WayValidator { return &MessageNest{} }, constant.MessageTypeMessageNest: func() WayValidator { return &MessageNest{} },
constant.MessageTypeAliyunSMS: func() WayValidator { return &WayDetailAliyunSMS{} }, constant.MessageTypeAliyunSMS: func() WayValidator { return &WayDetailAliyunSMS{} },
constant.MessageTypeTelegram: func() WayValidator { return &WayDetailTelegram{} },
} }
testerRegistry = map[string]func(interface{}) WayTester{ testerRegistry = map[string]func(interface{}) WayTester{
constant.MessageTypeEmail: func(m interface{}) WayTester { return m.(*WayDetailEmail) }, constant.MessageTypeEmail: func(m interface{}) WayTester { return m.(*WayDetailEmail) },
@@ -55,6 +56,7 @@ var (
constant.MessageTypeWeChatOFAccount: func(m interface{}) WayTester { return m.(*WeChatOFAccount) }, constant.MessageTypeWeChatOFAccount: func(m interface{}) WayTester { return m.(*WeChatOFAccount) },
constant.MessageTypeMessageNest: func(m interface{}) WayTester { return m.(*MessageNest) }, constant.MessageTypeMessageNest: func(m interface{}) WayTester { return m.(*MessageNest) },
constant.MessageTypeAliyunSMS: func(m interface{}) WayTester { return m.(*WayDetailAliyunSMS) }, 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 "自托管消息不用测试运行,请直接添加", "" 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) { func (sw *SendWay) GetByID() (interface{}, error) {
return models.GetWayByID(sw.ID) return models.GetWayByID(sw.ID)
} }
@@ -338,8 +338,8 @@ const saveButtonText = computed(() => {
<span <span
class="cursor-help inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 hover:border-gray-400 text-xs">?</span> class="cursor-help inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 hover:border-gray-400 text-xs">?</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent class="max-w-md">
<p class="text-sm">{{ currentChannelConfig.tips.desc }}</p> <div class="text-sm" v-html="currentChannelConfig.tips.desc"></div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
+22
View File
@@ -180,6 +180,28 @@ const CONSTANT = {
{ value: '', col: 'template_code', desc: "短信模板CODE(在阿里云短信控制台获取)", label: '短信模板CODE' }, { 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发送消息。<br />1. 通过 @BotFather 创建机器人获取Bot Token<br />2. Chat ID可以是用户ID、群组ID或频道ID<br />3. <strong>代理配置说明:</strong><br />&nbsp;&nbsp;• <strong>自定义API地址</strong>(优先级最高):适用于自建代理服务器,如 https://api.example.com<br />&nbsp;&nbsp;• <strong>代理地址</strong>(优先级较低):支持以下格式<br />&nbsp;&nbsp;&nbsp;&nbsp;- HTTP代理:http://127.0.0.1:7890<br />&nbsp;&nbsp;&nbsp;&nbsp;- HTTPS代理:https://proxy.example.com:8080<br />&nbsp;&nbsp;&nbsp;&nbsp;- SOCKS5代理:socks5://127.0.0.1:1080<br />&nbsp;&nbsp;&nbsp;&nbsp;- 带认证的SOCKS5socks5://user:pass@host:1080<br />&nbsp;&nbsp;• 如果同时配置,将优先使用自定义API地址,代理地址会被忽略"
},
taskInsRadios: [
{ subLabel: 'text', content: 'text' },
{ subLabel: 'markdown', content: 'markdown' },
{ subLabel: 'html', content: 'html' },
],
taskInsInputs: [
],
},
], ],
API_VIEW_DATA: [ API_VIEW_DATA: [
{ label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString }, { label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString },