175 lines
4.6 KiB
Go
175 lines
4.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"
|
||
)
|
||
|
||
// Config 保存钉钉机器人请求所需的敏感参数;调用方必须从运行环境注入真实 webhook。
|
||
type Config struct {
|
||
WebhookURL string
|
||
Secret string
|
||
AtMobiles []string
|
||
AtAll bool
|
||
RequestTimeout time.Duration
|
||
}
|
||
|
||
// MarkdownMessage 是当前财务通知使用的唯一消息格式,避免业务层拼钉钉专属 JSON。
|
||
type MarkdownMessage struct {
|
||
Title string
|
||
Text string
|
||
}
|
||
|
||
// Client 封装钉钉机器人加签、HTTP 请求和返回码校验,gateway/admin 复用同一套边界。
|
||
type Client struct {
|
||
webhookURL string
|
||
secret string
|
||
atMobiles []string
|
||
atAll bool
|
||
httpClient *http.Client
|
||
now func() time.Time
|
||
}
|
||
|
||
// New 创建钉钉机器人客户端;httpClient 仅用于测试或特殊运行时注入。
|
||
func New(config Config, httpClient *http.Client) (*Client, error) {
|
||
webhookURL := strings.TrimSpace(config.WebhookURL)
|
||
if webhookURL == "" {
|
||
return nil, errors.New("dingtalk webhook url is required")
|
||
}
|
||
if _, err := url.ParseRequestURI(webhookURL); err != nil {
|
||
return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err)
|
||
}
|
||
timeout := config.RequestTimeout
|
||
if timeout <= 0 {
|
||
timeout = 3 * time.Second
|
||
}
|
||
if httpClient == nil {
|
||
httpClient = &http.Client{Timeout: timeout}
|
||
}
|
||
return &Client{
|
||
webhookURL: webhookURL,
|
||
secret: strings.TrimSpace(config.Secret),
|
||
atMobiles: compactStrings(config.AtMobiles),
|
||
atAll: config.AtAll,
|
||
httpClient: httpClient,
|
||
now: func() time.Time { return time.Now().UTC() },
|
||
}, nil
|
||
}
|
||
|
||
// SendMarkdown 发送 markdown 消息;钉钉业务错误码也视为失败,调用方决定是否回滚业务。
|
||
func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error {
|
||
if c == nil || c.httpClient == nil {
|
||
return errors.New("dingtalk client is not configured")
|
||
}
|
||
title := strings.TrimSpace(message.Title)
|
||
text := strings.TrimSpace(message.Text)
|
||
if title == "" || text == "" {
|
||
return errors.New("dingtalk markdown title and text are required")
|
||
}
|
||
body, err := json.Marshal(markdownPayload{
|
||
MsgType: "markdown",
|
||
Markdown: markdownBody{
|
||
Title: title,
|
||
Text: text,
|
||
},
|
||
At: atBody{
|
||
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: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
|
||
}
|
||
var result sendResponse
|
||
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 := c.now().UnixMilli()
|
||
query := parsed.Query()
|
||
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
|
||
query.Set("sign", sign(timestamp, c.secret))
|
||
parsed.RawQuery = query.Encode()
|
||
return parsed.String()
|
||
}
|
||
|
||
func sign(timestamp int64, secret string) string {
|
||
// 钉钉加签要求 timestamp + "\n" + secret 作为明文,secret 同时作为 HMAC key。
|
||
payload := fmt.Sprintf("%d\n%s", timestamp, secret)
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
_, _ = mac.Write([]byte(payload))
|
||
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
func compactStrings(values []string) []string {
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||
out = append(out, trimmed)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
type markdownPayload struct {
|
||
MsgType string `json:"msgtype"`
|
||
Markdown markdownBody `json:"markdown"`
|
||
At atBody `json:"at"`
|
||
}
|
||
|
||
type markdownBody struct {
|
||
Title string `json:"title"`
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type atBody struct {
|
||
AtMobiles []string `json:"atMobiles"`
|
||
IsAtAll bool `json:"isAtAll"`
|
||
}
|
||
|
||
type sendResponse struct {
|
||
ErrCode int `json:"errcode"`
|
||
ErrMsg string `json:"errmsg"`
|
||
}
|