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 (
<>
123456789||@wechat,两个连续的分隔符表示跳过该渠道。
+ 推送目标如若不填,则使用子渠道的默认推送目标。如果填写,请务必全部按顺序填写,对于不需要指定的直接留空即可,例如{' '}
+ 123456789||@wechat,两个连续的分隔符表示跳过该渠道。
类型:ID
+
+ ,详见飞书
+
+ 开发文档
+
+ 中查询参数一节。
+