Skip to content

Commit cec2e3d

Browse files
committed
feat(notify): 企业微信+飞书群机器人渠道——补齐国内三巨头(钉钉/企业微信/飞书),飞书支持官方加签
1 parent 2471424 commit cec2e3d

15 files changed

Lines changed: 597 additions & 1 deletion

File tree

internal/api/sitesettings/handler.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,8 @@ func validateValue(key string, raw json.RawMessage) error {
166166
return fmt.Errorf("%s 需要是 http(s) URL", key)
167167
}
168168
}
169-
if key == settings.KeyNotifyDingtalkURL || key == settings.KeyNotifyWebhookURL {
169+
if key == settings.KeyNotifyDingtalkURL || key == settings.KeyNotifyWebhookURL ||
170+
key == settings.KeyNotifyWecomURL || key == settings.KeyNotifyFeishuURL {
170171
if v != "" && !isHTTPLikeURL(v) {
171172
return fmt.Errorf("%s 需要是 http(s) URL", key)
172173
}

internal/platform/approvals/notification.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const (
4545
ChannelDingTalk NotificationChannel = "dingtalk"
4646
ChannelWeChat NotificationChannel = "wechat"
4747
ChannelSlack NotificationChannel = "slack"
48+
ChannelWecom NotificationChannel = "wecom"
49+
ChannelFeishu NotificationChannel = "feishu"
4850
ChannelInApp NotificationChannel = "in_app"
4951
)
5052

@@ -828,3 +830,96 @@ func validateEmailAddress(addr string) (string, error) {
828830
}
829831
return parsed.Address, nil
830832
}
833+
834+
// WecomSender sends 企业微信群机器人 notifications.
835+
//
836+
// webhookURL 为群机器人 webhook(https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…)。
837+
// 企业微信机器人无加签机制,key 由 URL 携带。URL 为空时视为未配置,Send 直接 no-op。
838+
type WecomSender struct {
839+
webhookURL string
840+
841+
// postJSON 与 DingTalkSender 同款测试注入口。
842+
postJSON func(ctx context.Context, url string, payload []byte) error
843+
}
844+
845+
// NewWecomSender creates a new 企业微信 sender.
846+
func NewWecomSender(webhookURL string) *WecomSender {
847+
return &WecomSender{webhookURL: webhookURL}
848+
}
849+
850+
// Send posts a markdown message to the group bot.
851+
func (w *WecomSender) Send(ctx context.Context, recipient string, event NotificationEvent) error {
852+
if w.webhookURL == "" {
853+
return nil
854+
}
855+
payload := map[string]interface{}{
856+
"msgtype": "markdown",
857+
"markdown": map[string]string{
858+
"content": fmt.Sprintf("### %s\n\n%s", event.Title, event.Message),
859+
},
860+
}
861+
body, err := json.Marshal(payload)
862+
if err != nil {
863+
return err
864+
}
865+
postJSON := w.postJSON
866+
if postJSON == nil {
867+
postJSON = defaultPostJSON
868+
}
869+
return postJSON(ctx, w.webhookURL, body)
870+
}
871+
872+
// Channel returns the channel type.
873+
func (w *WecomSender) Channel() NotificationChannel {
874+
return ChannelWecom
875+
}
876+
877+
// FeishuSender sends 飞书群机器人 notifications.
878+
//
879+
// webhookURL 为自定义机器人 webhook(https://open.feishu.cn/open-apis/bot/v2/hook/…)。
880+
// secret 为可选的加签密钥:官方校验方式为 sign = base64(hmac_sha256(key=timestamp+"\n"+secret, data="")),
881+
// payload 顶层附带 timestamp(秒)与 sign。
882+
type FeishuSender struct {
883+
webhookURL string
884+
secret string
885+
886+
postJSON func(ctx context.Context, url string, payload []byte) error
887+
}
888+
889+
// NewFeishuSender creates a new 飞书 sender.
890+
func NewFeishuSender(webhookURL, secret string) *FeishuSender {
891+
return &FeishuSender{webhookURL: webhookURL, secret: secret}
892+
}
893+
894+
// Send posts a text message to the group bot.
895+
func (f *FeishuSender) Send(ctx context.Context, recipient string, event NotificationEvent) error {
896+
if f.webhookURL == "" {
897+
return nil
898+
}
899+
payload := map[string]interface{}{
900+
"msg_type": "text",
901+
"content": map[string]string{
902+
"text": fmt.Sprintf("%s\n%s", event.Title, event.Message),
903+
},
904+
}
905+
if f.secret != "" {
906+
ts := time.Now().Unix()
907+
mac := hmac.New(sha256.New, []byte(fmt.Sprintf("%d\n%s", ts, f.secret)))
908+
payload["timestamp"] = fmt.Sprintf("%d", ts)
909+
payload["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
910+
}
911+
body, err := json.Marshal(payload)
912+
if err != nil {
913+
return err
914+
}
915+
postJSON := f.postJSON
916+
if postJSON == nil {
917+
postJSON = defaultPostJSON
918+
}
919+
return postJSON(ctx, f.webhookURL, body)
920+
}
921+
922+
// Channel returns the channel type.
923+
func (f *FeishuSender) Channel() NotificationChannel {
924+
return ChannelFeishu
925+
}

internal/platform/approvals/notification_extra_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package approvals
22

33
import (
44
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/json"
59
"errors"
610
"io"
711
"net/http"
@@ -372,3 +376,68 @@ func TestEmailSender_SendRejectsInjectedRecipient(t *testing.T) {
372376
require.Error(t, err)
373377
assert.Contains(t, err.Error(), "invalid email recipient")
374378
}
379+
380+
// 企业微信:markdown 载荷、无加签、URL 原样。
381+
func TestWecomSender_Payload(t *testing.T) {
382+
var gotURL string
383+
var gotBody []byte
384+
w := NewWecomSender("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=k1")
385+
w.postJSON = func(ctx context.Context, url string, payload []byte) error {
386+
gotURL, gotBody = url, payload
387+
return nil
388+
}
389+
require.NoError(t, w.Send(context.Background(), "", NotificationEvent{
390+
Title: "审批待办", Message: "订单退款需要审批",
391+
}))
392+
assert.Equal(t, "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=k1", gotURL)
393+
assert.Contains(t, string(gotBody), `"msgtype":"markdown"`)
394+
assert.Contains(t, string(gotBody), "审批待办")
395+
assert.Equal(t, ChannelWecom, w.Channel())
396+
397+
// 未配置 URL:no-op
398+
idle := NewWecomSender("")
399+
assert.NoError(t, idle.Send(context.Background(), "", NotificationEvent{Title: "x"}))
400+
}
401+
402+
// 飞书:text 载荷 + 加签(timestamp/sign 顶层字段,签名可复现校验)。
403+
func TestFeishuSender_PayloadAndSign(t *testing.T) {
404+
var gotURL string
405+
var gotBody []byte
406+
f := NewFeishuSender("https://open.feishu.cn/open-apis/bot/v2/hook/h1", "sec-1")
407+
f.postJSON = func(ctx context.Context, url string, payload []byte) error {
408+
gotURL, gotBody = url, payload
409+
return nil
410+
}
411+
require.NoError(t, f.Send(context.Background(), "", NotificationEvent{
412+
Title: "告警", Message: "agent 离线",
413+
}))
414+
assert.Equal(t, "https://open.feishu.cn/open-apis/bot/v2/hook/h1", gotURL)
415+
416+
var payload map[string]interface{}
417+
require.NoError(t, json.Unmarshal(gotBody, &payload))
418+
assert.Equal(t, "text", payload["msg_type"])
419+
content := payload["content"].(map[string]interface{})
420+
assert.Contains(t, content["text"], "告警")
421+
assert.Contains(t, content["text"], "agent 离线")
422+
423+
// 加签可复现:timestamp + sign 与本地 HMAC 结果一致
424+
ts, ok := payload["timestamp"].(string)
425+
require.True(t, ok)
426+
sign, ok := payload["sign"].(string)
427+
require.True(t, ok)
428+
mac := hmac.New(sha256.New, []byte(ts+"\nsec-1"))
429+
expect := base64.StdEncoding.EncodeToString(mac.Sum(nil))
430+
assert.Equal(t, expect, sign)
431+
assert.Equal(t, ChannelFeishu, f.Channel())
432+
433+
// 无密钥:不携带 timestamp/sign
434+
noSign := NewFeishuSender("https://open.feishu.cn/hook/h2", "")
435+
noSign.postJSON = func(ctx context.Context, url string, payload []byte) error {
436+
var p map[string]interface{}
437+
require.NoError(t, json.Unmarshal(payload, &p))
438+
assert.NotContains(t, p, "sign")
439+
assert.NotContains(t, p, "timestamp")
440+
return nil
441+
}
442+
require.NoError(t, noSign.Send(context.Background(), "", NotificationEvent{Title: "x"}))
443+
}

internal/platform/settings/layered.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ const (
5858
KeyNotifyDingtalkSecret = "notification.dingtalkSecret" // string
5959
KeyNotifyWebhookURL = "notification.webhookUrl" // string
6060
KeyNotifyWebhookSecret = "notification.webhookSecret" // string
61+
KeyNotifyWecomURL = "notification.wecomUrl" // string
62+
KeyNotifyFeishuURL = "notification.feishuUrl" // string
63+
KeyNotifyFeishuSecret = "notification.feishuSecret" // string
6164
KeyNotifyInAppEnabled = "notification.inAppEnabled" // bool
6265

6366
// 登录方式(外部身份源,L3 运行时配置——Harbor 模式:yaml 仅作
@@ -93,6 +96,7 @@ var ValidKeys = map[string]struct{}{
9396
KeyNotifySMTPUser: {}, KeyNotifySMTPPassword: {}, KeyNotifySMTPFrom: {},
9497
KeyNotifyDingtalkURL: {}, KeyNotifyDingtalkSecret: {},
9598
KeyNotifyWebhookURL: {}, KeyNotifyWebhookSecret: {}, KeyNotifyInAppEnabled: {},
99+
KeyNotifyWecomURL: {}, KeyNotifyFeishuURL: {}, KeyNotifyFeishuSecret: {},
96100

97101
KeyAuthLdapEnabled: {}, KeyAuthLdapAddr: {}, KeyAuthLdapBaseDn: {},
98102
KeyAuthLdapBindDn: {}, KeyAuthLdapBindPassword: {}, KeyAuthLdapUserFilter: {},
@@ -406,6 +410,10 @@ type NotificationSnapshot struct {
406410
WebhookURL string `json:"webhookUrl"`
407411
WebhookSecretSet bool `json:"webhookSecretSet"`
408412
WebhookSecretMasked string `json:"webhookSecretMasked,omitempty"`
413+
WecomURL string `json:"wecomUrl"`
414+
FeishuURL string `json:"feishuUrl"`
415+
FeishuSecretSet bool `json:"feishuSecretSet"`
416+
FeishuSecretMasked string `json:"feishuSecretMasked,omitempty"`
409417
InAppEnabled bool `json:"inAppEnabled"`
410418
Sources map[string]string `json:"sources"`
411419
}
@@ -420,6 +428,8 @@ func (l *Layered) NotificationSnapshot() NotificationSnapshot {
420428
SMTPFrom: stringOrEmpty(l.GetString(context.Background(), KeyNotifySMTPFrom)),
421429
DingtalkURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyDingtalkURL)),
422430
WebhookURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyWebhookURL)),
431+
WecomURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyWecomURL)),
432+
FeishuURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyFeishuURL)),
423433
InAppEnabled: l.GetBool(KeyNotifyInAppEnabled, true),
424434
Sources: map[string]string{},
425435
}
@@ -437,6 +447,7 @@ func (l *Layered) NotificationSnapshot() NotificationSnapshot {
437447
snap.SMTPPasswordSet, snap.SMTPPasswordMasked = mask(KeyNotifySMTPPassword)
438448
snap.DingtalkSecretSet, snap.DingtalkSecretMasked = mask(KeyNotifyDingtalkSecret)
439449
snap.WebhookSecretSet, snap.WebhookSecretMasked = mask(KeyNotifyWebhookSecret)
450+
snap.FeishuSecretSet, snap.FeishuSecretMasked = mask(KeyNotifyFeishuSecret)
440451
return snap
441452
}
442453

