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"` }