Skip to content

Commit

Permalink
feat: support lark app now (close #41)
Browse files Browse the repository at this point in the history
  • Loading branch information
songquanpeng committed May 8, 2023
1 parent 00a9f02 commit 5b5a561
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 42 deletions.
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ _✨ 搭建专属于你的消息推送服务,支持多种消息推送方式,
+ QQ,
+ 企业微信应用号,
+ 企业微信群机器人
+ 飞书自建应用
+ 飞书群机器人,
+ 钉钉群机器人,
+ Bark App,
Expand Down Expand Up @@ -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`:选填,推送给指定用户,如果不填则默认推送给自己,受限于具体的消息推送方式,有些推送方式不支持此项。
Expand Down
161 changes: 161 additions & 0 deletions channel/lark-app.go
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 27 additions & 18 deletions channel/lark.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,32 @@ 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 {
Code int `json:"code"`
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" {
Expand 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
Expand Down
2 changes: 2 additions & 0 deletions channel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
19 changes: 15 additions & 4 deletions channel/token-store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion model/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
TypeNone = "none"
TypeOneBot = "one_bot"
TypeGroup = "group"
TypeLarkApp = "lark_app"
)

type Channel struct {
Expand Down Expand Up @@ -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
}

Expand Down
6 changes: 6 additions & 0 deletions web/src/constants/channel.constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
{
Expand Down
Loading

0 comments on commit 5b5a561

Please sign in to comment.