diff --git a/README.md b/README.md index 8ce31b5..9bf2075 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ _✨ 搭建专属于你的消息推送服务,支持多种消息推送方式, + QQ, + 企业微信应用号, + 企业微信群机器人 + + 飞书自建应用 + 飞书群机器人, + 钉钉群机器人, + Bark App, @@ -166,16 +167,17 @@ proxy_send_timeout 300s; 1. `email`:通过发送邮件的方式进行推送(使用 `title` 或 `description` 字段设置邮件主题,使用 `content` 字段设置正文,支持完整的 Markdown 语法)。 2. `test`:通过微信测试号进行推送(使用 `description` 字段设置模板消息内容,不支持 Markdown)。 3. `corp_app`:通过企业微信应用号进行推送(仅当使用企业微信 APP 时,如果设置了 `content` 字段,`title` 和 `description` 字段会被忽略;使用微信中的企业微信插件时正常)。 - 4. `corp`:通过企业微信群机器人推送(设置 `content` 字段则将渲染 Markdown 消息,支持 Markdown 的子集;设置 `description` 字段则为普通文本消息)。 - 5. `lark`:通过飞书群机器人进行推送(注意事项同上)。 - 6. `ding`:通过钉钉群机器人进行推送(注意事项同上)。 - 7. `bark`:通过 Bark 进行推送(支持 `title` 和 `description` 字段)。 - 8. `client`:通过 WebSocket 客户端进行推送(支持 `title` 和 `description` 字段)。 - 9. `telegram`:通过 Telegram 机器人进行推送(`description` 或 `content` 字段二选一,支持 Markdown 的子集)。 - 10. `discord`:通过 Discord 群机器人进行推送(注意事项同上)。 - 11. `one_api`:通过 OneAPI 协议推送消息到 QQ。 - 12. `group`:通过预先配置的消息推送通道群组进行推送。 - 13. `none`:仅保存到数据库,不做推送。 + 4. `lark_app`:通过飞书自建应用进行推送。 + 5. `corp`:通过企业微信群机器人推送(设置 `content` 字段则将渲染 Markdown 消息,支持 Markdown 的子集;设置 `description` 字段则为普通文本消息)。 + 6. `lark`:通过飞书群机器人进行推送(注意事项同上)。 + 7. `ding`:通过钉钉群机器人进行推送(注意事项同上)。 + 8. `bark`:通过 Bark 进行推送(支持 `title` 和 `description` 字段)。 + 9. `client`:通过 WebSocket 客户端进行推送(支持 `title` 和 `description` 字段)。 + 10. `telegram`:通过 Telegram 机器人进行推送(`description` 或 `content` 字段二选一,支持 Markdown 的子集)。 + 11. `discord`:通过 Discord 群机器人进行推送(注意事项同上)。 + 12. `one_api`:通过 OneAPI 协议推送消息到 QQ。 + 13. `group`:通过预先配置的消息推送通道群组进行推送。 + 14. `none`:仅保存到数据库,不做推送。 5. `token`:如果你在后台设置了推送 token,则此项必填。另外可以通过设置 HTTP `Authorization` 头部设置此项。 6. `url`:选填,如果不填则系统自动为消息生成 URL,其内容为消息详情。 7. `to`:选填,推送给指定用户,如果不填则默认推送给自己,受限于具体的消息推送方式,有些推送方式不支持此项。 diff --git a/channel/lark-app.go b/channel/lark-app.go new file mode 100644 index 0000000..72dbbf5 --- /dev/null +++ b/channel/lark-app.go @@ -0,0 +1,161 @@ +package channel + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "message-pusher/common" + "message-pusher/model" + "net/http" + "strings" +) + +type larkAppTokenRequest struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` +} + +type larkAppTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + Expire int `json:"expire"` +} + +type LarkAppTokenStoreItem struct { + AppID string + AppSecret string + AccessToken string +} + +func (i *LarkAppTokenStoreItem) Key() string { + return i.AppID + i.AppSecret +} + +func (i *LarkAppTokenStoreItem) IsShared() bool { + var count int64 = 0 + model.DB.Model(&model.Channel{}).Where("secret = ? and app_id = ? and type = ?", + i.AppSecret, i.AppID, model.TypeLarkApp).Count(&count) + return count > 1 +} + +func (i *LarkAppTokenStoreItem) IsFilled() bool { + return i.AppID != "" && i.AppSecret != "" +} + +func (i *LarkAppTokenStoreItem) Token() string { + return i.AccessToken +} + +func (i *LarkAppTokenStoreItem) Refresh() { + // https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal + tokenRequest := larkAppTokenRequest{ + AppID: i.AppID, + AppSecret: i.AppSecret, + } + tokenRequestData, err := json.Marshal(tokenRequest) + responseData, err := http.Post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", + "application/json; charset=utf-8", bytes.NewBuffer(tokenRequestData)) + if err != nil { + common.SysError("failed to refresh access token: " + err.Error()) + return + } + defer responseData.Body.Close() + var res larkAppTokenResponse + err = json.NewDecoder(responseData.Body).Decode(&res) + if err != nil { + common.SysError("failed to decode larkAppTokenResponse: " + err.Error()) + return + } + if res.Code != 0 { + common.SysError(res.Msg) + return + } + i.AccessToken = res.TenantAccessToken + common.SysLog("access token refreshed") +} + +type larkAppMessageRequest struct { + ReceiveId string `json:"receive_id"` + MsgType string `json:"msg_type"` + Content string `json:"content"` +} + +type larkAppMessageResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +func parseLarkAppTarget(target string) (string, string, error) { + parts := strings.Split(target, ":") + if len(parts) != 2 { + return "", "", errors.New("无效的飞书应用号消息接收者参数") + } + return parts[0], parts[1], nil +} + +func SendLarkAppMessage(message *model.Message, user *model.User, channel_ *model.Channel) error { + // https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create + rawTarget := message.To + if rawTarget == "" { + rawTarget = channel_.AccountId + } + targetType, target, err := parseLarkAppTarget(rawTarget) + if err != nil { + return err + } + request := larkAppMessageRequest{ + ReceiveId: target, + } + atPrefix := getLarkAtPrefix(message) + if message.Description != "" { + request.MsgType = "text" + content := larkTextContent{Text: atPrefix + message.Description} + contentData, err := json.Marshal(content) + if err != nil { + return err + } + request.Content = string(contentData) + } else { + request.MsgType = "interactive" + content := larkCardContent{} + content.Config.WideScreenMode = true + content.Config.EnableForward = true + content.Elements = append(content.Elements, larkMessageRequestCardElement{ + Tag: "div", + Text: larkMessageRequestCardElementText{ + Content: atPrefix + message.Content, + Tag: "lark_md", + }, + }) + contentData, err := json.Marshal(content) + if err != nil { + return err + } + request.Content = string(contentData) + } + requestData, err := json.Marshal(request) + if err != nil { + return err + } + key := fmt.Sprintf("%s%s", channel_.AppId, channel_.Secret) + accessToken := TokenStoreGetToken(key) + url := fmt.Sprintf("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=%s", targetType) + req, _ := http.NewRequest("POST", url, bytes.NewReader(requestData)) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + var res larkAppMessageResponse + err = json.NewDecoder(resp.Body).Decode(&res) + if err != nil { + return err + } + if res.Code != 0 { + return errors.New(res.Msg) + } + return nil +} diff --git a/channel/lark.go b/channel/lark.go index 5e05a72..45def02 100644 --- a/channel/lark.go +++ b/channel/lark.go @@ -25,20 +25,24 @@ type larkMessageRequestCardElement struct { Text larkMessageRequestCardElementText `json:"text"` } +type larkTextContent struct { + Text string `json:"text"` +} + +type larkCardContent struct { + Config struct { + WideScreenMode bool `json:"wide_screen_mode"` + EnableForward bool `json:"enable_forward"` + } + Elements []larkMessageRequestCardElement `json:"elements"` +} + type larkMessageRequest struct { - MessageType string `json:"msg_type"` - Timestamp string `json:"timestamp"` - Sign string `json:"sign"` - Content struct { - Text string `json:"text"` - } `json:"content"` - Card struct { - Config struct { - WideScreenMode bool `json:"wide_screen_mode"` - EnableForward bool `json:"enable_forward"` - } - Elements []larkMessageRequestCardElement `json:"elements"` - } `json:"card"` + MessageType string `json:"msg_type"` + Timestamp string `json:"timestamp"` + Sign string `json:"sign"` + Content larkTextContent `json:"content"` + Card larkCardContent `json:"card"` } type larkMessageResponse struct { @@ -46,11 +50,7 @@ type larkMessageResponse struct { Message string `json:"msg"` } -func SendLarkMessage(message *model.Message, user *model.User, channel_ *model.Channel) error { - // https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN#e1cdee9f - messageRequest := larkMessageRequest{ - MessageType: "text", - } +func getLarkAtPrefix(message *model.Message) string { atPrefix := "" if message.To != "" { if message.To == "@all" { @@ -62,6 +62,15 @@ func SendLarkMessage(message *model.Message, user *model.User, channel_ *model.C } } } + return atPrefix +} + +func SendLarkMessage(message *model.Message, user *model.User, channel_ *model.Channel) error { + // https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN#e1cdee9f + messageRequest := larkMessageRequest{ + MessageType: "text", + } + atPrefix := getLarkAtPrefix(message) if message.Content == "" { messageRequest.MessageType = "text" messageRequest.Content.Text = atPrefix + message.Description diff --git a/channel/main.go b/channel/main.go index afa409d..c9d9b1c 100644 --- a/channel/main.go +++ b/channel/main.go @@ -33,6 +33,8 @@ func SendMessage(message *model.Message, user *model.User, channel_ *model.Chann return SendOneBotMessage(message, user, channel_) case model.TypeGroup: return SendGroupMessage(message, user, channel_) + case model.TypeLarkApp: + return SendLarkAppMessage(message, user, channel_) default: return errors.New("不支持的消息通道:" + channel_.Type) } diff --git a/channel/token-store.go b/channel/token-store.go index f261106..0e65ccd 100644 --- a/channel/token-store.go +++ b/channel/token-store.go @@ -24,13 +24,14 @@ type tokenStore struct { var s tokenStore func channel2item(channel_ *model.Channel) TokenStoreItem { - if channel_.Type == model.TypeWeChatTestAccount { + switch channel_.Type { + case model.TypeWeChatTestAccount: item := &WeChatTestAccountTokenStoreItem{ AppID: channel_.AppId, AppSecret: channel_.Secret, } return item - } else if channel_.Type == model.TypeWeChatCorpAccount { + case model.TypeWeChatCorpAccount: corpId, agentId, err := parseWechatCorpAccountAppId(channel_.AppId) if err != nil { common.SysError(err.Error()) @@ -42,6 +43,12 @@ func channel2item(channel_ *model.Channel) TokenStoreItem { AgentId: agentId, } return item + case model.TypeLarkApp: + item := &LarkAppTokenStoreItem{ + AppID: channel_.AppId, + AppSecret: channel_.Secret, + } + return item } return nil } @@ -146,8 +153,12 @@ func TokenStoreRemoveUser(user *model.User) { } } +func checkTokenStoreChannelType(channelType string) bool { + return channelType == model.TypeWeChatTestAccount || channelType == model.TypeWeChatCorpAccount || channelType == model.TypeLarkApp +} + func TokenStoreAddChannel(channel *model.Channel) { - if channel.Type != model.TypeWeChatTestAccount && channel.Type != model.TypeWeChatCorpAccount { + if !checkTokenStoreChannelType(channel.Type) { return } item := channel2item(channel) @@ -158,7 +169,7 @@ func TokenStoreAddChannel(channel *model.Channel) { } func TokenStoreRemoveChannel(channel *model.Channel) { - if channel.Type != model.TypeWeChatTestAccount && channel.Type != model.TypeWeChatCorpAccount { + if !checkTokenStoreChannelType(channel.Type) { return } item := channel2item(channel) diff --git a/model/channel.go b/model/channel.go index 185c9e6..c22043d 100644 --- a/model/channel.go +++ b/model/channel.go @@ -19,6 +19,7 @@ const ( TypeNone = "none" TypeOneBot = "one_bot" TypeGroup = "group" + TypeLarkApp = "lark_app" ) type Channel struct { @@ -61,7 +62,7 @@ func GetChannelByName(name string, userId int) (*Channel, error) { } func GetTokenStoreChannels() (channels []*Channel, err error) { - err = DB.Where("type = ? or type = ?", TypeWeChatCorpAccount, TypeWeChatTestAccount).Find(&channels).Error + err = DB.Where("type in ?", []string{TypeWeChatCorpAccount, TypeWeChatTestAccount, TypeLarkApp}).Find(&channels).Error return channels, err } diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index fa62df1..eed43d2 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -9,6 +9,12 @@ export const CHANNEL_OPTIONS = [ }, { key: 'corp', text: '企业微信群机器人', value: 'corp', color: '#019d82' }, { key: 'lark', text: '飞书群机器人', value: 'lark', color: '#00d6b9' }, + { + key: 'lark_app', + text: '飞书自建应用', + value: 'lark_app', + color: '#0d71fe', + }, { key: 'ding', text: '钉钉群机器人', value: 'ding', color: '#007fff' }, { key: 'bark', text: 'Bark App', value: 'bark', color: '#ff3b30' }, { diff --git a/web/src/pages/Channel/EditChannel.js b/web/src/pages/Channel/EditChannel.js index da1ea3a..7f54142 100644 --- a/web/src/pages/Channel/EditChannel.js +++ b/web/src/pages/Channel/EditChannel.js @@ -20,7 +20,7 @@ const EditChannel = () => { url: '', other: '', corp_id: '', // only for corp_app - agent_id: '' // only for corp_app + agent_id: '', // only for corp_app }; const [inputs, setInputs] = useState(originInputs); @@ -78,14 +78,16 @@ const EditChannel = () => { localInputs.account_id += '|'; } } else if (channels.length !== targets.length) { - showError('群组通道的子通道数量与目标数量不匹配,对于不需要指定的目标请直接留空'); + showError( + '群组通道的子通道数量与目标数量不匹配,对于不需要指定的目标请直接留空' + ); return; } } if (isEditing) { res = await API.put(`/api/channel/`, { ...localInputs, - id: parseInt(channelId) + id: parseInt(channelId), }); } else { res = await API.post(`/api/channel`, localInputs); @@ -258,9 +260,9 @@ const EditChannel = () => { { key: 'plugin', text: '微信中的企业微信插件', - value: 'plugin' + value: 'plugin', }, - { key: 'app', text: '企业微信 APP', value: 'app' } + { key: 'app', text: '企业微信 APP', value: 'app' }, ]} value={inputs.other} onChange={handleInputChange} @@ -476,9 +478,11 @@ const EditChannel = () => { return ( <> - 通过 OneBot 协议进行推送,可以使用 cqhttp 等实现。 - 利用 OneBot 协议可以实现推送 QQ 消息。 + 通过 OneBot 协议进行推送,可以使用{' '} + + cqhttp + {' '} + 等实现。 利用 OneBot 协议可以实现推送 QQ 消息。 { 对渠道进行分组,然后在推送时选择分组进行推送,可以实现一次性推送到多个渠道的功能。

- 推送目标如若不填,则使用子渠道的默认推送目标。如果填写,请务必全部按顺序填写,对于不需要指定的直接留空即可,例如 123456789||@wechat,两个连续的分隔符表示跳过该渠道。 + 推送目标如若不填,则使用子渠道的默认推送目标。如果填写,请务必全部按顺序填写,对于不需要指定的直接留空即可,例如{' '} + 123456789||@wechat,两个连续的分隔符表示跳过该渠道。 { ); + case 'lark_app': + return ( + <> + + 通过飞书自建应用进行推送,点击前往配置: + + 飞书开放平台 + + 。 +
+ 需要为应用添加机器人能力:应用能力->添加应用能力—>机器人。 +
+ 需要为应用添加消息发送权限:开发配置->权限管理->权限配置->搜索「获取与发送单聊、群组消息」->开通权限。 +
+ 注意,添加完成权限后需要发布版本提交审核才能见效。 +
+ 注意,推送目标的格式为: + + 类型:ID + + ,详见飞书 + + 开发文档 + + 中查询参数一节。 +
+ + + + + + + ); + case 'none': return ( <>