Browse Source

feat: support lark app now (close #41)

JustSong 2 years ago
parent
commit
5b5a56122b

+ 12 - 10
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`:选填,推送给指定用户,如果不填则默认推送给自己,受限于具体的消息推送方式,有些推送方式不支持此项。

+ 161 - 0
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
+}

+ 27 - 18
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

+ 2 - 0
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)
 	}

+ 15 - 4
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)

+ 2 - 1
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
 }
 

+ 6 - 0
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' },
   {

+ 72 - 9
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 (
           <>
             <Message>
-              通过 OneBot 协议进行推送,可以使用 <a href='https://github.com/Mrs4s/go-cqhttp'
-                                                   target='_blank'>cqhttp</a> 等实现。
-              利用 OneBot 协议可以实现推送 QQ 消息。
+              通过 OneBot 协议进行推送,可以使用{' '}
+              <a href='https://github.com/Mrs4s/go-cqhttp' target='_blank'>
+                cqhttp
+              </a>{' '}
+              等实现。 利用 OneBot 协议可以实现推送 QQ 消息。
             </Message>
             <Form.Group widths={3}>
               <Form.Input
@@ -516,7 +520,8 @@ const EditChannel = () => {
               对渠道进行分组,然后在推送时选择分组进行推送,可以实现一次性推送到多个渠道的功能。
               <br />
               <br />
-              推送目标如若不填,则使用子渠道的默认推送目标。如果填写,请务必全部按顺序填写,对于不需要指定的直接留空即可,例如 <code>123456789||@wechat</code>,两个连续的分隔符表示跳过该渠道。
+              推送目标如若不填,则使用子渠道的默认推送目标。如果填写,请务必全部按顺序填写,对于不需要指定的直接留空即可,例如{' '}
+              <code>123456789||@wechat</code>,两个连续的分隔符表示跳过该渠道。
             </Message>
             <Form.Group widths={2}>
               <Form.Input
@@ -538,6 +543,64 @@ const EditChannel = () => {
             </Form.Group>
           </>
         );
+      case 'lark_app':
+        return (
+          <>
+            <Message>
+              通过飞书自建应用进行推送,点击前往配置:
+              <a target='_blank' href='https://open.feishu.cn/app'>
+                飞书开放平台
+              </a>
+              。
+              <br />
+              需要为应用添加机器人能力:应用能力->添加应用能力—>机器人。
+              <br />
+              需要为应用添加消息发送权限:开发配置->权限管理->权限配置->搜索「获取与发送单聊、群组消息」->开通权限。
+              <br />
+              注意,添加完成权限后需要发布版本提交审核才能见效。
+              <br />
+              注意,推送目标的格式为:
+              <strong>
+                <code>类型:ID</code>
+              </strong>
+              ,详见飞书
+              <a
+                href='https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create#bc6d1214'
+                target='_blank'
+              >
+                开发文档
+              </a>
+              中查询参数一节。
+            </Message>
+            <Form.Group widths={3}>
+              <Form.Input
+                label='App ID'
+                name='app_id'
+                onChange={handleInputChange}
+                autoComplete='new-password'
+                value={inputs.app_id}
+                placeholder='应用凭证 -> App ID'
+              />
+              <Form.Input
+                label='App Secret'
+                name='secret'
+                onChange={handleInputChange}
+                autoComplete='new-password'
+                value={inputs.secret}
+                placeholder='应用凭证 -> App Secret'
+              />
+              <Form.Input
+                label='默认推送目标'
+                name='account_id'
+                onChange={handleInputChange}
+                autoComplete='new-password'
+                value={inputs.account_id}
+                placeholder='格式必须为:<类型>:<ID>,例如 open_id:123456'
+              />
+            </Form.Group>
+          </>
+        );
+
       case 'none':
         return (
           <>