@@ -468,6 +479,9 @@ type NotifyChannelsResolved struct {
468479
DingtalkSecret string
469480
WebhookURL string
470481
WebhookSecret string
482+
WecomURL string
483+
FeishuURL string
484+
FeishuSecret string
471485
InAppEnabled bool
472486
EmailEnabled bool
473487
}
@@ -479,6 +493,9 @@ func (l *Layered) NotifyChannels() NotifyChannelsResolved {
479493
DingtalkSecret: stringOrEmpty(l.GetString(context.Background(), KeyNotifyDingtalkSecret)),
480494
WebhookURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyWebhookURL)),
481495
WebhookSecret: stringOrEmpty(l.GetString(context.Background(), KeyNotifyWebhookSecret)),
496+
WecomURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyWecomURL)),
497+
FeishuURL: stringOrEmpty(l.GetString(context.Background(), KeyNotifyFeishuURL)),
498+
FeishuSecret: stringOrEmpty(l.GetString(context.Background(), KeyNotifyFeishuSecret)),
482499
InAppEnabled: l.GetBool(KeyNotifyInAppEnabled, true),
483500
EmailEnabled: l.GetBool(KeyNotifyEmailEnabled, false),
484501
}

internal/service/notify/notify_extra_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"net/http"
1010
"net/http/httptest"
1111
"sync"
12+
13+
gsqlite "github.com/glebarez/sqlite"
1214
"testing"
1315

