feat: support lark app now (close #41)

This commit is contained in:
JustSong
2023-05-08 10:39:30 +08:00
parent 00a9f02974
commit 5b5a56122b
8 changed files with 297 additions and 42 deletions

View File

@@ -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
channel/lark-app.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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' },
{

View File

@@ -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 (
<>