From d519cfd16e5b5a3cc6e84c1f44ad9ef4ba4d6199 Mon Sep 17 00:00:00 2001 From: voidxxl7 Date: Mon, 22 Dec 2025 16:10:44 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B8=A0=E9=81=93=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/notifier.go | 120 ++++++++++++++++++ web/src/api/property.ts | 2 +- .../pages/Settings/NotificationChannels.tsx | 111 +++++++++++++++- 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/internal/service/notifier.go b/internal/service/notifier.go index 1cf9a79..cf3593b 100644 --- a/internal/service/notifier.go +++ b/internal/service/notifier.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/dushixiang/pika/internal/models" "github.com/dushixiang/pika/internal/utils" + "github.com/go-orz/cache" "github.com/valyala/fasttemplate" "go.uber.org/zap" "gopkg.in/gomail.v2" @@ -278,6 +280,85 @@ func (n *Notifier) sendWeCom(ctx context.Context, webhook, message string) error return nil } +var wecomAppAccessTokenCache = cache.New[string, string](time.Minute) +func (n *Notifier) getWecomAppToken(ctx context.Context, origin, corpId, corpSecret string) (string, error) { + key := fmt.Sprintf("%s#%s", corpId, corpSecret) + if token, found := wecomAppAccessTokenCache.Get(key); found { + return token, nil + } + + accessTokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", origin, corpId, corpSecret) + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, accessTokenURL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var tokenResp struct { + AccessToken string `json:"access_token"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + ExpiresIn int64 `json:"expires_in"` //Second + } + + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", err + } + + if tokenResp.ErrCode != 0 { + return "", errors.New(tokenResp.ErrMsg) + } + + // 提前两分钟过期 + token := tokenResp.AccessToken + expires := time.Duration(tokenResp.ExpiresIn)*time.Second - 2*time.Minute + wecomAppAccessTokenCache.Set(key, token, expires) + return token, nil + +} + +// sendWeComApp 发送企业应用微信通知 +func (n *Notifier) sendWeComApp(ctx context.Context, origin, corpId, corpSecret string, agentId int, toUser string, message string) error { + token, err := n.getWecomAppToken(ctx, origin, corpId, corpSecret) + if err != nil { + return fmt.Errorf("获取企业微信应用ACCESS_TOKEN失败:%s", err) + } + + webhook := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", origin, token) + + body := map[string]interface{}{ + "touser": toUser, + "msgtype": "text", + "agentid": agentId, + "text": map[string]string{ + "content": message, + }, + "safe": 0, + } + + result, err := n.sendJSONRequest(ctx, webhook, body) + if err != nil { + return err + } + + var sendRespBody struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + + if err := json.Unmarshal(result, &sendRespBody); err != nil { + return err + } + + if sendRespBody.ErrCode != 0 { + return fmt.Errorf("%s", sendRespBody.ErrMsg) + } + + return nil +} + // sendFeishu 发送飞书通知 func (n *Notifier) sendFeishu(ctx context.Context, webhook, message string) error { body := map[string]interface{}{ @@ -651,6 +732,36 @@ func (n *Notifier) sendWeComByConfig(ctx context.Context, config map[string]inte return n.sendWeCom(ctx, webhook, message) } +// sendWeComAppByConfig 根据配置发送企业微信应用通知 +func (n *Notifier) sendWeComAppByConfig(ctx context.Context, config map[string]interface{}, message string) error { + origin := "https://qyapi.weixin.qq.com" + if v, ok := config["origin"].(string); ok && v != "" { + origin = v + } + + toUser := "@all" + if v, ok := config["toUser"].(string); ok && v != "" { + toUser = v + } + + corpId, ok := config["corpId"].(string) + if !ok || corpId == "" { + return fmt.Errorf("企业微信应用配置缺少 corpid") + } + + corpSecret, _ := config["corpSecret"].(string) + if !ok || corpSecret == "" { + return fmt.Errorf("企业微信应用配置缺少 corpsecret") + } + + agentIdf, ok := config["agentId"].(float64) + if !ok || agentIdf <= 0 { + return fmt.Errorf("企业微信应用配置缺少 agentid") + } + + return n.sendWeComApp(ctx, origin, corpId, corpSecret, int(agentIdf), toUser, message) +} + // sendFeishuByConfig 根据配置发送飞书通知 func (n *Notifier) sendFeishuByConfig(ctx context.Context, config map[string]interface{}, message string) error { secretKey, ok := config["secretKey"].(string) @@ -748,6 +859,8 @@ func (n *Notifier) SendNotificationByConfig(ctx context.Context, channelConfig * return n.sendDingTalkByConfig(ctx, channelConfig.Config, message) case "wecom": return n.sendWeComByConfig(ctx, channelConfig.Config, message) + case "wecomApp": + return n.sendWeComAppByConfig(ctx, channelConfig.Config, message) case "feishu": return n.sendFeishuByConfig(ctx, channelConfig.Config, message) case "telegram": @@ -792,6 +905,11 @@ func (n *Notifier) SendWeComByConfig(ctx context.Context, config map[string]inte return n.sendWeComByConfig(ctx, config, message) } +// SendWeComAppByConfig 导出方法供外部调用 +func (n *Notifier) SendWeComAppByConfig(ctx context.Context, config map[string]interface{}, message string) error { + return n.sendWeComAppByConfig(ctx, config, message) +} + // SendFeishuByConfig 导出方法供外部调用 func (n *Notifier) SendFeishuByConfig(ctx context.Context, config map[string]interface{}, message string) error { return n.sendFeishuByConfig(ctx, config, message) @@ -835,6 +953,8 @@ func (n *Notifier) SendTestNotification(ctx context.Context, channelType string, return n.sendDingTalkByConfig(ctx, config, message) case "wecom": return n.sendWeComByConfig(ctx, config, message) + case "wecomApp": + return n.sendWeComAppByConfig(ctx, config, message) case "feishu": return n.sendFeishuByConfig(ctx, config, message) case "telegram": diff --git a/web/src/api/property.ts b/web/src/api/property.ts index b50a760..0ad6fa8 100644 --- a/web/src/api/property.ts +++ b/web/src/api/property.ts @@ -54,7 +54,7 @@ const PROPERTY_ID_NOTIFICATION_CHANNELS = 'notification_channels'; // 通知渠道配置(通过 type 标识,不再使用独立ID) export interface NotificationChannel { - type: 'dingtalk' | 'wecom' | 'feishu' | 'email' | 'webhook' | 'telegram'; // 渠道类型,作为唯一标识 + type: 'dingtalk' | 'wecom' | 'wecomApp' | 'feishu' | 'email' | 'webhook' | 'telegram'; // 渠道类型,作为唯一标识 enabled: boolean; // 是否启用 config: Record; // JSON配置,根据type不同而不同 } diff --git a/web/src/pages/Settings/NotificationChannels.tsx b/web/src/pages/Settings/NotificationChannels.tsx index dbd0687..2505dc3 100644 --- a/web/src/pages/Settings/NotificationChannels.tsx +++ b/web/src/pages/Settings/NotificationChannels.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { App, Button, Card, Collapse, Form, Input, Select, Space, Spin, Switch } from 'antd'; +import { App, Button, Card, Collapse, Form, Input, InputNumber, Select, Space, Spin, Switch } from 'antd'; import { TestTube } from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -18,6 +18,7 @@ const NotificationChannels = () => { // 监听各个通知渠道的启用状态 const dingtalkEnabled = Form.useWatch('dingtalkEnabled', form); const wecomEnabled = Form.useWatch('wecomEnabled', form); + const wecomAppEnabled = Form.useWatch('wecomAppEnabled', form); const feishuEnabled = Form.useWatch('feishuEnabled', form); const telegramEnabled = Form.useWatch('telegramEnabled', form); const emailEnabled = Form.useWatch('emailEnabled', form); @@ -65,6 +66,13 @@ const NotificationChannels = () => { } else if (channel.type === 'wecom') { formValues.wecomEnabled = channel.enabled; formValues.wecomSecretKey = channel.config?.secretKey || ''; + } else if (channel.type === 'wecomApp') { + formValues.wecomAppEnabled = channel.enabled; + formValues.wecomAppOrigin = channel.config?.origin || 'https://qyapi.weixin.qq.com'; + formValues.wecomAppCorpId = channel.config?.corpId || ''; + formValues.wecomAppCorpSecret = channel.config?.corpSecret || ''; + formValues.wecomAppAgentId = channel.config?.agentId; + formValues.wecomAppToUser = channel.config?.toUser || '@all'; } else if (channel.type === 'feishu') { formValues.feishuEnabled = channel.enabled; formValues.feishuSecretKey = channel.config?.secretKey || ''; @@ -130,6 +138,21 @@ const NotificationChannels = () => { }); } + // 企业微信应用 + if (values.wecomAppEnabled || values.wecomAppOrigin) { + newChannels.push({ + type: 'wecomApp', + enabled: values.wecomAppEnabled || false, + config: { + origin: values.wecomAppOrigin || '', + corpId: values.wecomAppCorpId || '', + corpSecret: values.wecomAppCorpSecret || '', + agentId: values.wecomAppAgentId, + toUser: values.wecomAppToUser || '', + }, + }); + } + // 飞书 if (values.feishuEnabled || values.feishuSecretKey) { newChannels.push({ @@ -333,6 +356,92 @@ const NotificationChannels = () => { + {/* 企业微信应用通知 */} + +
企业微信应用通知
+
+ 了解更多:https://developer.work.weixin.qq.com/document/path/90236 +
+ + } + type="inner" + className="mb-4" + extra={ + + } + > + + + + + prevValues.wecomAppEnabled !== currentValues.wecomAppEnabled} + > + {({ getFieldValue }) => + getFieldValue('wecomAppEnabled') ? ( + <> + + + + + + + + + + + + + + + + + ) : null + } + +
+ {/* 飞书通知 */}