1416
"github.com/cuihairu/croupier/internal/model"
@@ -155,3 +157,44 @@ func TestDispatch_OnlyLayeredNoMessageModel(t *testing.T) {
155157
svc.Dispatch(context.Background(), notifyservice.Event{Title: "t", Recipients: []string{"a"}})
156158
})
157159
}
160+
161+
// 企业微信/飞书渠道接线:配置 URL 后 dispatchExternal 投递两渠道。
162+
func TestDispatchExternal_WecomAndFeishu(t *testing.T) {
163+
var wecomBody, feishuBody []byte
164+
wecomSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+
wecomBody, _ = io.ReadAll(r.Body)
166+
w.WriteHeader(200)
167+
}))
168+
defer wecomSrv.Close()
169+
feishuSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
170+
feishuBody, _ = io.ReadAll(r.Body)
171+
w.WriteHeader(200)
172+
}))
173+
defer feishuSrv.Close()
174+
175+
settings.ResetForTest()
176+
db, err := gorm.Open(gsqlite.Open(t.TempDir()+"/notify-wf.db"), &gorm.Config{})
177+
require.NoError(t, err)
178+
require.NoError(t, model.AutoMigrate(db))
179+
settingStore := model.NewPlatformSettingModel(db)
180+
layered := settings.InitLayered(context.Background(), &settings.ConfigInput{}, settingStore)
181+
setStr := func(key, val string) {
182+
require.NoError(t, settingStore.Set(context.Background(), key, json.RawMessage(`"`+val+`"`), "test"))
183+
layered.Reload(context.Background(), settingStore)
184+
}
185+
setStr("notification.wecomUrl", wecomSrv.URL)
186+
setStr("notification.feishuUrl", feishuSrv.URL)
187+
setStr("notification.feishuSecret", "s")
188+
189+
svc := notifyservice.New(layered, nil)
190+
svc.Dispatch(context.Background(), notifyservice.Event{
191+
Type: "approval_required", Title: "T", Message: "M",
192+
})
193+
194+
require.NotEmpty(t, wecomBody, "wecom 应收到投递")
195+
assert.Contains(t, string(wecomBody), `"msgtype":"markdown"`)
196+
assert.Contains(t, string(wecomBody), "T")
197+
198+
require.NotEmpty(t, feishuBody, "feishu 应收到投递")
199+
assert.Contains(t, string(feishuBody), `"msg_type":"text"`)
200+
}

