133 lines
3.6 KiB
Go
133 lines
3.6 KiB
Go
package dingtalkrobot
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
WebhookURL string
|
|
Secret string
|
|
AtMobiles []string
|
|
AtAll bool
|
|
RequestTimeout time.Duration
|
|
}
|
|
|
|
// Client 位于 admin 自己的 platform 边界,避免独立 module 反向依赖根模块 hyapp/pkg。
|
|
type Client struct {
|
|
webhookURL string
|
|
secret string
|
|
atMobiles []string
|
|
atAll bool
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func New(config Config) (*Client, error) {
|
|
webhookURL := strings.TrimSpace(config.WebhookURL)
|
|
if webhookURL == "" {
|
|
return nil, errors.New("dingtalk webhook url is required")
|
|
}
|
|
parsed, err := url.ParseRequestURI(webhookURL)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return nil, errors.New("dingtalk webhook url must be an absolute http url")
|
|
}
|
|
timeout := config.RequestTimeout
|
|
if timeout <= 0 {
|
|
timeout = 3 * time.Second
|
|
}
|
|
return &Client{
|
|
webhookURL: webhookURL,
|
|
secret: strings.TrimSpace(config.Secret),
|
|
atMobiles: compactStrings(config.AtMobiles),
|
|
atAll: config.AtAll,
|
|
httpClient: &http.Client{Timeout: timeout},
|
|
}, nil
|
|
}
|
|
|
|
// SendMarkdown 将钉钉 HTTP 状态和业务 errcode 都视为失败;业务层决定通知失败不能回滚审核事实。
|
|
func (c *Client) SendMarkdown(ctx context.Context, title string, text string) error {
|
|
if c == nil || c.httpClient == nil {
|
|
return errors.New("dingtalk client is not configured")
|
|
}
|
|
title, text = strings.TrimSpace(title), strings.TrimSpace(text)
|
|
if title == "" || text == "" {
|
|
return errors.New("dingtalk markdown title and text are required")
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"msgtype": "markdown",
|
|
"markdown": map[string]string{"title": title, "text": text},
|
|
"at": map[string]any{"atMobiles": c.atMobiles, "isAtAll": c.atAll},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return fmt.Errorf("dingtalk returned http %d", resp.StatusCode)
|
|
}
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.Unmarshal(payload, &result); err != nil {
|
|
return fmt.Errorf("decode dingtalk response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) signedWebhookURL() string {
|
|
if c.secret == "" {
|
|
return c.webhookURL
|
|
}
|
|
parsed, err := url.Parse(c.webhookURL)
|
|
if err != nil {
|
|
return c.webhookURL
|
|
}
|
|
timestamp := time.Now().UTC().UnixMilli()
|
|
payload := fmt.Sprintf("%d\n%s", timestamp, c.secret)
|
|
mac := hmac.New(sha256.New, []byte(c.secret))
|
|
_, _ = mac.Write([]byte(payload))
|
|
query := parsed.Query()
|
|
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
|
|
query.Set("sign", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
|
parsed.RawQuery = query.Encode()
|
|
return parsed.String()
|
|
}
|
|
|
|
func compactStrings(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|