From 64f746a7ecafe48485e7cc472eb199317c3f86ad Mon Sep 17 00:00:00 2001 From: engigu Date: Wed, 17 Dec 2025 21:51:21 +0800 Subject: [PATCH] feat: add feishu and aliyunsms --- .release_log | 1 + README.md | 3 +- models/send_ins.go | 4 + pkg/constant/constant.go | 19 ++ pkg/message/feishu.go | 133 ++++++++ service/send_ins_service/send_ins.go | 35 +- service/send_message_service/send_message.go | 5 +- .../unified/channel_registry.go | 2 + .../unified/channels/feishu.go | 59 ++++ .../unified/channels/types.go | 26 +- service/send_way_service/send_way.go | 302 ++++++++++++------ .../components/pages/sendWays/SendWays.vue | 12 +- .../components/pages/sendWays/WaysForm.vue | 8 +- web/src/components/ui/InstanceConfig.vue | 24 +- .../components/ui/drawer/DrawerContent.vue | 4 +- web/src/constant.js | 65 ++-- 16 files changed, 537 insertions(+), 165 deletions(-) create mode 100644 pkg/message/feishu.go create mode 100644 service/send_message_service/unified/channels/feishu.go diff --git a/.release_log b/.release_log index 1fc2b86..dad77b5 100644 --- a/.release_log +++ b/.release_log @@ -26,3 +26,4 @@ 26. [2025.09.30] 支持页面的明暗主题切换设置,增加登录日志 27. [2025.10.12] 增加cookies过期天数设置 28. [2025.12.06] 增加模板功能:集成预览到编辑器、优化ID生成规则、增强发送API日志记录 +29. [2025.12.17] 新增飞书机器人渠道、阿里云短信动态接收者、代码架构优化(注册模式重构、常量统一管理) diff --git a/README.md b/README.md index 06abd96..4351048 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化 - 🔄 **整合性:** 提供了多种消息推送方式,包括邮件、钉钉、企业微信等,方便你集中管理和定制通知。 - 🎨 **自定义性:** 可以根据需求定制消息推送策略,满足不同场景的个性化需求。 - 📝 **模板化(⭐推荐):** 支持消息模板功能,通过占位符实现动态内容替换,一次定义多处复用,大幅提高开发效率和维护便利性。 -- 📧 **群发模式(🆕):** 支持动态接收者功能,可在 API 调用时指定多个接收账号,实现邮 - 🛠 **开放性:** 易于扩展和集成新的消息通知服务,以适应未来的变化。 ## 进度 🔨 @@ -28,12 +27,12 @@ Message Nest 是一个灵活而强大的消息推送整合平台,旨在简化 ### 最近更新 +**2025.12.17** - 新增飞书机器人渠道、阿里云短信、代码架构优化 **2025.12.06** - 新增消息模板功能、V2 API **2025.10.12** - 增加 cookies 过期天数设置 **2025.09.30** - 支持明暗主题切换、登录日志 **2025.08.10** - 重构 web 页面,UI 升级(shadcn-vue + tailwindcss) **2025.04.28** - 支持 TiDB、数据库 SSL 配置 -**2025.01.01** - 支持托管消息功能 [查看完整更新日志](https://engigu.github.io/Message-Push-Nest/guide/changelog.html) diff --git a/models/send_ins.go b/models/send_ins.go index 84be220..b1e3b0d 100644 --- a/models/send_ins.go +++ b/models/send_ins.go @@ -33,6 +33,10 @@ type InsDtalkConfig struct { type InsQyWeiXinConfig struct { } +// InsFeishuConfig 实例里面的飞书config +type InsFeishuConfig struct { +} + // InsCustomConfig 实例里面的自定义config type InsCustomConfig struct { } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 5500f4f..a3f00a6 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -47,6 +47,25 @@ var HostedMsgCleanDefaultValueMap = map[string]string{ const AboutSectionName = "about" +// 消息格式类型常量 +const ( + FormatTypeText = "text" + FormatTypeHTML = "html" + FormatTypeMarkdown = "markdown" +) + +// 消息类型常量 +const ( + MessageTypeEmail = "Email" + MessageTypeDtalk = "Dtalk" + MessageTypeQyWeiXin = "QyWeiXin" + MessageTypeFeishu = "Feishu" + MessageTypeCustom = "Custom" + MessageTypeWeChatOFAccount = "WeChatOFAccount" + MessageTypeMessageNest = "MessageNest" + MessageTypeAliyunSMS = "AliyunSMS" +) + // 限制goroutine的最大数量 var MaxSendTaskSemaphoreChan = make(chan string, 2048) diff --git a/pkg/message/feishu.go b/pkg/message/feishu.go new file mode 100644 index 0000000..3f183ff --- /dev/null +++ b/pkg/message/feishu.go @@ -0,0 +1,133 @@ +package message + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +type feishuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +type Feishu struct { + AccessToken string + Secret string +} + +// genSign 生成飞书签名 +func (f *Feishu) genSign(timestamp int64) string { + if f.Secret == "" { + return "" + } + stringToSign := fmt.Sprintf("%v\n%s", timestamp, f.Secret) + h := hmac.New(sha256.New, []byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return signature +} + +// SendMessageText 发送文本消息 +func (f *Feishu) SendMessageText(content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "text", + "content": map[string]interface{}{ + "text": content, + }, + } + + return f.send(msg) +} + +// SendMessageMarkdown 发送 Markdown 消息 +func (f *Feishu) SendMessageMarkdown(title, content string, atMobiles ...string) ([]byte, error) { + timestamp := time.Now().Unix() + sign := f.genSign(timestamp) + + // 处理 @ 人员 + atContent := "" + if len(atMobiles) > 0 { + for _, mobile := range atMobiles { + if mobile == "all" { + atContent += "所有人" + } else { + atContent += fmt.Sprintf("", mobile) + } + } + content = atContent + "\n" + content + } + + msg := map[string]interface{}{ + "timestamp": strconv.FormatInt(timestamp, 10), + "sign": sign, + "msg_type": "interactive", + "card": map[string]interface{}{ + "header": map[string]interface{}{ + "title": map[string]interface{}{ + "tag": "plain_text", + "content": title, + }, + }, + "elements": []map[string]interface{}{ + { + "tag": "markdown", + "content": content, + }, + }, + }, + } + + return f.send(msg) +} + +// send 发送请求 +func (f *Feishu) send(msg map[string]interface{}) ([]byte, error) { + url := fmt.Sprintf("https://open.feishu.cn/open-apis/bot/v2/hook/%s", f.AccessToken) + + jsonData, err := json.Marshal(msg) + if err != nil { + return nil, fmt.Errorf("JSON序列化失败: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + var result feishuResponse + if err := json.Unmarshal(body, &result); err != nil { + return body, fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != 0 { + return body, fmt.Errorf("飞书返回错误: %s", result.Msg) + } + + return body, nil +} diff --git a/service/send_ins_service/send_ins.go b/service/send_ins_service/send_ins.go index c34d3ce..f0cd835 100644 --- a/service/send_ins_service/send_ins.go +++ b/service/send_ins_service/send_ins.go @@ -5,6 +5,7 @@ import ( "fmt" "message-nest/models" "message-nest/pkg/app" + "message-nest/pkg/constant" ) type SendTaskInsService struct { @@ -22,7 +23,7 @@ type SendTaskInsService struct { // ValidateDiffWay 各种发信渠道具体字段校验 func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string, interface{}) { var empty interface{} - if ins.WayType == "Email" { + if ins.WayType == constant.MessageTypeEmail { var emailConfig models.InsEmailConfig err := json.Unmarshal([]byte(ins.Config), &emailConfig) if err != nil { @@ -45,19 +46,23 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string, _, Msg := app.CommonPlaygroundValid(emailConfig) return Msg, emailConfig } - if ins.WayType == "Dtalk" { + if ins.WayType == constant.MessageTypeDtalk { var Config models.InsDtalkConfig return "", Config } - if ins.WayType == "QyWeiXin" { + if ins.WayType == constant.MessageTypeQyWeiXin { var Config models.InsQyWeiXinConfig return "", Config } - if ins.WayType == "MessageNest" { + if ins.WayType == constant.MessageTypeFeishu { + var Config models.InsFeishuConfig + return "", Config + } + if ins.WayType == constant.MessageTypeMessageNest { var Config models.InsQyWeiXinConfig return "", Config } - if ins.WayType == "Custom" { + if ins.WayType == constant.MessageTypeCustom { var Config models.InsCustomConfig err := json.Unmarshal([]byte(ins.Config), &Config) if err != nil { @@ -66,7 +71,7 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string, _, Msg := app.CommonPlaygroundValid(Config) return Msg, Config } - if ins.WayType == "WeChatOFAccount" { + if ins.WayType == constant.MessageTypeWeChatOFAccount { var Config models.InsWeChatAccountConfig err := json.Unmarshal([]byte(ins.Config), &Config) if err != nil { @@ -89,12 +94,28 @@ func (sw *SendTaskInsService) ValidateDiffIns(ins models.SendTasksIns) (string, _, Msg := app.CommonPlaygroundValid(Config) return Msg, Config } - if ins.WayType == "AliyunSMS" { + if ins.WayType == constant.MessageTypeAliyunSMS { var Config models.InsAliyunSMSConfig err := json.Unmarshal([]byte(ins.Config), &Config) if err != nil { return "阿里云短信配置反序列化失败!", empty } + + // 检查是否为动态接收者模式 + var configMap map[string]interface{} + json.Unmarshal([]byte(ins.Config), &configMap) + allowMultiRecip, exists := configMap["allowMultiRecip"].(bool) + + // allowMultiRecip=true为动态模式,phone_number可以为空,但template_code仍需验证 + if exists && allowMultiRecip && Config.PhoneNumber == "" { + // 动态模式,只验证template_code + if Config.TemplateCode == "" { + return "短信模板CODE不能为空", empty + } + return "", Config + } + + // 固定模式或有phone_number时,进行常规验证 _, Msg := app.CommonPlaygroundValid(Config) return Msg, Config } diff --git a/service/send_message_service/send_message.go b/service/send_message_service/send_message.go index 3baac5a..1b9dc11 100644 --- a/service/send_message_service/send_message.go +++ b/service/send_message_service/send_message.go @@ -397,8 +397,9 @@ func (sm *SendMessageService) BuildTemplateContent(ins models.SendTasksIns) *uni func (sm *SendMessageService) supportsDynamicRecipient(wayType string) bool { // 支持动态接收者的渠道类型 supportedTypes := map[string]bool{ - unified.MessageTypeEmail: true, - unified.MessageTypeWeChatOFAccount: true, + constant.MessageTypeEmail: true, + constant.MessageTypeWeChatOFAccount: true, + constant.MessageTypeAliyunSMS: true, // 可以继续添加其他支持动态接收者的渠道 } return supportedTypes[wayType] diff --git a/service/send_message_service/unified/channel_registry.go b/service/send_message_service/unified/channel_registry.go index a41ce8f..bccdb3f 100644 --- a/service/send_message_service/unified/channel_registry.go +++ b/service/send_message_service/unified/channel_registry.go @@ -21,6 +21,7 @@ const ( MessageTypeEmail = channels.MessageTypeEmail MessageTypeDtalk = channels.MessageTypeDtalk MessageTypeQyWeiXin = channels.MessageTypeQyWeiXin + MessageTypeFeishu = channels.MessageTypeFeishu MessageTypeCustom = channels.MessageTypeCustom MessageTypeWeChatOFAccount = channels.MessageTypeWeChatOFAccount MessageTypeMessageNest = channels.MessageTypeMessageNest @@ -93,6 +94,7 @@ func GetGlobalChannelRegistry() *ChannelRegistry { globalChannelRegistry.Register(channels.NewEmailChannel()) globalChannelRegistry.Register(channels.NewDtalkChannel()) globalChannelRegistry.Register(channels.NewQyWeiXinChannel()) + globalChannelRegistry.Register(channels.NewFeishuChannel()) globalChannelRegistry.Register(channels.NewCustomChannel()) globalChannelRegistry.Register(channels.NewWeChatOFAccountChannel()) globalChannelRegistry.Register(channels.NewMessageNestChannel()) diff --git a/service/send_message_service/unified/channels/feishu.go b/service/send_message_service/unified/channels/feishu.go new file mode 100644 index 0000000..b46ef4a --- /dev/null +++ b/service/send_message_service/unified/channels/feishu.go @@ -0,0 +1,59 @@ +package channels + +import ( + "fmt" + "message-nest/models" + "message-nest/pkg/message" + "message-nest/service/send_ins_service" + "message-nest/service/send_way_service" +) + +type FeishuChannel struct{ *BaseChannel } + +func NewFeishuChannel() *FeishuChannel { + return &FeishuChannel{BaseChannel: NewBaseChannel(MessageTypeFeishu, []string{FormatTypeMarkdown, FormatTypeText})} +} + +func (c *FeishuChannel) SendUnified(msgObj interface{}, ins models.SendTasksIns, content *UnifiedMessageContent) (string, string) { + auth, ok := msgObj.(send_way_service.WayDetailFeishu) + if !ok { + return "", "类型转换失败" + } + insService := send_ins_service.SendTaskInsService{} + errStr, configInterface := insService.ValidateDiffIns(ins) + if errStr != "" { + return errStr, "" + } + _, ok = configInterface.(models.InsFeishuConfig) + if !ok { + return "飞书config校验失败", "" + } + contentType, formattedContent, err := c.FormatContent(content) + if err != nil { + return "", err.Error() + } + atMobiles := content.GetAtMobiles() + atUserIds := content.GetAtUserIds() + // 合并 @ 人员列表 + atList := append(atMobiles, atUserIds...) + if content.IsAtAll() { + atList = append(atList, "all") + } + cli := message.Feishu{AccessToken: auth.AccessToken, Secret: auth.Secret} + var res []byte + var errMsg string + if contentType == FormatTypeText { + res, err = cli.SendMessageText(formattedContent, atList...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else if contentType == FormatTypeMarkdown { + res, err = cli.SendMessageMarkdown(content.Title, formattedContent, atList...) + if err != nil { + errMsg = fmt.Sprintf("发送失败:%s", err.Error()) + } + } else { + errMsg = fmt.Sprintf("未知的飞书发送内容类型:%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 e2acd4f..bdf23ee 100644 --- a/service/send_message_service/unified/channels/types.go +++ b/service/send_message_service/unified/channels/types.go @@ -1,21 +1,23 @@ package channels -// 消息格式类型常量 +import "message-nest/pkg/constant" + +// 重导出常量,保持向后兼容 const ( - FormatTypeText = "text" - FormatTypeHTML = "html" - FormatTypeMarkdown = "markdown" + FormatTypeText = constant.FormatTypeText + FormatTypeHTML = constant.FormatTypeHTML + FormatTypeMarkdown = constant.FormatTypeMarkdown ) -// 消息类型常量 const ( - MessageTypeEmail = "Email" - MessageTypeDtalk = "Dtalk" - MessageTypeQyWeiXin = "QyWeiXin" - MessageTypeCustom = "Custom" - MessageTypeWeChatOFAccount = "WeChatOFAccount" - MessageTypeMessageNest = "MessageNest" - MessageTypeAliyunSMS = "AliyunSMS" + MessageTypeEmail = constant.MessageTypeEmail + MessageTypeDtalk = constant.MessageTypeDtalk + MessageTypeQyWeiXin = constant.MessageTypeQyWeiXin + MessageTypeFeishu = constant.MessageTypeFeishu + MessageTypeCustom = constant.MessageTypeCustom + MessageTypeWeChatOFAccount = constant.MessageTypeWeChatOFAccount + MessageTypeMessageNest = constant.MessageTypeMessageNest + MessageTypeAliyunSMS = constant.MessageTypeAliyunSMS ) // UnifiedMessageContent 统一的消息内容结构 diff --git a/service/send_way_service/send_way.go b/service/send_way_service/send_way.go index 1b8ef4a..ed6832d 100644 --- a/service/send_way_service/send_way.go +++ b/service/send_way_service/send_way.go @@ -6,6 +6,7 @@ import ( "fmt" "message-nest/models" "message-nest/pkg/app" + "message-nest/pkg/constant" "message-nest/pkg/message" "strings" ) @@ -23,6 +24,40 @@ type SendWay struct { PageSize int } +// WayValidator 渠道验证接口 +type WayValidator interface { + Validate(authJson string) (string, interface{}) +} + +// WayTester 渠道测试接口 +type WayTester interface { + Test() (string, string) +} + +// 渠道注册表 +var ( + validatorRegistry = map[string]func() WayValidator{ + constant.MessageTypeEmail: func() WayValidator { return &WayDetailEmail{} }, + constant.MessageTypeDtalk: func() WayValidator { return &WayDetailDTalk{} }, + constant.MessageTypeQyWeiXin: func() WayValidator { return &WayDetailQyWeiXin{} }, + constant.MessageTypeFeishu: func() WayValidator { return &WayDetailFeishu{} }, + constant.MessageTypeCustom: func() WayValidator { return &WayDetailCustom{} }, + constant.MessageTypeWeChatOFAccount: func() WayValidator { return &WeChatOFAccount{} }, + constant.MessageTypeMessageNest: func() WayValidator { return &MessageNest{} }, + constant.MessageTypeAliyunSMS: func() WayValidator { return &WayDetailAliyunSMS{} }, + } + testerRegistry = map[string]func(interface{}) WayTester{ + constant.MessageTypeEmail: func(m interface{}) WayTester { return m.(*WayDetailEmail) }, + constant.MessageTypeDtalk: func(m interface{}) WayTester { return m.(*WayDetailDTalk) }, + constant.MessageTypeQyWeiXin: func(m interface{}) WayTester { return m.(*WayDetailQyWeiXin) }, + constant.MessageTypeFeishu: func(m interface{}) WayTester { return m.(*WayDetailFeishu) }, + constant.MessageTypeCustom: func(m interface{}) WayTester { return m.(*WayDetailCustom) }, + 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) }, + } +) + // WayDetailEmail 邮箱渠道明细字段 type WayDetailEmail struct { Server string `validate:"required,max=50" label:"SMTP服务地址"` @@ -31,6 +66,24 @@ type WayDetailEmail struct { Passwd string `validate:"required,max=50" label:"邮箱密码"` } +func (w *WayDetailEmail) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "邮箱auth反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailEmail) Test() (string, string) { + testMsg := "This is a test message from message-nest." + var emailer message.EmailMessage + emailer.Init(w.Server, 465, w.Account, w.Passwd) + errMsg := emailer.SendTextMessage(w.Account, "test email", testMsg) + return errMsg, "" +} + // WayDetailDTalk 钉钉渠道明细字段 type WayDetailDTalk struct { AccessToken string `json:"access_token" validate:"required,max=100" label:"钉钉access_token"` @@ -38,17 +91,106 @@ type WayDetailDTalk struct { Secret string `json:"secret" validate:"max=100" label:"钉钉加签秘钥"` } +func (w *WayDetailDTalk) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "钉钉auth反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailDTalk) Test() (string, string) { + testMsg := "This is a test message from message-nest." + var cli = message.Dtalk{ + AccessToken: w.AccessToken, + Secret: w.Secret, + } + res, err := cli.SendMessageText(testMsg + w.Keys) + if err != nil { + return fmt.Sprintf("发送失败:%s", err), string(res) + } + return "", string(res) +} + // WayDetailQyWeiXin 企业微信渠道明细字段 type WayDetailQyWeiXin struct { AccessToken string `json:"access_token" validate:"required,max=100" label:"企业微信access_token"` } +func (w *WayDetailQyWeiXin) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "企业微信auth反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailQyWeiXin) Test() (string, string) { + testMsg := "This is a test message from message-nest." + var cli = message.QyWeiXin{ + AccessToken: w.AccessToken, + } + res, err := cli.SendMessageText(testMsg) + if err != nil { + return fmt.Sprintf("发送失败:%s", err), string(res) + } + return "", string(res) +} + +// WayDetailFeishu 飞书渠道明细字段 +type WayDetailFeishu struct { + AccessToken string `json:"access_token" validate:"required,max=100" label:"飞书access_token"` + Keys string `json:"keys" validate:"max=200" label:"飞书关键字"` + Secret string `json:"secret" validate:"max=100" label:"飞书加签秘钥"` +} + +func (w *WayDetailFeishu) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "飞书auth反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailFeishu) Test() (string, string) { + testMsg := "This is a test message from message-nest." + var cli = message.Feishu{ + AccessToken: w.AccessToken, + Secret: w.Secret, + } + res, err := cli.SendMessageText(testMsg + w.Keys) + if err != nil { + return fmt.Sprintf("发送失败:%s", err), string(res) + } + return "", string(res) +} + // WayDetailCustom 自定义渠道 type WayDetailCustom struct { Webhook string `json:"webhook" validate:"required,max=200" label:"自定义的webhook地址"` Body string `json:"body" validate:"max=2000" label:"自定义的请求体"` } +func (w *WayDetailCustom) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "自定义参数反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailCustom) Test() (string, string) { + return "自定义webhook不用测试运行,请直接添加", "" +} + // WeChatOFAccount 微信公众号 type WeChatOFAccount struct { AppID string `json:"appID" validate:"required,max=200" label:"微信公众号id"` @@ -56,6 +198,20 @@ type WeChatOFAccount struct { TempID string `json:"tempid" validate:"max=2000" label:"模板消息id"` } +func (w *WeChatOFAccount) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "微信公众号反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WeChatOFAccount) Test() (string, string) { + return "微信公众号模板消息不用测试运行,请直接添加", "" +} + // WayDetailAliyunSMS 阿里云短信渠道明细字段 type WayDetailAliyunSMS struct { AccessKeyId string `json:"access_key_id" validate:"required,max=100" label:"AccessKeyId"` @@ -63,10 +219,38 @@ type WayDetailAliyunSMS struct { SignName string `json:"sign_name" validate:"required,max=50" label:"短信签名"` } +func (w *WayDetailAliyunSMS) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "阿里云短信auth反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *WayDetailAliyunSMS) Test() (string, string) { + return "阿里云短信不用测试运行,请直接添加", "" +} + // MessageNest 自托管消息 type MessageNest struct { } +func (w *MessageNest) Validate(authJson string) (string, interface{}) { + var empty interface{} + err := json.Unmarshal([]byte(authJson), w) + if err != nil { + return "自托管消息反序列化失败!", empty + } + _, msg := app.CommonPlaygroundValid(*w) + return msg, *w +} + +func (w *MessageNest) Test() (string, string) { + return "自托管消息不用测试运行,请直接添加", "" +} + func (sw *SendWay) GetByID() (interface{}, error) { return models.GetWayByID(sw.ID) } @@ -138,113 +322,31 @@ func (sw *SendWay) getMaps() map[string]interface{} { return maps } +// getValidator 根据渠道类型获取对应的验证器 +func (sw *SendWay) getValidator() WayValidator { + factory, exists := validatorRegistry[sw.Type] + if !exists { + return nil + } + return factory() +} + // ValidateDiffWay 各种发信渠道具体字段校验 func (sw *SendWay) ValidateDiffWay() (string, interface{}) { var empty interface{} - if sw.Type == "Email" { - var email WayDetailEmail - err := json.Unmarshal([]byte(sw.Auth), &email) - if err != nil { - return "邮箱auth反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(email) - return Msg, email - } else if sw.Type == "Dtalk" { - var dtalk WayDetailDTalk - err := json.Unmarshal([]byte(sw.Auth), &dtalk) - if err != nil { - return "钉钉auth反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(dtalk) - return Msg, dtalk - } else if sw.Type == "QyWeiXin" { - var config WayDetailQyWeiXin - err := json.Unmarshal([]byte(sw.Auth), &config) - if err != nil { - return "企业微信auth反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(config) - return Msg, config - } else if sw.Type == "Custom" { - var custom WayDetailCustom - err := json.Unmarshal([]byte(sw.Auth), &custom) - if err != nil { - return "自定义参数反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(custom) - return Msg, custom - } else if sw.Type == "WeChatOFAccount" { - var wca WeChatOFAccount - err := json.Unmarshal([]byte(sw.Auth), &wca) - if err != nil { - return "微信公众号反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(wca) - return Msg, wca - } else if sw.Type == "MessageNest" { - var wca MessageNest - err := json.Unmarshal([]byte(sw.Auth), &wca) - if err != nil { - return "自托管消息反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(wca) - return Msg, wca - } else if sw.Type == "AliyunSMS" { - var aliyunSMS WayDetailAliyunSMS - err := json.Unmarshal([]byte(sw.Auth), &aliyunSMS) - if err != nil { - return "阿里云短信auth反序列化失败!", empty - } - _, Msg := app.CommonPlaygroundValid(aliyunSMS) - return Msg, aliyunSMS + validator := sw.getValidator() + if validator == nil { + return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), empty } - return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), empty + return validator.Validate(sw.Auth) } // TestSendWay 尝试带发信测试连通性 func (sw *SendWay) TestSendWay(msgObj interface{}) (string, string) { - testMsg := "This is a test message from message-nest." - emailAuth, ok := msgObj.(WayDetailEmail) - if ok { - var emailer message.EmailMessage - emailer.Init(emailAuth.Server, 465, emailAuth.Account, emailAuth.Passwd) - errMsg := emailer.SendTextMessage(emailAuth.Account, "test email", testMsg) - return errMsg, "" + factory, exists := testerRegistry[sw.Type] + if !exists { + return fmt.Sprintf("未知的发信渠道测试: %s", sw.Type), "" } - dtalkAuth, ok := msgObj.(WayDetailDTalk) - if ok { - var cli = message.Dtalk{ - AccessToken: dtalkAuth.AccessToken, - Secret: dtalkAuth.Secret, - } - res, err := cli.SendMessageText(testMsg + dtalkAuth.Keys) - if err != nil { - return fmt.Sprintf("发送失败:%s", err), string(res) - } - return "", string(res) - } - qywxAuth, ok := msgObj.(WayDetailQyWeiXin) - if ok { - var cli = message.QyWeiXin{ - AccessToken: qywxAuth.AccessToken, - } - res, err := cli.SendMessageText(testMsg) - if err != nil { - return fmt.Sprintf("发送失败:%s", err), string(res) - } - return "", string(res) - } - _, ok = msgObj.(WeChatOFAccount) - if ok { - return "微信公众号模板消息不用测试运行,请直接添加", "" - } - _, ok = msgObj.(MessageNest) - if ok { - return "自托管消息不用测试运行,请直接添加", "" - } - _, ok = msgObj.(WayDetailAliyunSMS) - if ok { - return "阿里云短信不用测试运行,请直接添加", "" - } - return fmt.Sprintf("未知的发信渠道校验: %s", sw.Type), "" + tester := factory(msgObj) + return tester.Test() } diff --git a/web/src/components/pages/sendWays/SendWays.vue b/web/src/components/pages/sendWays/SendWays.vue index d99a619..06b3fe8 100644 --- a/web/src/components/pages/sendWays/SendWays.vue +++ b/web/src/components/pages/sendWays/SendWays.vue @@ -194,12 +194,12 @@ onMounted(async () => { - - + + 新增发信渠道 -
+
@@ -257,12 +257,12 @@ onMounted(async () => { - - + + 编辑发信渠道 -
+
diff --git a/web/src/components/pages/sendWays/WaysForm.vue b/web/src/components/pages/sendWays/WaysForm.vue index bd4130d..755a32e 100644 --- a/web/src/components/pages/sendWays/WaysForm.vue +++ b/web/src/components/pages/sendWays/WaysForm.vue @@ -251,10 +251,10 @@ const saveButtonText = computed(() => {
- -
-
当前渠道:{{ currentChannelConfig?.label || channelMode }}
- 群发 + +
+ {{ currentChannelConfig?.label || channelMode }} + 群发
diff --git a/web/src/components/ui/InstanceConfig.vue b/web/src/components/ui/InstanceConfig.vue index d20589a..c510e8a 100644 --- a/web/src/components/ui/InstanceConfig.vue +++ b/web/src/components/ui/InstanceConfig.vue @@ -212,17 +212,21 @@ const formatInsConfigDisplay = (row: any) => { if (!row.config) { return "" } - if (["Email", "WeChatOFAccount"].includes(row.way_type)) { - let config = JSON.parse(row.config) - // 检查是否为动态接收者模式 - if (config.allowMultiRecip === true) { - return "动态接收" - } - // 固定模式,显示接收者 - return config.to_account || "" - } else { - return "" + let config = JSON.parse(row.config) + + // 检查是否为动态接收者模式 + if (config.allowMultiRecip === true) { + return "动态接收" } + + // 固定模式,根据 constant.js 配置动态获取接收者字段 + const channelConfig = CONSTANT.WAYS_DATA.find((item: any) => item.type === row.way_type) + if (channelConfig?.dynamicRecipient?.support) { + const recipientField = channelConfig.dynamicRecipient.field + return config[recipientField] || "" + } + + return "" } // 查询实例列表数据 diff --git a/web/src/components/ui/drawer/DrawerContent.vue b/web/src/components/ui/drawer/DrawerContent.vue index 4eaf19d..7f6ec29 100644 --- a/web/src/components/ui/drawer/DrawerContent.vue +++ b/web/src/components/ui/drawer/DrawerContent.vue @@ -20,8 +20,8 @@ const forwarded = useForwardPropsEmits(props, emits) as any v-bind="forwarded" :class="cn( `group/drawer-content bg-background fixed z-50 flex h-auto flex-col`, - `data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg`, - `data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg`, + `data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[96vh] data-[vaul-drawer-direction=top]:rounded-b-lg`, + `data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[96vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg`, `data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:sm:max-w-sm`, `data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:sm:max-w-sm`, props.class, diff --git a/web/src/constant.js b/web/src/constant.js index ca3265d..1c7b1ab 100644 --- a/web/src/constant.js +++ b/web/src/constant.js @@ -76,6 +76,24 @@ const CONSTANT = { taskInsInputs: [ ], }, + { + type: 'Feishu', + label: '飞书机器人', + inputs: [ + { subLabel: 'access_token', value: '', col: 'access_token', desc: "飞书webhook中的access_token" }, + { subLabel: '加签', value: '', col: 'secret', desc: "加签的签名" }, + { subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" }, + ], + tips: { + text: "输入框说明", desc: "飞书支持加签和关键字过滤,如果是配置了关键字过滤,只需要消息里面包含了关键字,就会发送" + }, + taskInsRadios: [ + { subLabel: 'text', content: 'text' }, + { subLabel: 'markdown', content: 'markdown' }, + ], + taskInsInputs: [ + ], + }, { type: 'Custom', label: '自定义推送', @@ -135,26 +153,33 @@ const CONSTANT = { ], }, // 暂时屏蔽阿里云短信入口 - // { - // type: 'AliyunSMS', - // label: '阿里云短信', - // inputs: [ - // { subLabel: 'AccessKeyId', value: '', col: 'access_key_id', desc: "阿里云AccessKeyId" }, - // { subLabel: 'AccessKeySecret', value: '', col: 'access_key_secret', desc: "阿里云AccessKeySecret" }, - // { subLabel: '短信签名', value: '', col: 'sign_name', desc: "短信签名名称" }, - // { subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" }, - // ], - // tips: { - // text: "阿里云短信说明", desc: "使用阿里云短信服务发送短信,需要在阿里云控制台申请短信签名和模板。
AccessKey请在阿里云控制台获取。" - // }, - // taskInsRadios: [ - // { subLabel: 'text', content: 'text' }, - // ], - // taskInsInputs: [ - // { value: '', col: 'phone_number', desc: "手机号码(接收短信的手机号)" }, - // { value: '', col: 'template_code', desc: "短信模板CODE(在阿里云短信控制台获取)" }, - // ], - // }, + { + type: 'AliyunSMS', + label: '阿里云短信', + // 动态接收者配置 + dynamicRecipient: { + support: true, // 是否支持动态接收者 + field: 'phone_number', // 接收者字段名 + label: '手机号码', // 接收者字段标签 + desc: '手机号码', // 接收者字段描述 + }, + inputs: [ + { subLabel: 'AccessKeyId', value: '', col: 'access_key_id', desc: "阿里云AccessKeyId" }, + { subLabel: 'AccessKeySecret', value: '', col: 'access_key_secret', desc: "阿里云AccessKeySecret" }, + { subLabel: '短信签名', value: '', col: 'sign_name', desc: "短信签名名称" }, + { subLabel: '渠道名', value: '', col: 'name', desc: "想要设置的渠道名字" }, + ], + tips: { + text: "阿里云短信说明", desc: "使用阿里云短信服务发送短信,需要在阿里云控制台申请短信签名和模板。
AccessKey请在阿里云控制台获取。" + }, + taskInsRadios: [ + { subLabel: 'text', content: 'text' }, + ], + taskInsInputs: [ + { value: '', col: 'phone_number', desc: "手机号码(接收短信的手机号)", label: '手机号码' }, + { value: '', col: 'template_code', desc: "短信模板CODE(在阿里云短信控制台获取)", label: '短信模板CODE' }, + ], + }, ], API_VIEW_DATA: [ { label: "curl", class: "language-shell line-numbers", code: "", func: ApiStrGenerate.getCurlString },