internal/service/notify/service.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
// 渠道矩阵(docs/architecture/config-layering.md notification.*):
44
// - 站内信(默认开启):写 messages 表 + SSE 推送,复用 message 模块
55
// - 钉钉群机器人:notification.dingtalkUrl/Secret 配置后启用
6+
// - 企业微信群机器人:notification.wecomUrl 配置后启用(无加签,key 在 URL)
7+
// - 飞书群机器人:notification.feishuUrl/Secret 配置后启用(加签可选)
68
// - 通用 webhook:notification.webhookUrl/Secret 配置后启用
79
// - 邮件:notification.emailEnabled + smtp.* 配置后启用
810
//
@@ -105,6 +107,18 @@ func (s *Service) dispatchExternal(ctx context.Context, ev Event) {
105107
slog.WarnContext(ctx, "notify: dingtalk send failed", "error", err)
106108
}
107109
}
110+
if ch.WecomURL != "" {
111+
sender := approvals.NewWecomSender(ch.WecomURL)
112+
if err := sender.Send(ctx, "", approvalEvent); err != nil {
113+
slog.WarnContext(ctx, "notify: wecom send failed", "error", err)
114+
}
115+
}
116+
if ch.FeishuURL != "" {
117+
sender := approvals.NewFeishuSender(ch.FeishuURL, ch.FeishuSecret)
118+
if err := sender.Send(ctx, "", approvalEvent); err != nil {
119+
slog.WarnContext(ctx, "notify: feishu send failed", "error", err)
120+
}
121+
}
108122
if ch.WebhookURL != "" {
109123
sender := approvals.NewWebhookSender(ch.WebhookURL, ch.WebhookSecret, "croupier")
110124
if err := sender.Send(ctx, joinRecipients(ev.Recipients), approvalEvent); err != nil {

sdks/cpp/include/croupier/sdk/protocol.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ constexpr uint32_t MSG_HEARTBEAT_RESPONSE = 0x010104;
4242
constexpr uint32_t MSG_REGISTER_CAPABILITIES_REQ = 0x010105;
4343
constexpr uint32_t MSG_REGISTER_CAPABILITIES_RESP = 0x010106;
4444

45+
// F:文件下发原语(hotpatch P1 传输层)
46+
constexpr uint32_t MSG_PROVIDER_FILE_PUSH_REQ = 0x050109;
47+
constexpr uint32_t MSG_PROVIDER_FILE_PUSH_RESP = 0x05010A;
48+
4549
// ClientService (0x02xx)
4650
constexpr uint32_t MSG_REGISTER_CLIENT_REQUEST = 0x020101;
4751
constexpr uint32_t MSG_REGISTER_CLIENT_RESPONSE = 0x020102;

sdks/cpp/vcpkg.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
},
1818
{
1919
"name": "zlib"
20+
},
21+
{
22+
"name": "openssl"
2023
}
2124
],
2225
"overrides": [

sdks/java/src/main/java/io/github/cuihairu/croupier/sdk/ClientConfig.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public class ClientConfig {
5353
private boolean enableFileTransfer = false; // Enable file transfer functionality (default: false)
5454
private boolean validateInputPayloads = false; // F:provider 侧入站校验(按函数声明 input schema),默认关闭
5555
private int maxFileSize = 10485760; // Max file size in bytes (default: 10MB)
56+
private String fileStagingDir = "./croupier-staging"; // F:下发文件仅落盘至此(不自动应用)
5657

5758
// ========== Logging Configuration ==========
5859
private boolean disableLogging = false; // Disable all logging
@@ -135,6 +136,9 @@ public ClientConfig(String gameId, String serviceId) {
135136
public int getMaxFileSize() { return maxFileSize; }
136137
public void setMaxFileSize(int maxFileSize) { this.maxFileSize = maxFileSize; }
137138

139+
public String getFileStagingDir() { return fileStagingDir; }
140+
public void setFileStagingDir(String fileStagingDir) { this.fileStagingDir = fileStagingDir; }
141+
138142
public boolean isDisableLogging() { return disableLogging; }
139143
public void setDisableLogging(boolean disableLogging) { this.disableLogging = disableLogging; }
140144

0 commit comments

Comments
 (0)