mirror of
https://github.com/songquanpeng/message-pusher.git
synced 2025-09-26 20:21:22 +08:00
feat: support lark app now (close #41)
This commit is contained in:
22
README.md
22
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
channel/lark-app.go
Normal file
161
channel/lark-app.go
Normal 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
|
||||
}
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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' },
|
||||
{
|
||||
|
@@ -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 (
|
||||
<>
|
||||
|
Reference in New Issue
Block a user