2026-07-02 10:53:44 +08:00

170 lines
4.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dingtalk
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
}
type MarkdownMessage struct {
Title string
Text string
}
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
now func() time.Time
}
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
}
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签名再 Base64 并作为 URL query 参数传递。
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"`
}